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() + + 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 (
- + {(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false return ( @@ -102,7 +141,12 @@ export default function FileTree(props: { - + 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) { { 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(undefined) + const [pendingDiff, setPendingDiff] = createSignal(undefined) + + createEffect(() => { + if (!layout.fileTree.opened()) return + setFileTreeTab("changes") + }) + + const setFileTreeTabValue = (value: string) => { + if (value !== "changes" && value !== "all") return + setFileTreeTab(value) + } + + const reviewDiffId = (path: string) => { + const sum = checksum(path) + if (!sum) return + return `session-review-diff-${sum}` + } + + const scrollToReviewDiff = (path: string, behavior: ScrollBehavior) => { + const root = reviewScroll() + if (!root) return + + const id = reviewDiffId(path) + if (!id) return + + const el = document.getElementById(id) + if (!(el instanceof HTMLElement)) return + if (!root.contains(el)) return + + const a = el.getBoundingClientRect() + const b = root.getBoundingClientRect() + const top = a.top - b.top + root.scrollTop + root.scrollTo({ top, behavior }) + } + + const focusReviewDiff = (path: string) => { + const current = view().review.open() ?? [] + if (!current.includes(path)) view().review.setOpen([...current, path]) + setPendingDiff(path) + requestAnimationFrame(() => scrollToReviewDiff(path, "smooth")) + } + + createEffect(() => { + const pending = pendingDiff() + if (!pending) return + if (!reviewScroll()) return + if (!diffsReady()) return + + requestAnimationFrame(() => { + scrollToReviewDiff(pending, "smooth") + setPendingDiff(undefined) + }) + }) + const activeTab = createMemo(() => { const active = tabs().active() + if (layout.fileTree.opened() && fileTreeTab() === "all") { + if (active && active !== "review" && active !== "context") return normalizeTab(active) + + const first = openedTabs()[0] + if (first) return first + return "review" + } if (active) return normalizeTab(active) if (hasReview()) return "review" @@ -1033,12 +1098,27 @@ export default function Page() { tabs().setActive(activeTab()) }) + createEffect(() => { + if (!layout.fileTree.opened()) return + if (fileTreeTab() !== "all") return + + const first = openedTabs()[0] + if (!first) return + + const active = tabs().active() + if (active && active !== "review" && active !== "context") return + tabs().setActive(first) + }) + createEffect(() => { const id = params.id if (!id) return if (!hasReview()) return - const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review" + const wants = isDesktop() + ? view().reviewPanel.opened() && + (layout.fileTree.opened() ? fileTreeTab() === "changes" : activeTab() === "review") + : store.mobileTab === "review" if (!wants) return if (diffsReady()) return @@ -1814,18 +1894,753 @@ 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" > +
+ +
+
+ + + {language.t("session.review.loadingChanges")}
+ } + > + 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) + }} + /> + + + +
+ +
No changes in this session yet
+
+
+ +
+
+ + + + + + + +
+ + + +
+ + + +
+
{language.t("session.tab.review")}
+ +
+ {info()?.summary?.files ?? 0} +
+
+
+
+
+
+ + + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
+ + {(tab) => } + +
+ + dialog.show(() => )} + aria-label={language.t("command.file.open")} + /> + +
+
+
+ + + +
+ + + + {language.t("session.review.loadingChanges")} +
+ } + > + 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) + }} + /> +
+ + +
+ +
+ No changes in this session yet +
+
+
+ +
+ + + + + + +
+ +
Select a file to open
+
+
+
+ + + + +
+ +
+
+
+
+ + {(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(null) + const [commenting, setCommenting] = createSignal(null) + const [draft, setDraft] = createSignal("") + const [positions, setPositions] = createSignal>({}) + const [draftTop, setDraftTop] = createSignal(undefined) + + const empty = {} as Record + + 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 + + return root + } + + 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 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 equal = (a: Record, b: Record) => { + 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 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 = {} + 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)) + } + + let commentFrame: number | undefined + + const scheduleComments = () => { + if (commentFrame !== undefined) return + commentFrame = requestAnimationFrame(() => { + commentFrame = undefined + updateComments() + }) + } + + 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 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) => ( +
{ + wrap = el + scheduleComments() + }} + class={`relative overflow-hidden ${wrapperClass}`} + > + { + 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) + return + } + + setOpenedComment(null) + setCommenting(range) + }} + overflow="scroll" + class="select-text" + /> + + {(comment) => ( + { + 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)} + /> + )} + + + {(range) => ( + + setCommenting(null)} + onSubmit={(comment) => { + const p = path() + if (!p) return + addCommentToContext({ + file: p, + selection: range(), + comment, + origin: "file", + }) + 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) + ) { + setCommenting(null) + } + }, 0) + }} + /> + + )} + +
+ ) + + 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, + ) + } + + const queueScrollUpdate = (next: { x: number; y: number }) => { + pending = next + if (scrollFrame !== undefined) return + + scrollFrame = requestAnimationFrame(() => { + scrollFrame = undefined + + const next = pending + pending = undefined + if (!next) return + + view().setScroll(tab, next) + }) + } + + const handleCodeScroll = (event: Event) => { + const el = scroll + if (!el) return + + const target = event.currentTarget + if (!(target instanceof HTMLElement)) return + + queueScrollUpdate({ + x: target.scrollLeft, + y: el.scrollTop, + }) + } + + const syncCodeScroll = () => { + const next = getCodeScroll() + if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return + + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + codeScroll = next + + for (const item of codeScroll) { + item.addEventListener("scroll", handleCodeScroll) + } + } + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = view()?.scroll(tab) + if (!s) return + + syncCodeScroll() + + if (codeScroll.length > 0) { + for (const item of codeScroll) { + if (item.scrollLeft !== s.x) item.scrollLeft = s.x + } + } + + if (el.scrollTop !== s.y) el.scrollTop = s.y + + if (codeScroll.length > 0) return + + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + if (codeScroll.length === 0) syncCodeScroll() + + queueScrollUpdate({ + x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + }) + } + + 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 ( + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > + + +
+ {path()} requestAnimationFrame(restoreScroll)} + /> +
+
+ +
+ {renderCode(svgContent() ?? "", "")} + +
+ {path()} +
+
+
+
+ {renderCode(contents(), "pb-40")} + +
{language.t("common.loading")}...
+
+ + {(err) =>
{err()}
} +
+
+
+ ) + }} +
+ + + + {(tab) => { + const path = createMemo(() => file.pathFromTab(tab())) + return ( +
+ {(p) => } +
+ ) + }} +
+
+ + + +
-
- -
- openTab(file.tab(node.path))} /> -
+
+ + + + Changes + + + All files + + + + + + Loading...
} + > + d.file)} + onFileClick={(node) => focusReviewDiff(node.path)} + /> + + + +
No changes
+
+ + + + openTab(file.tab(node.path))} /> + +
- - - - -
- - - -
- - - -
-
{language.t("session.tab.review")}
- -
- {info()?.summary?.files ?? 0} -
-
-
-
-
-
- - - tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
-
-
-
- - {(tab) => } - -
- - dialog.show(() => )} - aria-label={language.t("command.file.open")} - /> - -
-
-
- - - -
- - - - {language.t("session.review.loadingChanges")} -
- } - > - 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) - }} - /> -
- - -
- -
No changes in this session yet
-
-
- - -
- - - - - -
- -
-
-
-
- - {(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(null) - const [commenting, setCommenting] = createSignal(null) - const [draft, setDraft] = createSignal("") - const [positions, setPositions] = createSignal>({}) - const [draftTop, setDraftTop] = createSignal(undefined) - - const empty = {} as Record - - 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 - - return root - } - - 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 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 equal = (a: Record, b: Record) => { - 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 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 = {} - 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)) - } - - let commentFrame: number | undefined - - const scheduleComments = () => { - if (commentFrame !== undefined) return - commentFrame = requestAnimationFrame(() => { - commentFrame = undefined - updateComments() - }) - } - - 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 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) => ( -
{ - wrap = el - scheduleComments() - }} - class={`relative overflow-hidden ${wrapperClass}`} - > - { - 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) - return - } - - setOpenedComment(null) - setCommenting(range) - }} - overflow="scroll" - class="select-text" - /> - - {(comment) => ( - { - 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)} - /> - )} - - - {(range) => ( - - setCommenting(null)} - onSubmit={(comment) => { - const p = path() - if (!p) return - addCommentToContext({ - file: p, - selection: range(), - comment, - origin: "file", - }) - 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)) { - setCommenting(null) - } - }, 0) - }} - /> - - )} - -
- ) - - 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, - ) - } - - const queueScrollUpdate = (next: { x: number; y: number }) => { - pending = next - if (scrollFrame !== undefined) return - - scrollFrame = requestAnimationFrame(() => { - scrollFrame = undefined - - const next = pending - pending = undefined - if (!next) return - - view().setScroll(tab, next) - }) - } - - const handleCodeScroll = (event: Event) => { - const el = scroll - if (!el) return - - const target = event.currentTarget - if (!(target instanceof HTMLElement)) return - - queueScrollUpdate({ - x: target.scrollLeft, - y: el.scrollTop, - }) - } - - const syncCodeScroll = () => { - const next = getCodeScroll() - if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return - - for (const item of codeScroll) { - item.removeEventListener("scroll", handleCodeScroll) - } - - codeScroll = next - - for (const item of codeScroll) { - item.addEventListener("scroll", handleCodeScroll) - } - } - - const restoreScroll = () => { - const el = scroll - if (!el) return - - const s = view()?.scroll(tab) - if (!s) return - - syncCodeScroll() - - if (codeScroll.length > 0) { - for (const item of codeScroll) { - if (item.scrollLeft !== s.x) item.scrollLeft = s.x - } - } - - if (el.scrollTop !== s.y) el.scrollTop = s.y - - if (codeScroll.length > 0) return - - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } - - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - if (codeScroll.length === 0) syncCodeScroll() - - queueScrollUpdate({ - x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - }) - } - - 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 ( - { - scroll = el - restoreScroll() - }} - onScroll={handleScroll} - > - - -
- {path()} requestAnimationFrame(restoreScroll)} - /> -
-
- -
- {renderCode(svgContent() ?? "", "")} - -
- {path()} -
-
-
-
- {renderCode(contents(), "pb-40")} - -
{language.t("common.loading")}...
-
- - {(err) =>
{err()}
} -
-
-
- ) - }} -
-
- - - {(tab) => { - const path = createMemo(() => file.pathFromTab(tab())) - return ( -
- {(p) => } -
- ) - }} -
-
-
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, "onResize"> { direction: "horizontal" | "vertical" + edge?: "start" | "end" size: number min: number max: number @@ -13,6 +14,7 @@ export interface ResizeHandleProps extends Omit { 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 ( - +