feat(tui): initial impl of diff viewer (#28476)
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFile,
|
||||
setFileTreeDirectoryExpanded,
|
||||
toggleFileTreeDirectory,
|
||||
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
|
||||
|
||||
describe("diff viewer file tree utilities", () => {
|
||||
test("builds a nested tree with deduplicated directories and file indexes", () => {
|
||||
const tree = buildFileTree([
|
||||
{ file: "src/config/tui.ts" },
|
||||
{ file: "src/config/keybind.ts" },
|
||||
{ file: "src/session/index.ts" },
|
||||
])
|
||||
|
||||
expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "src")).toHaveLength(1)
|
||||
expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "config")).toHaveLength(1)
|
||||
expect(tree.nodes.filter((node) => node.kind === "directory" && node.name === "session")).toHaveLength(1)
|
||||
expect(
|
||||
tree.nodes
|
||||
.filter((node) => node.kind === "file")
|
||||
.map((node) => ({ name: node.name, fileIndex: node.fileIndex, depth: node.depth })),
|
||||
).toEqual([
|
||||
{ name: "tui.ts", fileIndex: 0, depth: 2 },
|
||||
{ name: "keybind.ts", fileIndex: 1, depth: 2 },
|
||||
{ name: "index.ts", fileIndex: 2, depth: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
test("sorts directories before files and alphabetically within each group", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([
|
||||
{ file: "z-file.ts" },
|
||||
{ file: "b/file.ts" },
|
||||
{ file: "a/zeta.ts" },
|
||||
{ file: "b/alpha.ts" },
|
||||
{ file: "a/alpha.ts" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
|
||||
"directory:a",
|
||||
" file:alpha.ts",
|
||||
" file:zeta.ts",
|
||||
"directory:b",
|
||||
" file:alpha.ts",
|
||||
" file:file.ts",
|
||||
"file:z-file.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("sorts root-level files without creating directories", () => {
|
||||
const tree = buildFileTree([{ file: "zeta.ts" }, { file: "alpha.ts" }, { file: "beta.ts" }])
|
||||
|
||||
expect(tree.nodes.every((node) => node.kind === "file")).toBe(true)
|
||||
expect(flattenFileTree(tree).map((row) => row.name)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"])
|
||||
})
|
||||
|
||||
test("keeps same directory names under different parents separate", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([{ file: "components/button.ts" }, { file: "docs/components/usage.md" }]),
|
||||
)
|
||||
|
||||
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
|
||||
"directory:components",
|
||||
" file:button.ts",
|
||||
"directory:docs",
|
||||
" directory:components",
|
||||
" file:usage.md",
|
||||
])
|
||||
})
|
||||
|
||||
test("flattens all-expanded rows depth-first with depths and file references", () => {
|
||||
const rows = flattenFileTree(
|
||||
buildFileTree([
|
||||
{ file: "src/config/tui.ts" },
|
||||
{ file: "src/config/keybind.ts" },
|
||||
{ file: "README.md" },
|
||||
]),
|
||||
)
|
||||
|
||||
expect(rows.map((row) => ({ name: row.name, kind: row.kind, depth: row.depth, fileIndex: row.fileIndex }))).toEqual([
|
||||
{ name: "src", kind: "directory", depth: 0, fileIndex: undefined },
|
||||
{ name: "config", kind: "directory", depth: 1, fileIndex: undefined },
|
||||
{ name: "keybind.ts", kind: "file", depth: 2, fileIndex: 1 },
|
||||
{ name: "tui.ts", kind: "file", depth: 2, fileIndex: 0 },
|
||||
{ name: "README.md", kind: "file", depth: 0, fileIndex: 2 },
|
||||
])
|
||||
})
|
||||
|
||||
test("flattens only expanded directory descendants when expansion is provided", () => {
|
||||
const tree = buildFileTree([
|
||||
{ file: "src/config/tui.ts" },
|
||||
{ file: "src/session/index.ts" },
|
||||
{ file: "README.md" },
|
||||
])
|
||||
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
const config = tree.nodes.find((node) => node.kind === "directory" && node.name === "config")!
|
||||
|
||||
expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["src", "README.md"])
|
||||
expect(flattenFileTree(tree, new Set([src.id])).map((row) => row.name)).toEqual([
|
||||
"src",
|
||||
"config",
|
||||
"session",
|
||||
"README.md",
|
||||
])
|
||||
expect(flattenFileTree(tree, new Set([src.id, config.id])).map((row) => row.name)).toEqual([
|
||||
"src",
|
||||
"config",
|
||||
"tui.ts",
|
||||
"session",
|
||||
"README.md",
|
||||
])
|
||||
})
|
||||
|
||||
test("moves selection across visible rows and clamps to bounds", () => {
|
||||
const rows = flattenFileTree(buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }]))
|
||||
|
||||
expect(moveFileTreeSelection(rows, undefined, 1)).toBe(rows[0]!.id)
|
||||
expect(moveFileTreeSelection(rows, rows[0]!.id, 1)).toBe(rows[1]!.id)
|
||||
expect(moveFileTreeSelection(rows, rows[1]!.id, 99)).toBe(rows[rows.length - 1]!.id)
|
||||
expect(moveFileTreeSelection(rows, rows[1]!.id, -99)).toBe(rows[0]!.id)
|
||||
expect(moveFileTreeSelection([], undefined, 1)).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" },
|
||||
]),
|
||||
)
|
||||
const config = rows.find((row) => row.kind === "directory" && row.name === "config")!
|
||||
const session = rows.find((row) => row.kind === "directory" && row.name === "session")!
|
||||
const tui = rows.find((row) => row.name === "tui.ts")!
|
||||
const index = rows.find((row) => row.name === "index.ts")!
|
||||
const readme = rows.find((row) => row.name === "README.md")!
|
||||
|
||||
expect(moveFileTreeSelectionToFile(rows, undefined, 1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, undefined, -1)).toBe(readme.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, config.id, 1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, session.id, -1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, tui.id, 1)).toBe(index.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, index.id, -1)).toBe(tui.id)
|
||||
expect(moveFileTreeSelectionToFile(rows, readme.id, 1)).toBe(readme.id)
|
||||
})
|
||||
|
||||
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")!
|
||||
const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
|
||||
const expanded = allExpandedFileTreeDirectories(tree)
|
||||
|
||||
const collapsed = toggleFileTreeDirectory(tree, expanded, src.id)
|
||||
expect(collapsed.has(src.id)).toBe(false)
|
||||
expect(flattenFileTree(tree, collapsed).map((row) => row.name)).toEqual(["src", "README.md"])
|
||||
|
||||
const reopened = toggleFileTreeDirectory(tree, collapsed, src.id)
|
||||
expect(reopened.has(src.id)).toBe(true)
|
||||
|
||||
expect(toggleFileTreeDirectory(tree, reopened, readme.id)).toBe(reopened)
|
||||
expect(toggleFileTreeDirectory(tree, reopened, undefined)).toBe(reopened)
|
||||
})
|
||||
|
||||
test("sets 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")!
|
||||
const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")!
|
||||
const expanded = allExpandedFileTreeDirectories(tree)
|
||||
|
||||
const collapsed = setFileTreeDirectoryExpanded(tree, expanded, src.id, false)
|
||||
expect(collapsed.has(src.id)).toBe(false)
|
||||
|
||||
const reopened = setFileTreeDirectoryExpanded(tree, collapsed, src.id, true)
|
||||
expect(reopened.has(src.id)).toBe(true)
|
||||
|
||||
expect(setFileTreeDirectoryExpanded(tree, reopened, readme.id, false)).toBe(reopened)
|
||||
expect(setFileTreeDirectoryExpanded(tree, reopened, undefined, false)).toBe(reopened)
|
||||
})
|
||||
})
|
||||
143
packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx
Normal file
143
packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { RGBA } from "@opentui/core"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import type { JSX } from "solid-js"
|
||||
import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils"
|
||||
|
||||
const theme = {
|
||||
background: RGBA.fromHex("#000000"),
|
||||
backgroundPanel: RGBA.fromHex("#111111"),
|
||||
backgroundElement: RGBA.fromHex("#333333"),
|
||||
primary: RGBA.fromHex("#00ffff"),
|
||||
selectedListItemText: RGBA.fromHex("#ffffff"),
|
||||
text: RGBA.fromHex("#ffffff"),
|
||||
textMuted: RGBA.fromHex("#888888"),
|
||||
error: RGBA.fromHex("#ff0000"),
|
||||
}
|
||||
|
||||
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}
|
||||
/>
|
||||
),
|
||||
{ width: 40, height: 20 },
|
||||
)
|
||||
|
||||
try {
|
||||
await app.renderOnce()
|
||||
const lines = visibleLines(app.captureCharFrame())
|
||||
|
||||
expect(lines).toEqual([
|
||||
"▾ a",
|
||||
" alpha.ts",
|
||||
" zeta.ts",
|
||||
"▾ b",
|
||||
" alpha.ts",
|
||||
" file.ts",
|
||||
" z-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 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} />)
|
||||
|
||||
expect(loading).not.toContain("Loading diff...")
|
||||
expect(loading).not.toContain("No files")
|
||||
expect(failed).not.toContain("Failed to load diff")
|
||||
expect(failed).not.toContain("No files")
|
||||
expect(empty).toContain("No files")
|
||||
})
|
||||
|
||||
test("does not render text markers for highlighted rows", async () => {
|
||||
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
|
||||
const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
|
||||
const focused = visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree 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} />),
|
||||
)
|
||||
|
||||
expect(focused).toContain("▾ src")
|
||||
expect(unfocused).toContain("▾ src")
|
||||
expect(focused.some((line) => line.includes("*"))).toBe(false)
|
||||
expect(unfocused.some((line) => line.includes("*"))).toBe(false)
|
||||
})
|
||||
|
||||
test("renders collapsed and expanded directory rows", async () => {
|
||||
const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }]
|
||||
const tree = buildFileTree(files)
|
||||
const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")!
|
||||
const collapsed = allExpandedFileTreeDirectories(tree)
|
||||
collapsed.delete(src.id)
|
||||
|
||||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
|
||||
)),
|
||||
),
|
||||
).toEqual(["▸ src", " README.md"])
|
||||
|
||||
expect(
|
||||
visibleLines(
|
||||
await renderFrame(() => (
|
||||
<DiffViewerFileTree
|
||||
files={files}
|
||||
loading={false}
|
||||
error={undefined}
|
||||
theme={theme}
|
||||
expandedNodes={allExpandedFileTreeDirectories(tree)}
|
||||
/>
|
||||
)),
|
||||
),
|
||||
).toEqual(["▾ src", " ▾ config", " tui.ts", " README.md"])
|
||||
})
|
||||
})
|
||||
|
||||
async function renderFrame(component: () => JSX.Element) {
|
||||
const app = await testRender(component, { width: 40, height: 10 })
|
||||
try {
|
||||
await app.renderOnce()
|
||||
return app.captureCharFrame()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
Reference in New Issue
Block a user