| import { describe, expect, test } from "bun:test" |
| import { |
| allExpandedFileTreeDirectories, |
| buildFileTree, |
| fileTreeFileSelection, |
| flattenFileTree, |
| moveFileTreeSelection, |
| moveFileTreeSelectionToFirstChild, |
| moveFileTreeSelectionToFile, |
| moveFileTreeSelectionToParent, |
| movePatchFileIndex, |
| orderedPatchFileIndexes, |
| setFileTreeDirectoryExpanded, |
| showDiffViewerFileTree, |
| singlePatchFileIndex, |
| toggleFileTreeDirectory, |
| } from "../../src/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("collapses unary directory chains while flattening", () => { |
| const rows = flattenFileTree( |
| buildFileTree([{ file: "packages/opencode/src/cli/app.ts" }, { file: "packages/opencode/src/server/server.ts" }]), |
| ) |
|
|
| expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([ |
| "directory:packages/opencode/src", |
| " directory:cli", |
| " file:app.ts", |
| " directory:server", |
| " file:server.ts", |
| ]) |
| }) |
|
|
| test("does not collapse a directory into a file row", () => { |
| const rows = flattenFileTree(buildFileTree([{ file: "packages/opencode/src/app.ts" }])) |
|
|
| expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([ |
| "directory:packages/opencode/src", |
| " file:app.ts", |
| ]) |
| }) |
|
|
| test("stops collapsing at branches", () => { |
| const rows = flattenFileTree( |
| buildFileTree([ |
| { file: "packages/opencode/src/cli/app.ts" }, |
| { file: "packages/opencode/src/server/server.ts" }, |
| { file: "packages/readme.md" }, |
| ]), |
| ) |
|
|
| expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([ |
| "directory:packages", |
| " directory:opencode/src", |
| " directory:cli", |
| " file:app.ts", |
| " directory:server", |
| " file:server.ts", |
| " file:readme.md", |
| ]) |
| }) |
|
|
| 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/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/config", kind: "directory", depth: 0, fileIndex: undefined }, |
| { name: "keybind.ts", kind: "file", depth: 1, fileIndex: 1 }, |
| { name: "tui.ts", kind: "file", depth: 1, fileIndex: 0 }, |
| { name: "README.md", kind: "file", depth: 0, fileIndex: 2 }, |
| ], |
| ) |
| }) |
|
|
| test("collapses expanded unary children under the first visible directory id", () => { |
| const tree = buildFileTree([ |
| { file: "packages/opencode/src/cli/app.ts" }, |
| { file: "packages/opencode/src/server/server.ts" }, |
| ]) |
| const packages = tree.nodes.find((node) => node.kind === "directory" && node.name === "packages")! |
|
|
| expect(flattenFileTree(tree, new Set()).map((row) => row.name)).toEqual(["packages/opencode/src"]) |
| expect(flattenFileTree(tree, new Set([packages.id])).map((row) => row.name)).toEqual([ |
| "packages/opencode/src", |
| "cli", |
| "server", |
| ]) |
| }) |
|
|
| 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 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" }]), |
| ) |
| 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("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("shows the diff viewer file tree only when enabled and files exist", () => { |
| expect(showDiffViewerFileTree(true, 1)).toBe(true) |
| expect(showDiffViewerFileTree(true, 0)).toBe(false) |
| expect(showDiffViewerFileTree(false, 1)).toBe(false) |
| expect(showDiffViewerFileTree(false, 0)).toBe(false) |
| }) |
|
|
| 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(2) |
| 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(fileIndexes, 99, -1)).toBe(2) |
| expect(movePatchFileIndex([], undefined, 1)).toBeUndefined() |
| }) |
|
|
| 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/config", "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) |
| }) |
| }) |
|
|