From d0779d2aca699e96a00b5186d0d03dc7fb2dc1d4 Mon Sep 17 00:00:00 2001 From: James Long Date: Wed, 20 May 2026 16:22:04 -0400 Subject: [PATCH] feat(tui): collapse directories when possible in file tree (#28512) --- .../system/diff-viewer-file-tree-utils.ts | 30 +++++-- .../system/diff-viewer-file-tree.tsx | 36 ++++++-- .../tui/diff-viewer-file-tree-utils.test.ts | 83 ++++++++++++++++--- .../cli/tui/diff-viewer-file-tree.test.tsx | 8 +- 4 files changed, 129 insertions(+), 28 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts index 44e6a3fdd2..5ea7f189b3 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts @@ -74,21 +74,41 @@ export function buildFileTree(files: readonly FileTreeItem[]): FileTree { export function flattenFileTree(tree: FileTree, expanded?: ReadonlySet): FileTreeRow[] { const rows: FileTreeRow[] = [] - const visit = (id: number) => { + const visit = (id: number, depth: number) => { const node = tree.nodes[id]! + if (node.kind === "file") { + rows.push({ + id: node.id, + depth, + kind: node.kind, + name: node.name, + fileIndex: node.fileIndex, + }) + return + } + + const chain = collapsedFileTreeDirectoryChain(tree, node.id) + const last = chain[chain.length - 1]! rows.push({ id: node.id, - depth: node.depth, + depth, kind: node.kind, - name: node.name, + name: chain.map((item) => item.name).join("/"), fileIndex: node.fileIndex, }) - if (node.kind === "directory" && (!expanded || expanded.has(node.id))) node.children.forEach(visit) + if (!expanded || expanded.has(node.id)) last.children.forEach((child) => visit(child, depth + 1)) } - tree.roots.forEach(visit) + tree.roots.forEach((root) => visit(root, 0)) return rows } +function collapsedFileTreeDirectoryChain(tree: FileTree, id: number): FileTreeNode[] { + const node = tree.nodes[id]! + const child = node.children.length === 1 ? tree.nodes[node.children[0]!] : undefined + if (child?.kind !== "directory") return [node] + return [node, ...collapsedFileTreeDirectoryChain(tree, child.id)] +} + export function compareFileTreeNodes(tree: FileTree, left: number, right: number) { const leftNode = tree.nodes[left]! const rightNode = tree.nodes[right]! diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx index 80c9c97b3a..e55ef7aa7b 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx @@ -1,8 +1,12 @@ /** @jsxImportSource @opentui/solid */ import type { ColorInput, ScrollBoxRenderable } from "@opentui/core" +import { Locale } from "@/util/locale" import { createEffect, createMemo, For, Match, Switch } from "solid-js" import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils" +const FILE_TREE_WIDTH = 32 +const FILE_TREE_HORIZONTAL_PADDING = 2 + export type DiffViewerFileTreeTheme = { readonly background: ColorInput readonly backgroundPanel: ColorInput @@ -41,7 +45,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { return ( {(row) => { const highlighted = () => props.focused && props.highlightedNode === row.id + const prefix = () => + `${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}` + const name = () => + Locale.truncate( + row.name, + Math.max(1, FILE_TREE_WIDTH - FILE_TREE_HORIZONTAL_PADDING - prefix().length), + ) return ( - - - {`${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}`} - + - {row.name} + {prefix()} + + + {name()} + + ) }} diff --git a/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts b/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts index 755a25845f..7295a76f54 100644 --- a/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts +++ b/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts @@ -60,6 +60,52 @@ describe("diff viewer file tree utilities", () => { 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" }]), @@ -68,9 +114,8 @@ describe("diff viewer file tree utilities", () => { expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([ "directory:components", " file:button.ts", - "directory:docs", - " directory:components", - " file:usage.md", + "directory:docs/components", + " file:usage.md", ]) }) @@ -79,15 +124,27 @@ describe("diff viewer file tree utilities", () => { 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 }, - ], - ) + 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", () => { @@ -148,7 +205,7 @@ describe("diff viewer file tree utilities", () => { 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"]) + 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) diff --git a/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx index 6bbe5abb02..689bc7fd9c 100644 --- a/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -89,8 +89,8 @@ describe("DiffViewerFileTree", () => { await renderFrame(() => ), ) - expect(focused).toContain("▾ src") - expect(unfocused).toContain("▾ src") + expect(focused).toContain("▾ src/config") + expect(unfocused).toContain("▾ src/config") expect(focused.some((line) => line.includes("*"))).toBe(false) expect(unfocused.some((line) => line.includes("*"))).toBe(false) }) @@ -108,7 +108,7 @@ describe("DiffViewerFileTree", () => { )), ), - ).toEqual(["▸ src", " README.md"]) + ).toEqual(["▸ src/config", " README.md"]) expect( visibleLines( @@ -122,7 +122,7 @@ describe("DiffViewerFileTree", () => { /> )), ), - ).toEqual(["▾ src", " ▾ config", " tui.ts", " README.md"]) + ).toEqual(["▾ src/config", " tui.ts", " README.md"]) }) })