Skip to content

Commit 69e4f52

Browse files
authored
fix(tui): interaction improvements to diff viewer (anomalyco#28851)
1 parent 8a55920 commit 69e4f52

5 files changed

Lines changed: 285 additions & 44 deletions

File tree

packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,49 @@ export function moveFileTreeSelectionToFile(
157157
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
158158
}
159159

160+
export function fileTreeFileSelection(tree: FileTree, fileIndex: number) {
161+
const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
162+
if (!node) return undefined
163+
return {
164+
highlightedNode: node.id,
165+
expandedNodes: fileTreeParentDirectories(tree, node.id),
166+
}
167+
}
168+
169+
export function singlePatchFileIndex(
170+
selected: number | undefined,
171+
active: number | undefined,
172+
current: number | undefined,
173+
first: number | undefined,
174+
) {
175+
return selected ?? active ?? current ?? first
176+
}
177+
178+
export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
179+
return rows.flatMap((row) => (row.fileIndex === undefined ? [] : [row.fileIndex]))
180+
}
181+
182+
export function movePatchFileIndex(
183+
fileIndexes: readonly number[],
184+
current: number | undefined,
185+
offset: number,
186+
) {
187+
if (fileIndexes.length === 0) return undefined
188+
const index = current === undefined ? -1 : fileIndexes.indexOf(current)
189+
if (index === -1) return offset < 0 ? fileIndexes[fileIndexes.length - 1] : fileIndexes[0]
190+
return fileIndexes[Math.max(0, Math.min(fileIndexes.length - 1, index + offset))]
191+
}
192+
193+
export function relativePatchFileIndexFromViewport(
194+
entries: readonly { readonly fileIndex: number; readonly titleContentY: number }[],
195+
scrollTop: number,
196+
offset: number,
197+
) {
198+
const ordered = [...entries].sort((left, right) => left.titleContentY - right.titleContentY)
199+
if (offset > 0) return ordered.find((entry) => entry.titleContentY > scrollTop)?.fileIndex
200+
return ordered.findLast((entry) => entry.titleContentY < scrollTop)?.fileIndex
201+
}
202+
160203
export function allExpandedFileTreeDirectories(tree: FileTree) {
161204
return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id))
162205
}
@@ -189,3 +232,11 @@ function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<Fil
189232
else nodes[input.parent]!.children.push(id)
190233
return id
191234
}
235+
236+
function fileTreeParentDirectories(tree: FileTree, id: number) {
237+
const result = new Set<number>()
238+
for (let parent = tree.nodes[id]?.parent; parent !== undefined; parent = tree.nodes[parent]?.parent) {
239+
result.add(parent)
240+
}
241+
return result
242+
}

packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,10 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
9595
fg={
9696
highlighted()
9797
? props.theme.background
98-
: reviewed()
99-
? props.theme.textMuted
100-
: selected()
101-
? props.theme.primary
98+
: selected()
99+
? props.theme.primary
100+
: reviewed()
101+
? props.theme.textMuted
102102
: row.kind === "directory"
103103
? tint(props.theme.text, props.theme.background, 0.35)
104104
: props.theme.text

packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx

Lines changed: 120 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,17 @@ import { DialogSelect } from "@tui/ui/dialog-select"
1414
import {
1515
allExpandedFileTreeDirectories,
1616
buildFileTree,
17+
fileTreeFileSelection,
1718
flattenFileTree,
1819
moveFileTreeSelection,
1920
moveFileTreeSelectionToFirstChild,
2021
moveFileTreeSelectionToFile,
2122
moveFileTreeSelectionToParent,
23+
movePatchFileIndex,
24+
orderedPatchFileIndexes,
25+
relativePatchFileIndexFromViewport,
2226
setFileTreeDirectoryExpanded,
27+
singlePatchFileIndex,
2328
toggleFileTreeDirectory,
2429
} from "./diff-viewer-file-tree-utils"
2530

@@ -108,6 +113,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
108113
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
109114
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
110115
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
116+
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
111117
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
112118
const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
113119
const nextFileShortcut = useCommandShortcut("diff.next_file")
@@ -154,94 +160,158 @@ function DiffViewer(props: { api: TuiPluginApi }) {
154160
setActivePatchFileIndex(undefined)
155161
}
156162

157-
const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => {
158-
if (!scroll) return
159-
const offset = fileIndex === 0 ? 0 : 1
160-
scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
163+
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
161164
requestAnimationFrame(() => {
162-
if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y + offset)
165+
if (!scroll) return
166+
const scrollDelta = patchNode.y - scroll.viewport.y
167+
const contentY = scroll.scrollTop + scrollDelta
168+
const offset = contentY === 0 ? 0 : 1
169+
scroll.scrollBy(scrollDelta + offset)
163170
})
164171
}
165172

166173
const revealFileTreeFile = (fileIndex: number) => {
167-
const node = fileTree().nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex)
168-
if (!node) return
174+
const selection = fileTreeFileSelection(fileTree(), fileIndex)
175+
if (!selection) return
169176
setExpandedFileNodes((expanded) => {
170177
const next = new Set(expanded)
171-
for (let parent = node.parent; parent !== undefined; parent = fileTree().nodes[parent]?.parent) {
172-
next.add(parent)
173-
}
178+
selection.expandedNodes.forEach((node) => next.add(node))
174179
return next
175180
})
176-
setHighlighted(node.id)
181+
setHighlighted(selection.highlightedNode)
177182
}
178183

179-
const scrollToFileIndex = (fileIndex: number | undefined) => {
180-
if (fileIndex === undefined) return
184+
const selectPatchFile = (fileIndex: number) => {
185+
revealFileTreeFile(fileIndex)
181186
setActivePatchFileIndex(fileIndex)
182187
setSelectedFileIndex(fileIndex)
188+
}
189+
190+
const scrollToFileIndex = (fileIndex: number | undefined) => {
191+
if (fileIndex === undefined) return
192+
selectPatchFile(fileIndex)
183193
const patchNode = patchNodeByFileIndex.get(fileIndex)
184-
if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex)
194+
if (patchNode) scrollPatchNodeToTop(patchNode)
185195
}
186196

187197
const jumpToFileIndex = (fileIndex: number | undefined) => {
188198
if (fileIndex === undefined) return
189-
revealFileTreeFile(fileIndex)
190199
scrollToFileIndex(fileIndex)
191200
}
192201

193202
const currentPatchFileIndex = () => {
194203
if (!scroll) return undefined
195-
const entries = files()
196-
.map((_, fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) }))
204+
const viewportContentY = scroll.scrollTop + 1
205+
const entries = patchFileIndexes()
206+
.map((fileIndex) => ({
207+
fileIndex,
208+
node: patchNodeByFileIndex.get(fileIndex),
209+
}))
197210
.filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
198-
.sort((left, right) => left.node.y - right.node.y)
199-
return entries.findLast((entry) => entry.node.y <= scroll!.viewport.y + 1)?.fileIndex ?? entries[0]?.fileIndex
211+
.map((entry) => ({
212+
...entry,
213+
contentY: scroll!.scrollTop + entry.node.y - scroll!.viewport.y,
214+
}))
215+
.sort((left, right) => left.contentY - right.contentY)
216+
return entries.findLast((entry) => entry.contentY <= viewportContentY)?.fileIndex ?? entries[0]?.fileIndex
217+
}
218+
219+
const nextPatchFileIndexFromViewport = (offset: number) => {
220+
if (!scroll) return undefined
221+
return relativePatchFileIndexFromViewport(
222+
patchFileIndexes()
223+
.map((fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) }))
224+
.filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
225+
.map((entry) => {
226+
const contentY = scroll!.scrollTop + entry.node.y - scroll!.viewport.y
227+
return {
228+
fileIndex: entry.fileIndex,
229+
titleContentY: contentY + (contentY === 0 ? 0 : 1),
230+
}
231+
}),
232+
scroll.scrollTop,
233+
offset,
234+
)
200235
}
201236

202237
const jumpRelativePatchFile = (offset: number) => {
238+
if (singlePatch()) {
239+
const next = movePatchFileIndex(
240+
patchFileIndexes(),
241+
visiblePatchFiles()[0]?.fileIndex ?? selectedFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex(),
242+
offset,
243+
)
244+
if (next === undefined) return
245+
selectPatchFile(next)
246+
scrollSinglePatchToTop()
247+
return
248+
}
249+
203250
const current = focus() === "files" ? highlightedFileNode() : undefined
204251
const nextFromSelection =
205252
current === undefined ? undefined : moveFileTreeSelectionToFile(fileRows(), current, offset)
206253
if (nextFromSelection !== undefined) {
207254
jumpToFileIndex(fileRows().find((row) => row.id === nextFromSelection)?.fileIndex)
208255
return
209256
}
210-
const currentFileIndex = activePatchFileIndex() ?? currentPatchFileIndex()
211-
const currentRow = fileRows().find((row) => row.fileIndex === currentFileIndex)
212257
scrollToFileIndex(
213-
fileRows().find((row) => row.id === moveFileTreeSelectionToFile(fileRows(), currentRow?.id, offset))?.fileIndex,
258+
nextPatchFileIndexFromViewport(offset) ??
259+
movePatchFileIndex(patchFileIndexes(), currentPatchFileIndex() ?? activePatchFileIndex(), offset),
214260
)
215261
}
216262

217263
const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
218264
const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex
219265
const visiblePatchFiles = createMemo(() => {
220-
if (!singlePatch()) return files().map((file, fileIndex) => ({ file, fileIndex }))
221-
const fileIndex = activePatchFileIndex() ?? currentPatchFileIndex() ?? firstPatchFileIndex()
266+
if (!singlePatch()) {
267+
return patchFileIndexes().flatMap((fileIndex) => {
268+
const file = files()[fileIndex]
269+
return file ? [{ file, fileIndex }] : []
270+
})
271+
}
272+
const fileIndex = singlePatchFileIndex(
273+
selectedFileIndex(),
274+
activePatchFileIndex(),
275+
currentPatchFileIndex(),
276+
firstPatchFileIndex(),
277+
)
222278
const file = fileIndex === undefined ? undefined : files()[fileIndex]
223279
return file && fileIndex !== undefined ? [{ file, fileIndex }] : []
224280
})
225281

226282
const ensureHighlightedPatchFile = () => {
227-
if (activePatchFileIndex() !== undefined) return
228-
const fileIndex = currentPatchFileIndex() ?? firstPatchFileIndex()
229-
if (fileIndex !== undefined) setActivePatchFileIndex(fileIndex)
283+
const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex()
284+
if (fileIndex === undefined) return
285+
selectPatchFile(fileIndex)
230286
}
231287

232-
const scrollToHighlightedPatchFile = () => {
233-
const fileIndex = activePatchFileIndex()
234-
if (fileIndex === undefined) return
288+
const scrollToPatchFileIndexAfterRender = (fileIndex: number) => {
235289
setPendingPatchScrollFileIndex(fileIndex)
290+
requestAnimationFrame(() => {
291+
const patchNode = patchNodeByFileIndex.get(fileIndex)
292+
if (patchNode) scrollPatchNodeToTop(patchNode)
293+
requestAnimationFrame(() => {
294+
const patchNode = patchNodeByFileIndex.get(fileIndex)
295+
if (patchNode) scrollPatchNodeToTop(patchNode)
296+
setPendingPatchScrollFileIndex(undefined)
297+
})
298+
})
299+
}
300+
301+
const scrollSinglePatchToTop = () => {
302+
requestAnimationFrame(() => {
303+
scroll?.scrollTo(0)
304+
requestAnimationFrame(() => scroll?.scrollTo(0))
305+
})
236306
}
237307

238308
const registerPatchNode = (fileIndex: number, element: BoxRenderable) => {
239309
patchNodeByFileIndex.set(fileIndex, element)
240310
if (pendingPatchScrollFileIndex() !== fileIndex) return
241311
requestAnimationFrame(() => {
242-
scrollPatchNodeToTop(element, fileIndex)
312+
scrollPatchNodeToTop(element)
243313
requestAnimationFrame(() => {
244-
scrollPatchNodeToTop(element, fileIndex)
314+
scrollPatchNodeToTop(element)
245315
setPendingPatchScrollFileIndex(undefined)
246316
})
247317
})
@@ -437,12 +507,23 @@ function DiffViewer(props: { api: TuiPluginApi }) {
437507
title: "Toggle single patch view",
438508
category: "VCS",
439509
run() {
440-
setSinglePatch((value) => {
441-
const next = !value
442-
if (next) ensureHighlightedPatchFile()
443-
else scrollToHighlightedPatchFile()
444-
return next
445-
})
510+
if (!singlePatch()) {
511+
ensureHighlightedPatchFile()
512+
setSinglePatch(true)
513+
scrollSinglePatchToTop()
514+
return
515+
}
516+
const fileIndex =
517+
visiblePatchFiles()[0]?.fileIndex ??
518+
singlePatchFileIndex(
519+
selectedFileIndex(),
520+
activePatchFileIndex(),
521+
currentPatchFileIndex(),
522+
firstPatchFileIndex(),
523+
)
524+
if (fileIndex !== undefined) selectPatchFile(fileIndex)
525+
setSinglePatch(false)
526+
if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
446527
},
447528
},
448529
{
@@ -581,7 +662,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
581662
flexDirection="row"
582663
gap={1}
583664
flexShrink={0}
584-
paddingLeft={2}
665+
paddingLeft={1}
585666
paddingRight={1}
586667
border={["left"]}
587668
borderColor={theme().border}

0 commit comments

Comments
 (0)