Explorar o código

wip(app): file tree mode

adamelmore hai 3 semanas
pai
achega
ebeed03115

+ 47 - 3
packages/app/src/components/file-tree.tsx

@@ -2,7 +2,16 @@ import { useFile } from "@/context/file"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
-import { createEffect, For, Match, splitProps, Switch, type ComponentProps, type ParentProps } from "solid-js"
+import {
+  createEffect,
+  createMemo,
+  For,
+  Match,
+  splitProps,
+  Switch,
+  type ComponentProps,
+  type ParentProps,
+} from "solid-js"
 import { Dynamic } from "solid-js/web"
 import type { FileNode } from "@opencode-ai/sdk/v2"
 
@@ -11,15 +20,45 @@ export default function FileTree(props: {
   class?: string
   nodeClass?: string
   level?: number
+  allowed?: readonly string[]
   onFileClick?: (file: FileNode) => void
 }) {
   const file = useFile()
   const level = props.level ?? 0
 
+  const filter = createMemo(() => {
+    const allowed = props.allowed
+    if (!allowed) return
+
+    const files = new Set(allowed)
+    const dirs = new Set<string>()
+
+    for (const item of allowed) {
+      const parts = item.split("/")
+      const parents = parts.slice(0, -1)
+      for (const [idx] of parents.entries()) {
+        const dir = parents.slice(0, idx + 1).join("/")
+        if (dir) dirs.add(dir)
+      }
+    }
+
+    return { files, dirs }
+  })
+
   createEffect(() => {
     void file.tree.list(props.path)
   })
 
+  const nodes = createMemo(() => {
+    const nodes = file.tree.children(props.path)
+    const current = filter()
+    if (!current) return nodes
+    return nodes.filter((node) => {
+      if (node.type === "file") return current.files.has(node.path)
+      return current.dirs.has(node.path)
+    })
+  })
+
   const Node = (
     p: ParentProps &
       ComponentProps<"div"> &
@@ -81,7 +120,7 @@ export default function FileTree(props: {
 
   return (
     <div class={`flex flex-col ${props.class ?? ""}`}>
-      <For each={file.tree.children(props.path)}>
+      <For each={nodes()}>
         {(node) => {
           const expanded = () => file.tree.state(node.path)?.expanded ?? false
           return (
@@ -102,7 +141,12 @@ export default function FileTree(props: {
                       </Node>
                     </Collapsible.Trigger>
                     <Collapsible.Content>
-                      <FileTree path={node.path} level={level + 1} onFileClick={props.onFileClick} />
+                      <FileTree
+                        path={node.path}
+                        level={level + 1}
+                        allowed={props.allowed}
+                        onFileClick={props.onFileClick}
+                      />
                     </Collapsible.Content>
                   </Collapsible>
                 </Match>

+ 773 - 603
packages/app/src/pages/session.tsx

@@ -77,6 +77,7 @@ interface SessionReviewTabProps {
   comments?: LineComment[]
   focusedComment?: { file: string; id: string } | null
   onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
+  onScrollRef?: (el: HTMLDivElement) => void
   classes?: {
     root?: string
     header?: string
@@ -146,6 +147,7 @@ function SessionReviewTab(props: SessionReviewTabProps) {
     <SessionReview
       scrollRef={(el) => {
         scroll = el
+        props.onScrollRef?.(el)
         restoreScroll()
       }}
       onScroll={handleScroll}
@@ -1015,8 +1017,71 @@ export default function Page() {
 
   const showTabs = createMemo(() => view().reviewPanel.opened())
 
+  const [fileTreeTab, setFileTreeTab] = createSignal<"changes" | "all">("changes")
+  const [reviewScroll, setReviewScroll] = createSignal<HTMLDivElement | undefined>(undefined)
+  const [pendingDiff, setPendingDiff] = createSignal<string | undefined>(undefined)
+
+  createEffect(() => {
+    if (!layout.fileTree.opened()) return
+    setFileTreeTab("changes")
+  })
+
+  const setFileTreeTabValue = (value: string) => {
+    if (value !== "changes" && value !== "all") return
+    setFileTreeTab(value)
+  }
+
+  const reviewDiffId = (path: string) => {
+    const sum = checksum(path)
+    if (!sum) return
+    return `session-review-diff-${sum}`
+  }
+
+  const scrollToReviewDiff = (path: string, behavior: ScrollBehavior) => {
+    const root = reviewScroll()
+    if (!root) return
+
+    const id = reviewDiffId(path)
+    if (!id) return
+
+    const el = document.getElementById(id)
+    if (!(el instanceof HTMLElement)) return
+    if (!root.contains(el)) return
+
+    const a = el.getBoundingClientRect()
+    const b = root.getBoundingClientRect()
+    const top = a.top - b.top + root.scrollTop
+    root.scrollTo({ top, behavior })
+  }
+
+  const focusReviewDiff = (path: string) => {
+    const current = view().review.open() ?? []
+    if (!current.includes(path)) view().review.setOpen([...current, path])
+    setPendingDiff(path)
+    requestAnimationFrame(() => scrollToReviewDiff(path, "smooth"))
+  }
+
+  createEffect(() => {
+    const pending = pendingDiff()
+    if (!pending) return
+    if (!reviewScroll()) return
+    if (!diffsReady()) return
+
+    requestAnimationFrame(() => {
+      scrollToReviewDiff(pending, "smooth")
+      setPendingDiff(undefined)
+    })
+  })
+
   const activeTab = createMemo(() => {
     const active = tabs().active()
+    if (layout.fileTree.opened() && fileTreeTab() === "all") {
+      if (active && active !== "review" && active !== "context") return normalizeTab(active)
+
+      const first = openedTabs()[0]
+      if (first) return first
+      return "review"
+    }
     if (active) return normalizeTab(active)
     if (hasReview()) return "review"
 
@@ -1033,12 +1098,27 @@ export default function Page() {
     tabs().setActive(activeTab())
   })
 
+  createEffect(() => {
+    if (!layout.fileTree.opened()) return
+    if (fileTreeTab() !== "all") return
+
+    const first = openedTabs()[0]
+    if (!first) return
+
+    const active = tabs().active()
+    if (active && active !== "review" && active !== "context") return
+    tabs().setActive(first)
+  })
+
   createEffect(() => {
     const id = params.id
     if (!id) return
     if (!hasReview()) return
 
-    const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review"
+    const wants = isDesktop()
+      ? view().reviewPanel.opened() &&
+        (layout.fileTree.opened() ? fileTreeTab() === "changes" : activeTab() === "review")
+      : store.mobileTab === "review"
     if (!wants) return
     if (diffsReady()) return
 
@@ -1814,672 +1894,762 @@ export default function Page() {
             aria-label={language.t("session.panel.reviewAndFiles")}
             class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
           >
-            <Show when={layout.fileTree.opened()}>
-              <div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
-                <div class="h-full bg-background-base border-r border-border-weak-base flex flex-col">
-                  <div class="hidden h-12 shrink-0 flex items-center px-3 border-b border-border-weak-base text-12-medium text-text-weak">
-                    Files
-                  </div>
-                  <div class="flex-1 min-h-0 overflow-y-auto no-scrollbar p-2">
-                    <FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} />
+            <div class="flex-1 min-w-0 h-full">
+              <Show when={layout.fileTree.opened() && fileTreeTab() === "changes"}>
+                <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
+                  <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                    <Switch>
+                      <Match when={hasReview()}>
+                        <Show
+                          when={diffsReady()}
+                          fallback={
+                            <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
+                          }
+                        >
+                          <SessionReviewTab
+                            diffs={diffs}
+                            view={view}
+                            diffStyle={layout.review.diffStyle()}
+                            onDiffStyleChange={layout.review.setDiffStyle}
+                            onScrollRef={setReviewScroll}
+                            onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+                            comments={comments.all()}
+                            focusedComment={comments.focus()}
+                            onFocusedCommentChange={comments.setFocus}
+                            onViewFile={(path) => {
+                              const value = file.tab(path)
+                              tabs().open(value)
+                              file.load(path)
+                            }}
+                          />
+                        </Show>
+                      </Match>
+                      <Match when={true}>
+                        <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
+                          <Mark class="w-14 opacity-10" />
+                          <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
+                        </div>
+                      </Match>
+                    </Switch>
                   </div>
                 </div>
-                <ResizeHandle
-                  direction="horizontal"
-                  size={layout.fileTree.width()}
-                  min={200}
-                  max={480}
-                  collapseThreshold={160}
-                  onResize={layout.fileTree.resize}
-                  onCollapse={layout.fileTree.close}
-                />
-              </div>
-            </Show>
-            <DragDropProvider
-              onDragStart={handleDragStart}
-              onDragEnd={handleDragEnd}
-              onDragOver={handleDragOver}
-              collisionDetector={closestCenter}
-            >
-              <DragDropSensors />
-              <ConstrainDragYAxis />
-              <Tabs value={activeTab()} onChange={openTab}>
-                <div class="sticky top-0 shrink-0 flex">
-                  <Tabs.List>
-                    <Show when={true}>
-                      <Tabs.Trigger value="review">
-                        <div class="flex items-center gap-3">
-                          <Show when={diffs()}>
-                            <DiffChanges changes={diffs()} variant="bars" />
-                          </Show>
-                          <div class="flex items-center gap-1.5">
-                            <div>{language.t("session.tab.review")}</div>
-                            <Show when={info()?.summary?.files}>
-                              <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
-                                {info()?.summary?.files ?? 0}
+              </Show>
+
+              <Show when={!layout.fileTree.opened() || fileTreeTab() === "all"}>
+                <DragDropProvider
+                  onDragStart={handleDragStart}
+                  onDragEnd={handleDragEnd}
+                  onDragOver={handleDragOver}
+                  collisionDetector={closestCenter}
+                >
+                  <DragDropSensors />
+                  <ConstrainDragYAxis />
+                  <Tabs value={activeTab()} onChange={openTab}>
+                    <div class="sticky top-0 shrink-0 flex">
+                      <Tabs.List>
+                        <Show when={!layout.fileTree.opened()}>
+                          <Tabs.Trigger value="review">
+                            <div class="flex items-center gap-3">
+                              <Show when={diffs()}>
+                                <DiffChanges changes={diffs()} variant="bars" />
+                              </Show>
+                              <div class="flex items-center gap-1.5">
+                                <div>{language.t("session.tab.review")}</div>
+                                <Show when={info()?.summary?.files}>
+                                  <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
+                                    {info()?.summary?.files ?? 0}
+                                  </div>
+                                </Show>
                               </div>
-                            </Show>
-                          </div>
-                        </div>
-                      </Tabs.Trigger>
-                    </Show>
-                    <Show when={contextOpen()}>
-                      <Tabs.Trigger
-                        value="context"
-                        closeButton={
-                          <Tooltip value={language.t("common.closeTab")} placement="bottom">
+                            </div>
+                          </Tabs.Trigger>
+                        </Show>
+                        <Show when={!layout.fileTree.opened() && contextOpen()}>
+                          <Tabs.Trigger
+                            value="context"
+                            closeButton={
+                              <Tooltip value={language.t("common.closeTab")} placement="bottom">
+                                <IconButton
+                                  icon="close"
+                                  variant="ghost"
+                                  onClick={() => tabs().close("context")}
+                                  aria-label={language.t("common.closeTab")}
+                                />
+                              </Tooltip>
+                            }
+                            hideCloseButton
+                            onMiddleClick={() => tabs().close("context")}
+                          >
+                            <div class="flex items-center gap-2">
+                              <SessionContextUsage variant="indicator" />
+                              <div>{language.t("session.tab.context")}</div>
+                            </div>
+                          </Tabs.Trigger>
+                        </Show>
+                        <SortableProvider ids={openedTabs()}>
+                          <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
+                        </SortableProvider>
+                        <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
+                          <TooltipKeybind
+                            title={language.t("command.file.open")}
+                            keybind={command.keybind("file.open")}
+                            class="flex items-center"
+                          >
                             <IconButton
-                              icon="close"
+                              icon="plus-small"
                               variant="ghost"
-                              onClick={() => tabs().close("context")}
-                              aria-label={language.t("common.closeTab")}
+                              iconSize="large"
+                              onClick={() => dialog.show(() => <DialogSelectFile />)}
+                              aria-label={language.t("command.file.open")}
                             />
-                          </Tooltip>
-                        }
-                        hideCloseButton
-                        onMiddleClick={() => tabs().close("context")}
-                      >
-                        <div class="flex items-center gap-2">
-                          <SessionContextUsage variant="indicator" />
-                          <div>{language.t("session.tab.context")}</div>
+                          </TooltipKeybind>
                         </div>
-                      </Tabs.Trigger>
-                    </Show>
-                    <SortableProvider ids={openedTabs()}>
-                      <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
-                    </SortableProvider>
-                    <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
-                      <TooltipKeybind
-                        title={language.t("command.file.open")}
-                        keybind={command.keybind("file.open")}
-                        class="flex items-center"
-                      >
-                        <IconButton
-                          icon="plus-small"
-                          variant="ghost"
-                          iconSize="large"
-                          onClick={() => dialog.show(() => <DialogSelectFile />)}
-                          aria-label={language.t("command.file.open")}
-                        />
-                      </TooltipKeybind>
+                      </Tabs.List>
                     </div>
-                  </Tabs.List>
-                </div>
-                <Show when={true}>
-                  <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
-                    <Show when={activeTab() === "review"}>
-                      <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                        <Switch>
-                          <Match when={hasReview()}>
-                            <Show
-                              when={diffsReady()}
-                              fallback={
-                                <div class="px-6 py-4 text-text-weak">
-                                  {language.t("session.review.loadingChanges")}
+                    <Show when={!layout.fileTree.opened()}>
+                      <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
+                        <Show when={activeTab() === "review"}>
+                          <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                            <Switch>
+                              <Match when={hasReview()}>
+                                <Show
+                                  when={diffsReady()}
+                                  fallback={
+                                    <div class="px-6 py-4 text-text-weak">
+                                      {language.t("session.review.loadingChanges")}
+                                    </div>
+                                  }
+                                >
+                                  <SessionReviewTab
+                                    diffs={diffs}
+                                    view={view}
+                                    diffStyle={layout.review.diffStyle()}
+                                    onDiffStyleChange={layout.review.setDiffStyle}
+                                    onScrollRef={setReviewScroll}
+                                    onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+                                    comments={comments.all()}
+                                    focusedComment={comments.focus()}
+                                    onFocusedCommentChange={comments.setFocus}
+                                    onViewFile={(path) => {
+                                      const value = file.tab(path)
+                                      tabs().open(value)
+                                      file.load(path)
+                                    }}
+                                  />
+                                </Show>
+                              </Match>
+                              <Match when={true}>
+                                <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
+                                  <Mark class="w-14 opacity-10" />
+                                  <div class="text-13-regular text-text-weak max-w-56">
+                                    No changes in this session yet
+                                  </div>
                                 </div>
-                              }
-                            >
-                              <SessionReviewTab
-                                diffs={diffs}
-                                view={view}
-                                diffStyle={layout.review.diffStyle()}
-                                onDiffStyleChange={layout.review.setDiffStyle}
-                                onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-                                comments={comments.all()}
-                                focusedComment={comments.focus()}
-                                onFocusedCommentChange={comments.setFocus}
-                                onViewFile={(path) => {
-                                  const value = file.tab(path)
-                                  tabs().open(value)
-                                  file.load(path)
-                                }}
-                              />
-                            </Show>
-                          </Match>
-                          <Match when={true}>
-                            <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
-                              <Mark class="w-14 opacity-10" />
-                              <div class="text-13-regular text-text-weak max-w-56">No changes in this session yet</div>
-                            </div>
-                          </Match>
-                        </Switch>
-                      </div>
-                    </Show>
-                  </Tabs.Content>
-                </Show>
-                <Show when={contextOpen()}>
-                  <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
-                    <Show when={activeTab() === "context"}>
-                      <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                        <SessionContextTab
-                          messages={messages}
-                          visibleUserMessages={visibleUserMessages}
-                          view={view}
-                          info={info}
-                        />
-                      </div>
+                              </Match>
+                            </Switch>
+                          </div>
+                        </Show>
+                      </Tabs.Content>
                     </Show>
-                  </Tabs.Content>
-                </Show>
-                <For each={openedTabs()}>
-                  {(tab) => {
-                    let scroll: HTMLDivElement | undefined
-                    let scrollFrame: number | undefined
-                    let pending: { x: number; y: number } | undefined
-                    let codeScroll: HTMLElement[] = []
-                    let focusToken = 0
-
-                    const path = createMemo(() => file.pathFromTab(tab))
-                    const state = createMemo(() => {
-                      const p = path()
-                      if (!p) return
-                      return file.get(p)
-                    })
-                    const contents = createMemo(() => state()?.content?.content ?? "")
-                    const cacheKey = createMemo(() => checksum(contents()))
-                    const isImage = createMemo(() => {
-                      const c = state()?.content
-                      return (
-                        c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
-                      )
-                    })
-                    const isSvg = createMemo(() => {
-                      const c = state()?.content
-                      return c?.mimeType === "image/svg+xml"
-                    })
-                    const svgContent = createMemo(() => {
-                      if (!isSvg()) return
-                      const c = state()?.content
-                      if (!c) return
-                      if (c.encoding === "base64") return base64Decode(c.content)
-                      return c.content
-                    })
-                    const svgPreviewUrl = createMemo(() => {
-                      if (!isSvg()) return
-                      const c = state()?.content
-                      if (!c) return
-                      if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
-                      return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
-                    })
-                    const imageDataUrl = createMemo(() => {
-                      if (!isImage()) return
-                      const c = state()?.content
-                      return `data:${c?.mimeType};base64,${c?.content}`
-                    })
-                    const selectedLines = createMemo(() => {
-                      const p = path()
-                      if (!p) return null
-                      if (file.ready()) return file.selectedLines(p) ?? null
-                      return handoff.files[p] ?? null
-                    })
-
-                    let wrap: HTMLDivElement | undefined
-
-                    const fileComments = createMemo(() => {
-                      const p = path()
-                      if (!p) return []
-                      return comments.list(p)
-                    })
-
-                    const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
-
-                    const [openedComment, setOpenedComment] = createSignal<string | null>(null)
-                    const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null)
-                    const [draft, setDraft] = createSignal("")
-                    const [positions, setPositions] = createSignal<Record<string, number>>({})
-                    const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
-
-                    const empty = {} as Record<string, number>
-
-                    const commentLabel = (range: SelectedLineRange) => {
-                      const start = Math.min(range.start, range.end)
-                      const end = Math.max(range.start, range.end)
-                      if (start === end) return `line ${start}`
-                      return `lines ${start}-${end}`
-                    }
-
-                    const getRoot = () => {
-                      const el = wrap
-                      if (!el) return
-
-                      const host = el.querySelector("diffs-container")
-                      if (!(host instanceof HTMLElement)) return
 
-                      const root = host.shadowRoot
-                      if (!root) return
+                    <Show when={layout.fileTree.opened() && fileTreeTab() === "all" && openedTabs().length === 0}>
+                      <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
+                        <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
+                          <Mark class="w-14 opacity-10" />
+                          <div class="text-13-regular text-text-weak max-w-56">Select a file to open</div>
+                        </div>
+                      </Tabs.Content>
+                    </Show>
 
-                      return root
-                    }
+                    <Show when={!layout.fileTree.opened() && contextOpen()}>
+                      <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
+                        <Show when={activeTab() === "context"}>
+                          <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                            <SessionContextTab
+                              messages={messages}
+                              visibleUserMessages={visibleUserMessages}
+                              view={view}
+                              info={info}
+                            />
+                          </div>
+                        </Show>
+                      </Tabs.Content>
+                    </Show>
+                    <For each={openedTabs()}>
+                      {(tab) => {
+                        let scroll: HTMLDivElement | undefined
+                        let scrollFrame: number | undefined
+                        let pending: { x: number; y: number } | undefined
+                        let codeScroll: HTMLElement[] = []
+                        let focusToken = 0
+
+                        const path = createMemo(() => file.pathFromTab(tab))
+                        const state = createMemo(() => {
+                          const p = path()
+                          if (!p) return
+                          return file.get(p)
+                        })
+                        const contents = createMemo(() => state()?.content?.content ?? "")
+                        const cacheKey = createMemo(() => checksum(contents()))
+                        const isImage = createMemo(() => {
+                          const c = state()?.content
+                          return (
+                            c?.encoding === "base64" &&
+                            c?.mimeType?.startsWith("image/") &&
+                            c?.mimeType !== "image/svg+xml"
+                          )
+                        })
+                        const isSvg = createMemo(() => {
+                          const c = state()?.content
+                          return c?.mimeType === "image/svg+xml"
+                        })
+                        const svgContent = createMemo(() => {
+                          if (!isSvg()) return
+                          const c = state()?.content
+                          if (!c) return
+                          if (c.encoding === "base64") return base64Decode(c.content)
+                          return c.content
+                        })
+                        const svgPreviewUrl = createMemo(() => {
+                          if (!isSvg()) return
+                          const c = state()?.content
+                          if (!c) return
+                          if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
+                          return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
+                        })
+                        const imageDataUrl = createMemo(() => {
+                          if (!isImage()) return
+                          const c = state()?.content
+                          return `data:${c?.mimeType};base64,${c?.content}`
+                        })
+                        const selectedLines = createMemo(() => {
+                          const p = path()
+                          if (!p) return null
+                          if (file.ready()) return file.selectedLines(p) ?? null
+                          return handoff.files[p] ?? null
+                        })
+
+                        let wrap: HTMLDivElement | undefined
+
+                        const fileComments = createMemo(() => {
+                          const p = path()
+                          if (!p) return []
+                          return comments.list(p)
+                        })
+
+                        const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection))
+
+                        const [openedComment, setOpenedComment] = createSignal<string | null>(null)
+                        const [commenting, setCommenting] = createSignal<SelectedLineRange | null>(null)
+                        const [draft, setDraft] = createSignal("")
+                        const [positions, setPositions] = createSignal<Record<string, number>>({})
+                        const [draftTop, setDraftTop] = createSignal<number | undefined>(undefined)
+
+                        const empty = {} as Record<string, number>
+
+                        const commentLabel = (range: SelectedLineRange) => {
+                          const start = Math.min(range.start, range.end)
+                          const end = Math.max(range.start, range.end)
+                          if (start === end) return `line ${start}`
+                          return `lines ${start}-${end}`
+                        }
 
-                    const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
-                      const line = Math.max(range.start, range.end)
-                      const node = root.querySelector(`[data-line="${line}"]`)
-                      if (!(node instanceof HTMLElement)) return
-                      return node
-                    }
+                        const getRoot = () => {
+                          const el = wrap
+                          if (!el) return
 
-                    const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
-                      const wrapperRect = wrapper.getBoundingClientRect()
-                      const rect = marker.getBoundingClientRect()
-                      return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
-                    }
+                          const host = el.querySelector("diffs-container")
+                          if (!(host instanceof HTMLElement)) return
 
-                    const equal = (a: Record<string, number>, b: Record<string, number>) => {
-                      const aKeys = Object.keys(a)
-                      const bKeys = Object.keys(b)
-                      if (aKeys.length !== bKeys.length) return false
-                      for (const key of aKeys) {
-                        if (a[key] !== b[key]) return false
-                      }
-                      return true
-                    }
+                          const root = host.shadowRoot
+                          if (!root) return
 
-                    const updateComments = () => {
-                      const el = wrap
-                      const root = getRoot()
-                      if (!el || !root) {
-                        setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty))
-                        setDraftTop((prev) => (prev === undefined ? prev : undefined))
-                        return
-                      }
-
-                      const next: Record<string, number> = {}
-                      for (const comment of fileComments()) {
-                        const marker = findMarker(root, comment.selection)
-                        if (!marker) continue
-                        next[comment.id] = markerTop(el, marker)
-                      }
-
-                      setPositions((prev) => (equal(prev, next) ? prev : next))
-
-                      const range = commenting()
-                      if (!range) {
-                        setDraftTop(undefined)
-                        return
-                      }
-
-                      const marker = findMarker(root, range)
-                      if (!marker) {
-                        setDraftTop(undefined)
-                        return
-                      }
-
-                      const nextTop = markerTop(el, marker)
-                      setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
-                    }
+                          return root
+                        }
 
-                    let commentFrame: number | undefined
+                        const findMarker = (root: ShadowRoot, range: SelectedLineRange) => {
+                          const line = Math.max(range.start, range.end)
+                          const node = root.querySelector(`[data-line="${line}"]`)
+                          if (!(node instanceof HTMLElement)) return
+                          return node
+                        }
 
-                    const scheduleComments = () => {
-                      if (commentFrame !== undefined) return
-                      commentFrame = requestAnimationFrame(() => {
-                        commentFrame = undefined
-                        updateComments()
-                      })
-                    }
+                        const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => {
+                          const wrapperRect = wrapper.getBoundingClientRect()
+                          const rect = marker.getBoundingClientRect()
+                          return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2)
+                        }
 
-                    createEffect(() => {
-                      fileComments()
-                      scheduleComments()
-                    })
-
-                    createEffect(() => {
-                      commenting()
-                      scheduleComments()
-                    })
-
-                    createEffect(() => {
-                      const range = commenting()
-                      if (!range) return
-                      setDraft("")
-                    })
-
-                    createEffect(() => {
-                      const focus = comments.focus()
-                      const p = path()
-                      if (!focus || !p) return
-                      if (focus.file !== p) return
-                      if (activeTab() !== tab) return
-
-                      const target = fileComments().find((comment) => comment.id === focus.id)
-                      if (!target) return
-
-                      focusToken++
-                      const token = focusToken
-
-                      setOpenedComment(target.id)
-                      setCommenting(null)
-                      file.setSelectedLines(p, target.selection)
-
-                      const scrollTo = (attempt: number) => {
-                        if (token !== focusToken) return
-
-                        const root = scroll
-                        if (!root) {
-                          if (attempt >= 120) return
-                          requestAnimationFrame(() => scrollTo(attempt + 1))
-                          return
+                        const equal = (a: Record<string, number>, b: Record<string, number>) => {
+                          const aKeys = Object.keys(a)
+                          const bKeys = Object.keys(b)
+                          if (aKeys.length !== bKeys.length) return false
+                          for (const key of aKeys) {
+                            if (a[key] !== b[key]) return false
+                          }
+                          return true
                         }
 
-                        const anchor = root.querySelector(`[data-comment-id="${target.id}"]`)
-                        const ready =
-                          anchor instanceof HTMLElement &&
-                          anchor.style.pointerEvents !== "none" &&
-                          anchor.style.opacity !== "0"
-
-                        const shadow = getRoot()
-                        const marker = shadow ? findMarker(shadow, target.selection) : undefined
-                        const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined
-                        if (!node) {
-                          if (attempt >= 120) return
-                          requestAnimationFrame(() => scrollTo(attempt + 1))
-                          return
+                        const updateComments = () => {
+                          const el = wrap
+                          const root = getRoot()
+                          if (!el || !root) {
+                            setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty))
+                            setDraftTop((prev) => (prev === undefined ? prev : undefined))
+                            return
+                          }
+
+                          const next: Record<string, number> = {}
+                          for (const comment of fileComments()) {
+                            const marker = findMarker(root, comment.selection)
+                            if (!marker) continue
+                            next[comment.id] = markerTop(el, marker)
+                          }
+
+                          setPositions((prev) => (equal(prev, next) ? prev : next))
+
+                          const range = commenting()
+                          if (!range) {
+                            setDraftTop(undefined)
+                            return
+                          }
+
+                          const marker = findMarker(root, range)
+                          if (!marker) {
+                            setDraftTop(undefined)
+                            return
+                          }
+
+                          const nextTop = markerTop(el, marker)
+                          setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
                         }
 
-                        const rootRect = root.getBoundingClientRect()
-                        const targetRect = node.getBoundingClientRect()
-                        const offset = targetRect.top - rootRect.top
-                        const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
-                        root.scrollTop = Math.max(0, next)
+                        let commentFrame: number | undefined
 
-                        if (ready || marker) return
-                        if (attempt >= 120) return
-                        requestAnimationFrame(() => scrollTo(attempt + 1))
-                      }
+                        const scheduleComments = () => {
+                          if (commentFrame !== undefined) return
+                          commentFrame = requestAnimationFrame(() => {
+                            commentFrame = undefined
+                            updateComments()
+                          })
+                        }
 
-                      requestAnimationFrame(() => scrollTo(0))
-                      requestAnimationFrame(() => comments.clearFocus())
-                    })
+                        createEffect(() => {
+                          fileComments()
+                          scheduleComments()
+                        })
 
-                    const renderCode = (source: string, wrapperClass: string) => (
-                      <div
-                        ref={(el) => {
-                          wrap = el
+                        createEffect(() => {
+                          commenting()
                           scheduleComments()
-                        }}
-                        class={`relative overflow-hidden ${wrapperClass}`}
-                      >
-                        <Dynamic
-                          component={codeComponent}
-                          file={{
-                            name: path() ?? "",
-                            contents: source,
-                            cacheKey: cacheKey(),
-                          }}
-                          enableLineSelection
-                          selectedLines={selectedLines()}
-                          commentedLines={commentedLines()}
-                          onRendered={() => {
-                            requestAnimationFrame(restoreScroll)
-                            requestAnimationFrame(scheduleComments)
-                          }}
-                          onLineSelected={(range: SelectedLineRange | null) => {
-                            const p = path()
-                            if (!p) return
-                            file.setSelectedLines(p, range)
-                            if (!range) setCommenting(null)
-                          }}
-                          onLineSelectionEnd={(range: SelectedLineRange | null) => {
-                            if (!range) {
-                              setCommenting(null)
+                        })
+
+                        createEffect(() => {
+                          const range = commenting()
+                          if (!range) return
+                          setDraft("")
+                        })
+
+                        createEffect(() => {
+                          const focus = comments.focus()
+                          const p = path()
+                          if (!focus || !p) return
+                          if (focus.file !== p) return
+                          if (activeTab() !== tab) return
+
+                          const target = fileComments().find((comment) => comment.id === focus.id)
+                          if (!target) return
+
+                          focusToken++
+                          const token = focusToken
+
+                          setOpenedComment(target.id)
+                          setCommenting(null)
+                          file.setSelectedLines(p, target.selection)
+
+                          const scrollTo = (attempt: number) => {
+                            if (token !== focusToken) return
+
+                            const root = scroll
+                            if (!root) {
+                              if (attempt >= 120) return
+                              requestAnimationFrame(() => scrollTo(attempt + 1))
                               return
                             }
 
-                            setOpenedComment(null)
-                            setCommenting(range)
-                          }}
-                          overflow="scroll"
-                          class="select-text"
-                        />
-                        <For each={fileComments()}>
-                          {(comment) => (
-                            <LineCommentView
-                              id={comment.id}
-                              top={positions()[comment.id]}
-                              open={openedComment() === comment.id}
-                              onMouseEnter={() => {
-                                const p = path()
-                                if (!p) return
-                                file.setSelectedLines(p, comment.selection)
+                            const anchor = root.querySelector(`[data-comment-id="${target.id}"]`)
+                            const ready =
+                              anchor instanceof HTMLElement &&
+                              anchor.style.pointerEvents !== "none" &&
+                              anchor.style.opacity !== "0"
+
+                            const shadow = getRoot()
+                            const marker = shadow ? findMarker(shadow, target.selection) : undefined
+                            const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined
+                            if (!node) {
+                              if (attempt >= 120) return
+                              requestAnimationFrame(() => scrollTo(attempt + 1))
+                              return
+                            }
+
+                            const rootRect = root.getBoundingClientRect()
+                            const targetRect = node.getBoundingClientRect()
+                            const offset = targetRect.top - rootRect.top
+                            const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
+                            root.scrollTop = Math.max(0, next)
+
+                            if (ready || marker) return
+                            if (attempt >= 120) return
+                            requestAnimationFrame(() => scrollTo(attempt + 1))
+                          }
+
+                          requestAnimationFrame(() => scrollTo(0))
+                          requestAnimationFrame(() => comments.clearFocus())
+                        })
+
+                        const renderCode = (source: string, wrapperClass: string) => (
+                          <div
+                            ref={(el) => {
+                              wrap = el
+                              scheduleComments()
+                            }}
+                            class={`relative overflow-hidden ${wrapperClass}`}
+                          >
+                            <Dynamic
+                              component={codeComponent}
+                              file={{
+                                name: path() ?? "",
+                                contents: source,
+                                cacheKey: cacheKey(),
+                              }}
+                              enableLineSelection
+                              selectedLines={selectedLines()}
+                              commentedLines={commentedLines()}
+                              onRendered={() => {
+                                requestAnimationFrame(restoreScroll)
+                                requestAnimationFrame(scheduleComments)
                               }}
-                              onClick={() => {
+                              onLineSelected={(range: SelectedLineRange | null) => {
                                 const p = path()
                                 if (!p) return
-                                setCommenting(null)
-                                setOpenedComment((current) => (current === comment.id ? null : comment.id))
-                                file.setSelectedLines(p, comment.selection)
+                                file.setSelectedLines(p, range)
+                                if (!range) setCommenting(null)
                               }}
-                              comment={comment.comment}
-                              selection={commentLabel(comment.selection)}
-                            />
-                          )}
-                        </For>
-                        <Show when={commenting()}>
-                          {(range) => (
-                            <Show when={draftTop() !== undefined}>
-                              <LineCommentEditor
-                                top={draftTop()}
-                                value={draft()}
-                                selection={commentLabel(range())}
-                                onInput={setDraft}
-                                onCancel={() => setCommenting(null)}
-                                onSubmit={(comment) => {
-                                  const p = path()
-                                  if (!p) return
-                                  addCommentToContext({
-                                    file: p,
-                                    selection: range(),
-                                    comment,
-                                    origin: "file",
-                                  })
+                              onLineSelectionEnd={(range: SelectedLineRange | null) => {
+                                if (!range) {
                                   setCommenting(null)
-                                }}
-                                onPopoverFocusOut={(e) => {
-                                  const target = e.relatedTarget as Node | null
-                                  if (target && e.currentTarget.contains(target)) return
-                                  // Delay to allow click handlers to fire first
-                                  setTimeout(() => {
-                                    if (!document.activeElement || !e.currentTarget.contains(document.activeElement)) {
+                                  return
+                                }
+
+                                setOpenedComment(null)
+                                setCommenting(range)
+                              }}
+                              overflow="scroll"
+                              class="select-text"
+                            />
+                            <For each={fileComments()}>
+                              {(comment) => (
+                                <LineCommentView
+                                  id={comment.id}
+                                  top={positions()[comment.id]}
+                                  open={openedComment() === comment.id}
+                                  onMouseEnter={() => {
+                                    const p = path()
+                                    if (!p) return
+                                    file.setSelectedLines(p, comment.selection)
+                                  }}
+                                  onClick={() => {
+                                    const p = path()
+                                    if (!p) return
+                                    setCommenting(null)
+                                    setOpenedComment((current) => (current === comment.id ? null : comment.id))
+                                    file.setSelectedLines(p, comment.selection)
+                                  }}
+                                  comment={comment.comment}
+                                  selection={commentLabel(comment.selection)}
+                                />
+                              )}
+                            </For>
+                            <Show when={commenting()}>
+                              {(range) => (
+                                <Show when={draftTop() !== undefined}>
+                                  <LineCommentEditor
+                                    top={draftTop()}
+                                    value={draft()}
+                                    selection={commentLabel(range())}
+                                    onInput={setDraft}
+                                    onCancel={() => setCommenting(null)}
+                                    onSubmit={(comment) => {
+                                      const p = path()
+                                      if (!p) return
+                                      addCommentToContext({
+                                        file: p,
+                                        selection: range(),
+                                        comment,
+                                        origin: "file",
+                                      })
                                       setCommenting(null)
-                                    }
-                                  }, 0)
-                                }}
-                              />
+                                    }}
+                                    onPopoverFocusOut={(e) => {
+                                      const target = e.relatedTarget as Node | null
+                                      if (target && e.currentTarget.contains(target)) return
+                                      // Delay to allow click handlers to fire first
+                                      setTimeout(() => {
+                                        if (
+                                          !document.activeElement ||
+                                          !e.currentTarget.contains(document.activeElement)
+                                        ) {
+                                          setCommenting(null)
+                                        }
+                                      }, 0)
+                                    }}
+                                  />
+                                </Show>
+                              )}
                             </Show>
-                          )}
-                        </Show>
-                      </div>
-                    )
-
-                    const getCodeScroll = () => {
-                      const el = scroll
-                      if (!el) return []
-
-                      const host = el.querySelector("diffs-container")
-                      if (!(host instanceof HTMLElement)) return []
-
-                      const root = host.shadowRoot
-                      if (!root) return []
-
-                      return Array.from(root.querySelectorAll("[data-code]")).filter(
-                        (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
-                      )
-                    }
+                          </div>
+                        )
 
-                    const queueScrollUpdate = (next: { x: number; y: number }) => {
-                      pending = next
-                      if (scrollFrame !== undefined) return
+                        const getCodeScroll = () => {
+                          const el = scroll
+                          if (!el) return []
 
-                      scrollFrame = requestAnimationFrame(() => {
-                        scrollFrame = undefined
+                          const host = el.querySelector("diffs-container")
+                          if (!(host instanceof HTMLElement)) return []
 
-                        const next = pending
-                        pending = undefined
-                        if (!next) return
+                          const root = host.shadowRoot
+                          if (!root) return []
 
-                        view().setScroll(tab, next)
-                      })
-                    }
+                          return Array.from(root.querySelectorAll("[data-code]")).filter(
+                            (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
+                          )
+                        }
 
-                    const handleCodeScroll = (event: Event) => {
-                      const el = scroll
-                      if (!el) return
+                        const queueScrollUpdate = (next: { x: number; y: number }) => {
+                          pending = next
+                          if (scrollFrame !== undefined) return
 
-                      const target = event.currentTarget
-                      if (!(target instanceof HTMLElement)) return
+                          scrollFrame = requestAnimationFrame(() => {
+                            scrollFrame = undefined
 
-                      queueScrollUpdate({
-                        x: target.scrollLeft,
-                        y: el.scrollTop,
-                      })
-                    }
+                            const next = pending
+                            pending = undefined
+                            if (!next) return
 
-                    const syncCodeScroll = () => {
-                      const next = getCodeScroll()
-                      if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
+                            view().setScroll(tab, next)
+                          })
+                        }
 
-                      for (const item of codeScroll) {
-                        item.removeEventListener("scroll", handleCodeScroll)
-                      }
+                        const handleCodeScroll = (event: Event) => {
+                          const el = scroll
+                          if (!el) return
 
-                      codeScroll = next
+                          const target = event.currentTarget
+                          if (!(target instanceof HTMLElement)) return
 
-                      for (const item of codeScroll) {
-                        item.addEventListener("scroll", handleCodeScroll)
-                      }
-                    }
+                          queueScrollUpdate({
+                            x: target.scrollLeft,
+                            y: el.scrollTop,
+                          })
+                        }
 
-                    const restoreScroll = () => {
-                      const el = scroll
-                      if (!el) return
+                        const syncCodeScroll = () => {
+                          const next = getCodeScroll()
+                          if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
 
-                      const s = view()?.scroll(tab)
-                      if (!s) return
+                          for (const item of codeScroll) {
+                            item.removeEventListener("scroll", handleCodeScroll)
+                          }
 
-                      syncCodeScroll()
+                          codeScroll = next
 
-                      if (codeScroll.length > 0) {
-                        for (const item of codeScroll) {
-                          if (item.scrollLeft !== s.x) item.scrollLeft = s.x
+                          for (const item of codeScroll) {
+                            item.addEventListener("scroll", handleCodeScroll)
+                          }
                         }
-                      }
 
-                      if (el.scrollTop !== s.y) el.scrollTop = s.y
+                        const restoreScroll = () => {
+                          const el = scroll
+                          if (!el) return
 
-                      if (codeScroll.length > 0) return
+                          const s = view()?.scroll(tab)
+                          if (!s) return
 
-                      if (el.scrollLeft !== s.x) el.scrollLeft = s.x
-                    }
+                          syncCodeScroll()
 
-                    const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
-                      if (codeScroll.length === 0) syncCodeScroll()
+                          if (codeScroll.length > 0) {
+                            for (const item of codeScroll) {
+                              if (item.scrollLeft !== s.x) item.scrollLeft = s.x
+                            }
+                          }
 
-                      queueScrollUpdate({
-                        x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
-                        y: event.currentTarget.scrollTop,
-                      })
-                    }
+                          if (el.scrollTop !== s.y) el.scrollTop = s.y
 
-                    createEffect(
-                      on(
-                        () => state()?.loaded,
-                        (loaded) => {
-                          if (!loaded) return
-                          requestAnimationFrame(restoreScroll)
-                        },
-                        { defer: true },
-                      ),
-                    )
+                          if (codeScroll.length > 0) return
 
-                    createEffect(
-                      on(
-                        () => file.ready(),
-                        (ready) => {
-                          if (!ready) return
-                          requestAnimationFrame(restoreScroll)
-                        },
-                        { defer: true },
-                      ),
-                    )
-
-                    createEffect(
-                      on(
-                        () => tabs().active() === tab,
-                        (active) => {
-                          if (!active) return
-                          if (!state()?.loaded) return
-                          requestAnimationFrame(restoreScroll)
-                        },
-                      ),
-                    )
+                          if (el.scrollLeft !== s.x) el.scrollLeft = s.x
+                        }
 
-                    onCleanup(() => {
-                      if (commentFrame !== undefined) cancelAnimationFrame(commentFrame)
-                      for (const item of codeScroll) {
-                        item.removeEventListener("scroll", handleCodeScroll)
-                      }
+                        const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+                          if (codeScroll.length === 0) syncCodeScroll()
 
-                      if (scrollFrame === undefined) return
-                      cancelAnimationFrame(scrollFrame)
-                    })
+                          queueScrollUpdate({
+                            x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
+                            y: event.currentTarget.scrollTop,
+                          })
+                        }
 
-                    return (
-                      <Tabs.Content
-                        value={tab}
-                        class="mt-3 relative"
-                        ref={(el: HTMLDivElement) => {
-                          scroll = el
-                          restoreScroll()
-                        }}
-                        onScroll={handleScroll}
-                      >
-                        <Switch>
-                          <Match when={state()?.loaded && isImage()}>
-                            <div class="px-6 py-4 pb-40">
-                              <img
-                                src={imageDataUrl()}
-                                alt={path()}
-                                class="max-w-full"
-                                onLoad={() => requestAnimationFrame(restoreScroll)}
-                              />
-                            </div>
-                          </Match>
-                          <Match when={state()?.loaded && isSvg()}>
-                            <div class="flex flex-col gap-4 px-6 py-4">
-                              {renderCode(svgContent() ?? "", "")}
-                              <Show when={svgPreviewUrl()}>
-                                <div class="flex justify-center pb-40">
-                                  <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+                        createEffect(
+                          on(
+                            () => state()?.loaded,
+                            (loaded) => {
+                              if (!loaded) return
+                              requestAnimationFrame(restoreScroll)
+                            },
+                            { defer: true },
+                          ),
+                        )
+
+                        createEffect(
+                          on(
+                            () => file.ready(),
+                            (ready) => {
+                              if (!ready) return
+                              requestAnimationFrame(restoreScroll)
+                            },
+                            { defer: true },
+                          ),
+                        )
+
+                        createEffect(
+                          on(
+                            () => tabs().active() === tab,
+                            (active) => {
+                              if (!active) return
+                              if (!state()?.loaded) return
+                              requestAnimationFrame(restoreScroll)
+                            },
+                          ),
+                        )
+
+                        onCleanup(() => {
+                          if (commentFrame !== undefined) cancelAnimationFrame(commentFrame)
+                          for (const item of codeScroll) {
+                            item.removeEventListener("scroll", handleCodeScroll)
+                          }
+
+                          if (scrollFrame === undefined) return
+                          cancelAnimationFrame(scrollFrame)
+                        })
+
+                        return (
+                          <Tabs.Content
+                            value={tab}
+                            class="mt-3 relative"
+                            ref={(el: HTMLDivElement) => {
+                              scroll = el
+                              restoreScroll()
+                            }}
+                            onScroll={handleScroll}
+                          >
+                            <Switch>
+                              <Match when={state()?.loaded && isImage()}>
+                                <div class="px-6 py-4 pb-40">
+                                  <img
+                                    src={imageDataUrl()}
+                                    alt={path()}
+                                    class="max-w-full"
+                                    onLoad={() => requestAnimationFrame(restoreScroll)}
+                                  />
                                 </div>
-                              </Show>
-                            </div>
-                          </Match>
-                          <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
-                          <Match when={state()?.loading}>
-                            <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
-                          </Match>
-                          <Match when={state()?.error}>
-                            {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
-                          </Match>
-                        </Switch>
-                      </Tabs.Content>
-                    )
-                  }}
-                </For>
-              </Tabs>
-              <DragOverlay>
-                <Show when={store.activeDraggable}>
-                  {(tab) => {
-                    const path = createMemo(() => file.pathFromTab(tab()))
-                    return (
-                      <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
-                        <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
-                      </div>
-                    )
-                  }}
-                </Show>
-              </DragOverlay>
-            </DragDropProvider>
+                              </Match>
+                              <Match when={state()?.loaded && isSvg()}>
+                                <div class="flex flex-col gap-4 px-6 py-4">
+                                  {renderCode(svgContent() ?? "", "")}
+                                  <Show when={svgPreviewUrl()}>
+                                    <div class="flex justify-center pb-40">
+                                      <img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
+                                    </div>
+                                  </Show>
+                                </div>
+                              </Match>
+                              <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
+                              <Match when={state()?.loading}>
+                                <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>
+                              </Match>
+                              <Match when={state()?.error}>
+                                {(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
+                              </Match>
+                            </Switch>
+                          </Tabs.Content>
+                        )
+                      }}
+                    </For>
+                  </Tabs>
+                  <DragOverlay>
+                    <Show when={store.activeDraggable}>
+                      {(tab) => {
+                        const path = createMemo(() => file.pathFromTab(tab()))
+                        return (
+                          <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
+                            <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
+                          </div>
+                        )
+                      }}
+                    </Show>
+                  </DragOverlay>
+                </DragDropProvider>
+              </Show>
+            </div>
+
+            <Show when={layout.fileTree.opened()}>
+              <div class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
+                <div class="h-full border-l border-border-weak-base flex flex-col overflow-hidden">
+                  <Tabs value={fileTreeTab()} onChange={setFileTreeTabValue} class="h-full">
+                    <Tabs.List class="h-auto">
+                      <Tabs.Trigger value="changes" class="w-1/2" classes={{ button: "w-full" }}>
+                        Changes
+                      </Tabs.Trigger>
+                      <Tabs.Trigger value="all" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
+                        All files
+                      </Tabs.Trigger>
+                    </Tabs.List>
+                    <Tabs.Content value="changes" class="bg-background-base p-2">
+                      <Switch>
+                        <Match when={hasReview()}>
+                          <Show
+                            when={diffsReady()}
+                            fallback={<div class="px-2 py-2 text-12-regular text-text-weak">Loading...</div>}
+                          >
+                            <FileTree
+                              path=""
+                              allowed={diffs().map((d) => d.file)}
+                              onFileClick={(node) => focusReviewDiff(node.path)}
+                            />
+                          </Show>
+                        </Match>
+                        <Match when={true}>
+                          <div class="px-2 py-2 text-12-regular text-text-weak">No changes</div>
+                        </Match>
+                      </Switch>
+                    </Tabs.Content>
+                    <Tabs.Content value="all" class="bg-background-base p-2">
+                      <FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} />
+                    </Tabs.Content>
+                  </Tabs>
+                </div>
+                <ResizeHandle
+                  direction="horizontal"
+                  edge="start"
+                  size={layout.fileTree.width()}
+                  min={200}
+                  max={480}
+                  collapseThreshold={160}
+                  onResize={layout.fileTree.resize}
+                  onCollapse={layout.fileTree.close}
+                />
+              </div>
+            </Show>
           </aside>
         </Show>
       </div>

+ 12 - 0
packages/ui/src/components/resize-handle.css

@@ -21,6 +21,12 @@
     transform: translateX(50%);
     cursor: col-resize;
 
+    &[data-edge="start"] {
+      inset-inline-start: 0;
+      inset-inline-end: auto;
+      transform: translateX(-50%);
+    }
+
     &::after {
       width: 3px;
       inset-block: 0;
@@ -36,6 +42,12 @@
     transform: translateY(-50%);
     cursor: row-resize;
 
+    &[data-edge="end"] {
+      inset-block-start: auto;
+      inset-block-end: 0;
+      transform: translateY(50%);
+    }
+
     &::after {
       height: 3px;
       inset-inline: 0;

+ 12 - 1
packages/ui/src/components/resize-handle.tsx

@@ -2,6 +2,7 @@ import { splitProps, type JSX } from "solid-js"
 
 export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "onResize"> {
   direction: "horizontal" | "vertical"
+  edge?: "start" | "end"
   size: number
   min: number
   max: number
@@ -13,6 +14,7 @@ export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElemen
 export function ResizeHandle(props: ResizeHandleProps) {
   const [local, rest] = splitProps(props, [
     "direction",
+    "edge",
     "size",
     "min",
     "max",
@@ -25,6 +27,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
 
   const handleMouseDown = (e: MouseEvent) => {
     e.preventDefault()
+    const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end")
     const start = local.direction === "horizontal" ? e.clientX : e.clientY
     const startSize = local.size
     let current = startSize
@@ -34,7 +37,14 @@ export function ResizeHandle(props: ResizeHandleProps) {
 
     const onMouseMove = (moveEvent: MouseEvent) => {
       const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY
-      const delta = local.direction === "vertical" ? start - pos : pos - start
+      const delta =
+        local.direction === "vertical"
+          ? edge === "end"
+            ? pos - start
+            : start - pos
+          : edge === "start"
+            ? start - pos
+            : pos - start
       current = startSize + delta
       const clamped = Math.min(local.max, Math.max(local.min, current))
       local.onResize(clamped)
@@ -61,6 +71,7 @@ export function ResizeHandle(props: ResizeHandleProps) {
       {...rest}
       data-component="resize-handle"
       data-direction={local.direction}
+      data-edge={local.edge ?? (local.direction === "vertical" ? "start" : "end")}
       classList={{
         ...(local.classList ?? {}),
         [local.class ?? ""]: !!local.class,

+ 13 - 1
packages/ui/src/components/session-review.tsx

@@ -9,6 +9,7 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
 import { useDiffComponent } from "../context/diff"
 import { useI18n } from "../context/i18n"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { checksum } from "@opencode-ai/util/encode"
 import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js"
 import { createStore } from "solid-js/store"
 import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
@@ -118,6 +119,12 @@ function dataUrlFromValue(value: unknown): string | undefined {
   return `data:${mime};base64,${content}`
 }
 
+function diffId(file: string): string | undefined {
+  const sum = checksum(file)
+  if (!sum) return
+  return `session-review-diff-${sum}`
+}
+
 type SessionReviewSelection = {
   file: string
   range: SelectedLineRange
@@ -489,7 +496,12 @@ export const SessionReview = (props: SessionReviewProps) => {
               }
 
               return (
-                <Accordion.Item value={diff.file} data-slot="session-review-accordion-item">
+                <Accordion.Item
+                  value={diff.file}
+                  id={diffId(diff.file)}
+                  data-file={diff.file}
+                  data-slot="session-review-accordion-item"
+                >
                   <StickyAccordionHeader>
                     <Accordion.Trigger>
                       <div data-slot="session-review-trigger-content">