Jelajahi Sumber

feat(tui): collapse directories when possible in file tree (#28512)

James Long 3 bulan lalu
induk
melakukan
d0779d2aca

+ 25 - 5
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<number>): 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]!

+ 30 - 6
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 (
     <box
-      width={32}
+      width={FILE_TREE_WIDTH}
       flexShrink={0}
       backgroundColor={props.theme.backgroundPanel}
       paddingLeft={1}
@@ -68,11 +72,15 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
             <For each={rows()}>
               {(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 (
-                  <box flexDirection="row">
-                    <text fg={row.kind === "directory" ? props.theme.textMuted : props.theme.text} wrapMode="none">
-                      {`${"  ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : "  "}`}
-                    </text>
+                  <box flexDirection="row" width="100%">
                     <text
                       fg={
                         highlighted()
@@ -83,9 +91,25 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
                       }
                       bg={highlighted() ? props.theme.primary : undefined}
                       wrapMode="none"
+                      flexShrink={0}
                     >
-                      {row.name}
+                      {prefix()}
                     </text>
+                    <box flexGrow={1} minWidth={0}>
+                      <text
+                        fg={
+                          highlighted()
+                            ? props.theme.background
+                            : row.kind === "directory"
+                              ? props.theme.textMuted
+                              : props.theme.text
+                        }
+                        bg={highlighted() ? props.theme.primary : undefined}
+                        wrapMode="none"
+                      >
+                        {name()}
+                      </text>
+                    </box>
                   </box>
                 )
               }}

+ 70 - 13
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)

+ 4 - 4
packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx

@@ -89,8 +89,8 @@ describe("DiffViewerFileTree", () => {
       await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />),
     )
 
-    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", () => {
           <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
         )),
       ),
-    ).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"])
   })
 })