summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/components/file-tree.tsx50
-rw-r--r--packages/app/src/pages/session.tsx1376
-rw-r--r--packages/ui/src/components/resize-handle.css12
-rw-r--r--packages/ui/src/components/resize-handle.tsx13
-rw-r--r--packages/ui/src/components/session-review.tsx14
5 files changed, 857 insertions, 608 deletions
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
index 791b33b4a..ac435095d 100644
--- a/packages/app/src/components/file-tree.tsx
+++ b/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>
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 413d8ac34..5160c5288 100644
--- a/packages/app/src/pages/session.tsx
+++ b/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"
@@ -1034,11 +1099,26 @@ export default function Page() {
})
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>
diff --git a/packages/ui/src/components/resize-handle.css b/packages/ui/src/components/resize-handle.css
index 6aac4c2fd..c309ff838 100644
--- a/packages/ui/src/components/resize-handle.css
+++ b/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;
diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx
index 3ad01e27f..e2eed1bb7 100644
--- a/packages/ui/src/components/resize-handle.tsx
+++ b/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,
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 60ac0d516..9a337c453 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/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">