diff options
Diffstat (limited to 'packages')
| -rw-r--r-- | packages/app/src/components/file-tree.tsx | 50 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 1376 | ||||
| -rw-r--r-- | packages/ui/src/components/resize-handle.css | 12 | ||||
| -rw-r--r-- | packages/ui/src/components/resize-handle.tsx | 13 | ||||
| -rw-r--r-- | packages/ui/src/components/session-review.tsx | 14 |
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"> |
