feat(tui): design revamp of diff viewer (#28728)

This commit is contained in:
James Long
2026-05-21 18:34:32 -04:00
committed by GitHub
parent bbbef0da1c
commit ee008923f3
6 changed files with 515 additions and 256 deletions

View File

@@ -4,7 +4,9 @@ import {
buildFileTree,
flattenFileTree,
moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToFile,
moveFileTreeSelectionToParent,
setFileTreeDirectoryExpanded,
toggleFileTreeDirectory,
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
@@ -177,6 +179,47 @@ describe("diff viewer file tree utilities", () => {
expect(moveFileTreeSelection([], undefined, 1)).toBeUndefined()
})
test("moves directory selection to first visible child", () => {
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }]))
const src = rows.find((row) => row.kind === "directory" && row.name === "src")!
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
const tui = rows.find((row) => row.name === "tui.ts")!
expect(moveFileTreeSelectionToFirstChild(rows, src.id)).toBe(config.id)
expect(moveFileTreeSelectionToFirstChild(rows, tui.id)).toBe(tui.id)
expect(moveFileTreeSelectionToFirstChild(rows, undefined)).toBeUndefined()
})
test("moves collapsed chain selection to first visible child", () => {
const rows = flattenFileTree(
buildFileTree([
{ file: "packages/opencode/src/cli/app.ts" },
{ file: "packages/opencode/src/server/server.ts" },
]),
)
const packages = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
expect(moveFileTreeSelectionToFirstChild(rows, packages.id)).toBe(cli.id)
})
test("moves file and collapsed directory selection to visible parent", () => {
const rows = flattenFileTree(
buildFileTree([
{ file: "packages/opencode/src/cli/app.ts" },
{ file: "packages/opencode/src/server/server.ts" },
]),
)
const root = rows.find((row) => row.kind === "directory" && row.name === "packages/opencode/src")!
const cli = rows.find((row) => row.kind === "directory" && row.name === "cli")!
const app = rows.find((row) => row.name === "app.ts")!
expect(moveFileTreeSelectionToParent(rows, app.id)).toBe(cli.id)
expect(moveFileTreeSelectionToParent(rows, cli.id)).toBe(root.id)
expect(moveFileTreeSelectionToParent(rows, root.id)).toBe(root.id)
expect(moveFileTreeSelectionToParent(rows, undefined)).toBeUndefined()
})
test("moves file selection relative to the highlighted row", () => {
const rows = flattenFileTree(
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),

View File

@@ -3,6 +3,10 @@ import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
import {
allExpandedFileTreeDirectories,
@@ -14,6 +18,7 @@ const theme = {
backgroundPanel: RGBA.fromHex("#111111"),
backgroundElement: RGBA.fromHex("#333333"),
primary: RGBA.fromHex("#00ffff"),
secondary: RGBA.fromHex("#0088ff"),
selectedListItemText: RGBA.fromHex("#ffffff"),
text: RGBA.fromHex("#ffffff"),
textMuted: RGBA.fromHex("#888888"),
@@ -23,44 +28,50 @@ const theme = {
describe("DiffViewerFileTree", () => {
test("renders sorted hierarchical file rows", async () => {
const app = await testRender(
() => (
<DiffViewerFileTree
files={[
{ file: "z-file.ts" },
{ file: "b/file.ts" },
{ file: "a/zeta.ts" },
{ file: "b/alpha.ts" },
{ file: "a/alpha.ts" },
]}
loading={false}
error={undefined}
theme={theme}
focused={true}
/>
() => withTheme(
() => (
<DiffViewerFileTree
width={32}
files={[
{ file: "z-file.ts" },
{ file: "b/file.ts" },
{ file: "a/zeta.ts" },
{ file: "b/alpha.ts" },
{ file: "a/alpha.ts" },
]}
loading={false}
error={undefined}
theme={theme}
focused={true}
/>
),
),
{ width: 40, height: 20 },
)
try {
await app.renderOnce()
await renderOnceSettled(app)
const lines = visibleLines(app.captureCharFrame())
expect(lines).toEqual(["▾ a", " alpha.ts", " zeta.ts", "▾ b", " alpha.ts", " file.ts", " z-file.ts"])
expect(lines).toEqual([
"▾ a",
"│ ├─ alpha.ts ?",
"│ └─ zeta.ts ?",
"├─ ▾ b",
"│ ├─ alpha.ts ?",
"│ └─ file.ts ?",
])
} finally {
app.renderer.destroy()
}
})
test("keeps loading and error quiet while rendering an empty settled state", async () => {
const loading = await renderFrame(() => (
<DiffViewerFileTree files={[]} loading={true} error={undefined} theme={theme} />
))
const loading = await renderFrame(() => <DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />)
const failed = await renderFrame(() => (
<DiffViewerFileTree files={[]} loading={false} error={new Error("nope")} theme={theme} />
))
const empty = await renderFrame(() => (
<DiffViewerFileTree files={[]} loading={false} error={undefined} theme={theme} />
<DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
))
const empty = await renderFrame(() => <DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />)
expect(loading).not.toContain("Loading diff...")
expect(loading).not.toContain("No files")
@@ -75,18 +86,11 @@ describe("DiffViewerFileTree", () => {
const focused = visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
files={files}
loading={false}
error={undefined}
theme={theme}
focused
highlightedNode={src.id}
/>
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} focused highlightedNode={src.id} />
)),
)
const unfocused = visibleLines(
await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />),
await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />),
)
expect(focused).toContain("▾ src/config")
@@ -105,16 +109,17 @@ describe("DiffViewerFileTree", () => {
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
<DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
)),
),
).toEqual(["▸ src/config", " README.md"])
).toEqual(["▸ src/config"])
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
files={files}
width={32}
loading={false}
error={undefined}
theme={theme}
@@ -122,25 +127,41 @@ describe("DiffViewerFileTree", () => {
/>
)),
),
).toEqual(["▾ src/config", " tui.ts", " README.md"])
).toEqual(["▾ src/config", " └─ tui.ts ?"])
})
})
async function renderFrame(component: () => JSX.Element) {
const app = await testRender(component, { width: 40, height: 10 })
const app = await testRender(() => withTheme(component), { width: 40, height: 10 })
try {
await app.renderOnce()
await renderOnceSettled(app)
return app.captureCharFrame()
} finally {
app.renderer.destroy()
}
}
async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
await app.renderOnce()
await new Promise((resolve) => setTimeout(resolve, 25))
await app.renderOnce()
}
function withTheme(component: () => JSX.Element) {
return (
<TuiConfigProvider config={createTuiResolvedConfig()}>
<KVProvider>
<ThemeProvider mode="dark">{component()}</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
)
}
function visibleLines(frame: string) {
return frame
.split("\n")
.map((line) => line.trimEnd())
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
.map((line) => (line.startsWith(" ") ? line.slice(1) : line))
.filter((line) => line.length > 0 && !/^┌|^└/.test(line))
.filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
}