fix(tui): interaction improvements to diff viewer (#28851)

This commit is contained in:
James Long
2026-05-22 12:30:10 -04:00
committed by GitHub
parent 8a55920531
commit 69e4f52272
5 changed files with 285 additions and 44 deletions

View File

@@ -2,12 +2,17 @@ import { describe, expect, test } from "bun:test"
import {
allExpandedFileTreeDirectories,
buildFileTree,
fileTreeFileSelection,
flattenFileTree,
moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToFile,
moveFileTreeSelectionToParent,
movePatchFileIndex,
orderedPatchFileIndexes,
relativePatchFileIndexFromViewport,
setFileTreeDirectoryExpanded,
singlePatchFileIndex,
toggleFileTreeDirectory,
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
@@ -233,6 +238,100 @@ describe("diff viewer file tree utilities", () => {
expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
})
test("selects a file tree node and expands its parents for a patch file", () => {
const tree = buildFileTree([
{ file: "src/config/tui.ts" },
{ file: "src/session/index.ts" },
{ file: "README.md" },
])
const selection = fileTreeFileSelection(tree, 1)
expect(selection?.highlightedNode).toBe(tree.nodes.find((node) => node.kind === "file" && node.name === "index.ts")?.id)
expect([...selection!.expandedNodes].map((id) => tree.nodes[id]!.name)).toEqual(["session", "src"])
expect(fileTreeFileSelection(tree, 99)).toBeUndefined()
})
test("prefers the selected file when choosing the single patch file", () => {
expect(singlePatchFileIndex(2, 1, 0, 3)).toBe(2)
expect(singlePatchFileIndex(undefined, 1, 0, 3)).toBe(1)
expect(singlePatchFileIndex(undefined, undefined, 0, 3)).toBe(0)
expect(singlePatchFileIndex(undefined, undefined, undefined, 3)).toBe(3)
})
test("orders patches by the flattened file tree order", () => {
const rows = flattenFileTree(
buildFileTree([
{ file: "src/dir-8/juniper-4.ts" },
{ file: "src/dir-8/harbor-94.ts" },
{ file: "src/dir-8/cedar-16.ts" },
]),
)
expect(orderedPatchFileIndexes(rows)).toEqual([2, 1, 0])
})
test("moves patch selection through the ordered patch file indexes", () => {
const fileIndexes = [2, 1, 0]
expect(movePatchFileIndex(fileIndexes, undefined, 1)).toBe(2)
expect(movePatchFileIndex(fileIndexes, undefined, -1)).toBe(0)
expect(movePatchFileIndex(fileIndexes, 2, 1)).toBe(1)
expect(movePatchFileIndex(fileIndexes, 1, -1)).toBe(2)
expect(movePatchFileIndex(fileIndexes, 0, 1)).toBe(0)
expect(movePatchFileIndex(fileIndexes, 99, 1)).toBe(2)
expect(movePatchFileIndex([], undefined, 1)).toBeUndefined()
})
test("moves to the next visible patch title below the viewport", () => {
expect(
relativePatchFileIndexFromViewport(
[
{ fileIndex: 0, titleContentY: 0 },
{ fileIndex: 1, titleContentY: 30 },
{ fileIndex: 2, titleContentY: 60 },
],
10,
1,
),
).toBe(1)
expect(
relativePatchFileIndexFromViewport(
[
{ fileIndex: 0, titleContentY: 0 },
{ fileIndex: 1, titleContentY: 30 },
{ fileIndex: 2, titleContentY: 60 },
],
30,
1,
),
).toBe(2)
})
test("moves to the previous visible patch title above the viewport", () => {
expect(
relativePatchFileIndexFromViewport(
[
{ fileIndex: 0, titleContentY: 0 },
{ fileIndex: 1, titleContentY: 30 },
{ fileIndex: 2, titleContentY: 60 },
],
50,
-1,
),
).toBe(1)
expect(
relativePatchFileIndexFromViewport(
[
{ fileIndex: 0, titleContentY: 0 },
{ fileIndex: 1, titleContentY: 30 },
{ fileIndex: 2, titleContentY: 60 },
],
30,
-1,
),
).toBe(0)
})
test("toggles only selected directory expansion", () => {
const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }])
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!

View File

@@ -155,7 +155,7 @@ async function renderFrame(component: () => JSX.Element) {
const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
try {
await renderOnceSettled(app)
return app.captureCharFrame()
return await captureSettledFrame(app)
} finally {
app.renderer.destroy()
}
@@ -167,6 +167,16 @@ async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
await app.renderOnce()
}
async function captureSettledFrame(app: Awaited<ReturnType<typeof testRender>>) {
for (let attempt = 0; attempt < 5; attempt++) {
const frame = app.captureCharFrame()
if (frame.trim().length > 0) return frame
await new Promise((resolve) => setTimeout(resolve, 25))
await app.renderOnce()
}
return app.captureCharFrame()
}
function withTheme(component: () => JSX.Element) {
return (
<TuiConfigProvider config={createTuiResolvedConfig()}>