summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authoradamelmore <[email protected]>2026-01-26 09:55:37 -0600
committeradamelmore <[email protected]>2026-01-26 11:07:52 -0600
commitb8e8d82323f87080de377b4ac227356bac3e9726 (patch)
tree9e767fc98b49918989db7fdd36ec40fd094c6a18 /packages
parent801eb5d2cb868d0a14c056439e3898b110f4cc21 (diff)
downloadopencode-b8e8d82323f87080de377b4ac227356bac3e9726.tar.gz
opencode-b8e8d82323f87080de377b4ac227356bac3e9726.zip
chore: cleanup
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/components/file-tree.tsx15
-rw-r--r--packages/app/src/pages/session.tsx1226
-rw-r--r--packages/ui/src/components/tabs.css3
3 files changed, 596 insertions, 648 deletions
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
index a48f0039f..c27ccbe6d 100644
--- a/packages/app/src/components/file-tree.tsx
+++ b/packages/app/src/components/file-tree.tsx
@@ -22,6 +22,7 @@ export default function FileTree(props: {
nodeClass?: string
level?: number
allowed?: readonly string[]
+ modified?: readonly string[]
draggable?: boolean
tooltip?: boolean
onFileClick?: (file: FileNode) => void
@@ -50,6 +51,12 @@ export default function FileTree(props: {
return { files, dirs }
})
+ const marks = createMemo(() => {
+ const modified = props.modified
+ if (!modified || modified.length === 0) return
+ return new Set(modified)
+ })
+
createEffect(() => {
const current = filter()
if (!current) return
@@ -89,7 +96,7 @@ export default function FileTree(props: {
<Dynamic
component={local.as ?? "div"}
classList={{
- "w-full flex items-center gap-x-2 rounded-md px-2 py-1 hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
+ "w-full min-w-0 flex items-center gap-x-2 rounded-md px-2 py-1 hover:bg-surface-raised-base-hover active:bg-surface-base-active transition-colors cursor-pointer": true,
...(local.classList ?? {}),
[local.class ?? ""]: !!local.class,
[props.nodeClass ?? ""]: !!props.nodeClass,
@@ -125,13 +132,16 @@ export default function FileTree(props: {
{local.children}
<span
classList={{
- "text-12-regular whitespace-nowrap truncate": true,
+ "flex-1 min-w-0 text-12-regular whitespace-nowrap truncate": true,
"text-text-weaker": local.node.ignored,
"text-text-weak": !local.node.ignored,
}}
>
{local.node.name}
</span>
+ {local.node.type === "file" && marks()?.has(local.node.path) ? (
+ <div class="shrink-0 size-1.5 rounded-full bg-surface-warning-strong" />
+ ) : null}
</Dynamic>
)
}
@@ -173,6 +183,7 @@ export default function FileTree(props: {
path={node.path}
level={level + 1}
allowed={props.allowed}
+ modified={props.modified}
draggable={props.draggable}
tooltip={props.tooltip}
onFileClick={props.onFileClick}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 5ce03f403..458decfc4 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -425,6 +425,8 @@ export default function Page() {
}
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+ const emptyDiffFiles: string[] = []
+ const diffFiles = createMemo(() => diffs().map((d) => d.file), emptyDiffFiles, { equals: same })
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
@@ -1934,710 +1936,646 @@ export default function Page() {
class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex"
>
<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>
- </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
+ when={layout.fileTree.opened() && fileTreeTab() === "changes"}
+ fallback={
+ <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>
- </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="plus-small"
- variant="ghost"
- iconSize="large"
- onClick={() => dialog.show(() => <DialogSelectFile />)}
- aria-label={language.t("command.file.open")}
- />
- </TooltipKeybind>
- </div>
- </Tabs.List>
- </div>
- <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 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>
- }
- >
- <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>
+ </Show>
</div>
- </Match>
- </Switch>
+ </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 shrink-0 sticky right-0 z-10 flex items-center justify-center border-b border-l 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>
</div>
- </Show>
- </Tabs.Content>
- </Show>
+ </Tabs.List>
+ </div>
+ <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>
+ </Match>
+ </Switch>
+ </div>
+ </Show>
+ </Tabs.Content>
+ </Show>
- <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>
-
- <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}
- />
+ <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>
- </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
-
- 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<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 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
- }
+ </Tabs.Content>
+ </Show>
- 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)
+ <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[] = []
+
+ 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 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}`
}
- setPositions((prev) => (equal(prev, next) ? prev : next))
+ const getRoot = () => {
+ const el = wrap
+ if (!el) return
+
+ const host = el.querySelector("diffs-container")
+ if (!(host instanceof HTMLElement)) return
- const range = commenting()
- if (!range) {
- setDraftTop(undefined)
- return
+ const root = host.shadowRoot
+ if (!root) return
+
+ return root
}
- const marker = findMarker(root, range)
- if (!marker) {
- setDraftTop(undefined)
- return
+ 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 nextTop = markerTop(el, marker)
- setDraftTop((prev) => (prev === nextTop ? prev : nextTop))
- }
+ 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)
+ }
- let commentFrame: number | undefined
+ const updateComments = () => {
+ const el = wrap
+ const root = getRoot()
+ if (!el || !root) {
+ setPositions({})
+ setDraftTop(undefined)
+ return
+ }
- 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))
+ 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(next)
+
+ const range = commenting()
+ if (!range) {
+ setDraftTop(undefined)
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))
+ const marker = findMarker(root, range)
+ if (!marker) {
+ setDraftTop(undefined)
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)
+ setDraftTop(markerTop(el, marker))
+ }
- if (ready || marker) return
- if (attempt >= 120) return
- requestAnimationFrame(() => scrollTo(attempt + 1))
+ const scheduleComments = () => {
+ requestAnimationFrame(updateComments)
}
- requestAnimationFrame(() => scrollTo(0))
- requestAnimationFrame(() => comments.clearFocus())
- })
+ createEffect(() => {
+ fileComments()
+ scheduleComments()
+ })
- 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)
- }}
- 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)
+ createEffect(() => {
+ const range = commenting()
+ scheduleComments()
+ 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
+
+ setOpenedComment(target.id)
+ setCommenting(null)
+ file.setSelectedLines(p, target.selection)
+ requestAnimationFrame(() => comments.clearFocus())
+ })
+
+ const renderCode = (source: string, wrapperClass: string) => (
+ <div
+ ref={(el) => {
+ wrap = el
+ scheduleComments()
}}
- 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
+ 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)
- 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) => {
+ 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}
+ comment={comment.comment}
+ selection={commentLabel(comment.selection)}
+ onMouseEnter={() => {
const p = path()
if (!p) return
- addCommentToContext({
- file: p,
- selection: range(),
- comment,
- origin: "file",
- })
- setCommenting(null)
+ file.setSelectedLines(p, comment.selection)
}}
- 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)
+ onClick={() => {
+ const p = path()
+ if (!p) return
+ setCommenting(null)
+ setOpenedComment((current) => (current === comment.id ? null : comment.id))
+ file.setSelectedLines(p, comment.selection)
}}
/>
- </Show>
- )}
- </Show>
- </div>
- )
+ )}
+ </For>
+ <Show when={commenting()}>
+ {(range) => (
+ <Show when={draftTop() !== undefined}>
+ <LineCommentEditor
+ top={draftTop()}
+ value={draft()}
+ selection={commentLabel(range())}
+ onInput={(value) => setDraft(value)}
+ onCancel={() => setCommenting(null)}
+ onSubmit={(value) => {
+ const p = path()
+ if (!p) return
+ addCommentToContext({
+ file: p,
+ selection: range(),
+ comment: value,
+ origin: "file",
+ })
+ setCommenting(null)
+ }}
+ onPopoverFocusOut={(e: FocusEvent) => {
+ const current = e.currentTarget as HTMLDivElement
+ const target = e.relatedTarget
+ if (target instanceof Node && current.contains(target)) return
+
+ setTimeout(() => {
+ if (!document.activeElement || !current.contains(document.activeElement)) {
+ setCommenting(null)
+ }
+ }, 0)
+ }}
+ />
+ </Show>
+ )}
+ </Show>
+ </div>
+ )
- const getCodeScroll = () => {
- const el = scroll
- if (!el) return []
+ const getCodeScroll = () => {
+ const el = scroll
+ if (!el) return []
- const host = el.querySelector("diffs-container")
- if (!(host instanceof HTMLElement)) return []
+ const host = el.querySelector("diffs-container")
+ if (!(host instanceof HTMLElement)) return []
- const root = host.shadowRoot
- if (!root) 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,
- )
- }
+ 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
+ const queueScrollUpdate = (next: { x: number; y: number }) => {
+ pending = next
+ if (scrollFrame !== undefined) return
- scrollFrame = requestAnimationFrame(() => {
- scrollFrame = undefined
+ scrollFrame = requestAnimationFrame(() => {
+ scrollFrame = undefined
- const next = pending
- pending = undefined
- if (!next) return
+ const next = pending
+ pending = undefined
+ if (!next) return
- view().setScroll(tab, next)
- })
- }
+ view().setScroll(tab, next)
+ })
+ }
- const handleCodeScroll = (event: Event) => {
- const el = scroll
- if (!el) return
+ const handleCodeScroll = (event: Event) => {
+ const el = scroll
+ if (!el) return
- const target = event.currentTarget
- if (!(target instanceof HTMLElement)) return
+ const target = event.currentTarget
+ if (!(target instanceof HTMLElement)) return
- queueScrollUpdate({
- x: target.scrollLeft,
- y: el.scrollTop,
- })
- }
+ 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
+ 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)
- }
+ for (const item of codeScroll) {
+ item.removeEventListener("scroll", handleCodeScroll)
+ }
- codeScroll = next
+ codeScroll = next
- for (const item of codeScroll) {
- item.addEventListener("scroll", handleCodeScroll)
+ for (const item of codeScroll) {
+ item.addEventListener("scroll", handleCodeScroll)
+ }
}
- }
- const restoreScroll = () => {
- const el = scroll
- if (!el) return
+ const restoreScroll = () => {
+ const el = scroll
+ if (!el) return
- const s = view()?.scroll(tab)
- if (!s) return
+ const s = view()?.scroll(tab)
+ if (!s) return
- syncCodeScroll()
+ syncCodeScroll()
- if (codeScroll.length > 0) {
- for (const item of codeScroll) {
- if (item.scrollLeft !== s.x) item.scrollLeft = s.x
+ 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
}
- if (el.scrollTop !== s.y) el.scrollTop = s.y
+ const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
+ if (codeScroll.length === 0) syncCodeScroll()
- if (codeScroll.length > 0) return
+ queueScrollUpdate({
+ x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
+ y: event.currentTarget.scrollTop,
+ })
+ }
- if (el.scrollLeft !== s.x) el.scrollLeft = s.x
- }
+ createEffect(
+ on(
+ () => state()?.loaded,
+ (loaded) => {
+ if (!loaded) return
+ requestAnimationFrame(restoreScroll)
+ },
+ { defer: true },
+ ),
+ )
- const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
- if (codeScroll.length === 0) syncCodeScroll()
+ 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(() => {
+ for (const item of codeScroll) {
+ item.removeEventListener("scroll", handleCodeScroll)
+ }
- queueScrollUpdate({
- x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
- y: event.currentTarget.scrollTop,
+ if (scrollFrame === undefined) return
+ cancelAnimationFrame(scrollFrame)
})
- }
-
- 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()
+ 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" />
+ </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>
+ }
+ >
+ <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)
}}
- 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" />
- </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>
+ </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>
</Show>
</div>
@@ -2647,7 +2585,7 @@ export default function Page() {
<Tabs variant="pill" value={fileTreeTab()} onChange={setFileTreeTabValue} class="h-full">
<Tabs.List>
<Tabs.Trigger value="changes" class="flex-1" classes={{ button: "w-full" }}>
- Changes
+ {reviewCount()} {reviewCount() === 1 ? "Change" : "Changes"}
</Tabs.Trigger>
<Tabs.Trigger value="all" class="flex-1" classes={{ button: "w-full" }}>
All files
@@ -2662,7 +2600,7 @@ export default function Page() {
>
<FileTree
path=""
- allowed={diffs().map((d) => d.file)}
+ allowed={diffFiles()}
draggable={false}
tooltip={false}
onFileClick={(node) => focusReviewDiff(node.path)}
@@ -2675,7 +2613,7 @@ export default function Page() {
</Switch>
</Tabs.Content>
<Tabs.Content value="all" class="bg-background-base p-2">
- <FileTree path="" onFileClick={(node) => openTab(file.tab(node.path))} />
+ <FileTree path="" modified={diffFiles()} onFileClick={(node) => openTab(file.tab(node.path))} />
</Tabs.Content>
</Tabs>
</div>
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css
index 2f3c914e1..f02b7deb5 100644
--- a/packages/ui/src/components/tabs.css
+++ b/packages/ui/src/components/tabs.css
@@ -219,7 +219,6 @@
height: auto;
padding: 6px;
gap: 4px;
- border-bottom: 1px solid var(--border-weak-base);
background-color: var(--background-base);
&::after {
@@ -230,7 +229,7 @@
[data-slot="tabs-trigger-wrapper"] {
height: 32px;
border: none;
- border-radius: 999px;
+ border-radius: var(--radius-sm);
background-color: transparent;
gap: 0;