mirror of
https://github.com/anomalyco/opencode.git
synced 2026-06-01 19:05:38 +00:00
feat(tui): collapse directories when possible in file tree (#28512)
This commit is contained in:
@@ -74,21 +74,41 @@ export function buildFileTree(files: readonly FileTreeItem[]): FileTree {
|
|||||||
|
|
||||||
export function flattenFileTree(tree: FileTree, expanded?: ReadonlySet<number>): FileTreeRow[] {
|
export function flattenFileTree(tree: FileTree, expanded?: ReadonlySet<number>): FileTreeRow[] {
|
||||||
const rows: FileTreeRow[] = []
|
const rows: FileTreeRow[] = []
|
||||||
const visit = (id: number) => {
|
const visit = (id: number, depth: number) => {
|
||||||
const node = tree.nodes[id]!
|
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({
|
rows.push({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
depth: node.depth,
|
depth,
|
||||||
kind: node.kind,
|
kind: node.kind,
|
||||||
name: node.name,
|
name: chain.map((item) => item.name).join("/"),
|
||||||
fileIndex: node.fileIndex,
|
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
|
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) {
|
export function compareFileTreeNodes(tree: FileTree, left: number, right: number) {
|
||||||
const leftNode = tree.nodes[left]!
|
const leftNode = tree.nodes[left]!
|
||||||
const rightNode = tree.nodes[right]!
|
const rightNode = tree.nodes[right]!
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
/** @jsxImportSource @opentui/solid */
|
/** @jsxImportSource @opentui/solid */
|
||||||
import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
|
import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
|
||||||
|
import { Locale } from "@/util/locale"
|
||||||
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
|
||||||
import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils"
|
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 = {
|
export type DiffViewerFileTreeTheme = {
|
||||||
readonly background: ColorInput
|
readonly background: ColorInput
|
||||||
readonly backgroundPanel: ColorInput
|
readonly backgroundPanel: ColorInput
|
||||||
@@ -41,7 +45,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<box
|
<box
|
||||||
width={32}
|
width={FILE_TREE_WIDTH}
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
backgroundColor={props.theme.backgroundPanel}
|
backgroundColor={props.theme.backgroundPanel}
|
||||||
paddingLeft={1}
|
paddingLeft={1}
|
||||||
@@ -68,11 +72,15 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||||||
<For each={rows()}>
|
<For each={rows()}>
|
||||||
{(row) => {
|
{(row) => {
|
||||||
const highlighted = () => props.focused && props.highlightedNode === row.id
|
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 (
|
return (
|
||||||
<box flexDirection="row">
|
<box flexDirection="row" width="100%">
|
||||||
<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>
|
|
||||||
<text
|
<text
|
||||||
fg={
|
fg={
|
||||||
highlighted()
|
highlighted()
|
||||||
@@ -83,9 +91,25 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
|||||||
}
|
}
|
||||||
bg={highlighted() ? props.theme.primary : undefined}
|
bg={highlighted() ? props.theme.primary : undefined}
|
||||||
wrapMode="none"
|
wrapMode="none"
|
||||||
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
{row.name}
|
{prefix()}
|
||||||
</text>
|
</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>
|
</box>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -60,6 +60,52 @@ describe("diff viewer file tree utilities", () => {
|
|||||||
expect(flattenFileTree(tree).map((row) => row.name)).toEqual(["alpha.ts", "beta.ts", "zeta.ts"])
|
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", () => {
|
test("keeps same directory names under different parents separate", () => {
|
||||||
const rows = flattenFileTree(
|
const rows = flattenFileTree(
|
||||||
buildFileTree([{ file: "components/button.ts" }, { file: "docs/components/usage.md" }]),
|
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([
|
expect(rows.map((row) => `${" ".repeat(row.depth)}${row.kind}:${row.name}`)).toEqual([
|
||||||
"directory:components",
|
"directory:components",
|
||||||
" file:button.ts",
|
" file:button.ts",
|
||||||
"directory:docs",
|
"directory:docs/components",
|
||||||
" directory:components",
|
" file:usage.md",
|
||||||
" 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" }]),
|
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(
|
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: "src", kind: "directory", depth: 0, fileIndex: undefined },
|
{ name: "keybind.ts", kind: "file", depth: 1, fileIndex: 1 },
|
||||||
{ name: "config", kind: "directory", depth: 1, fileIndex: undefined },
|
{ name: "tui.ts", kind: "file", depth: 1, fileIndex: 0 },
|
||||||
{ name: "keybind.ts", kind: "file", depth: 2, fileIndex: 1 },
|
{ name: "README.md", kind: "file", depth: 0, fileIndex: 2 },
|
||||||
{ name: "tui.ts", kind: "file", depth: 2, 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", () => {
|
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)
|
const collapsed = toggleFileTreeDirectory(tree, expanded, src.id)
|
||||||
expect(collapsed.has(src.id)).toBe(false)
|
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)
|
const reopened = toggleFileTreeDirectory(tree, collapsed, src.id)
|
||||||
expect(reopened.has(src.id)).toBe(true)
|
expect(reopened.has(src.id)).toBe(true)
|
||||||
|
|||||||
@@ -89,8 +89,8 @@ describe("DiffViewerFileTree", () => {
|
|||||||
await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />),
|
await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />),
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(focused).toContain("▾ src")
|
expect(focused).toContain("▾ src/config")
|
||||||
expect(unfocused).toContain("▾ src")
|
expect(unfocused).toContain("▾ src/config")
|
||||||
expect(focused.some((line) => line.includes("*"))).toBe(false)
|
expect(focused.some((line) => line.includes("*"))).toBe(false)
|
||||||
expect(unfocused.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} />
|
<DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
).toEqual(["▸ src", " README.md"])
|
).toEqual(["▸ src/config", " README.md"])
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
visibleLines(
|
visibleLines(
|
||||||
@@ -122,7 +122,7 @@ describe("DiffViewerFileTree", () => {
|
|||||||
/>
|
/>
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
).toEqual(["▾ src", " ▾ config", " tui.ts", " README.md"])
|
).toEqual(["▾ src/config", " tui.ts", " README.md"])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user