From ee008923f39f8bbacf9f29c954c65b58b05070be Mon Sep 17 00:00:00 2001 From: James Long Date: Thu, 21 May 2026 18:34:32 -0400 Subject: [PATCH] feat(tui): design revamp of diff viewer (#28728) --- .../system/diff-viewer-file-tree-utils.ts | 16 + .../system/diff-viewer-file-tree.tsx | 101 +++-- .../feature-plugins/system/diff-viewer-ui.tsx | 94 ++++ .../feature-plugins/system/diff-viewer.tsx | 420 ++++++++++-------- .../tui/diff-viewer-file-tree-utils.test.ts | 43 ++ .../cli/tui/diff-viewer-file-tree.test.tsx | 97 ++-- 6 files changed, 515 insertions(+), 256 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-ui.tsx 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 5ea7f189b3..28aeb0fb65 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 @@ -5,6 +5,7 @@ export type FileTreeItem = { readonly file: string + readonly status?: "added" | "deleted" | "modified" } export type FileTreeNode = { @@ -125,6 +126,21 @@ export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: nu return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id } +export function moveFileTreeSelectionToFirstChild(rows: readonly FileTreeRow[], selected: number | undefined) { + const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected) + const row = index === -1 ? undefined : rows[index] + if (row?.kind !== "directory") return selected + const child = rows[index + 1] + return child && child.depth > row.depth ? child.id : selected +} + +export function moveFileTreeSelectionToParent(rows: readonly FileTreeRow[], selected: number | undefined) { + const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected) + const row = index === -1 ? undefined : rows[index] + if (!row || row.depth === 0) return selected + return rows.findLast((item, itemIndex) => itemIndex < index && item.depth < row.depth)?.id ?? selected +} + export function moveFileTreeSelectionToFile( rows: readonly FileTreeRow[], selected: number | undefined, 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 e55ef7aa7b..2d4fbf6025 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,30 +1,36 @@ /** @jsxImportSource @opentui/solid */ -import type { ColorInput, ScrollBoxRenderable } from "@opentui/core" +import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core" import { Locale } from "@/util/locale" +import { tint } from "@tui/context/theme" 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, type FileTreeRow } from "./diff-viewer-file-tree-utils" +import { Panel } from "./diff-viewer-ui" -const FILE_TREE_WIDTH = 32 const FILE_TREE_HORIZONTAL_PADDING = 2 +const FILE_TREE_STATUS_WIDTH = 2 export type DiffViewerFileTreeTheme = { - readonly background: ColorInput + readonly background: RGBA readonly backgroundPanel: ColorInput readonly backgroundElement: ColorInput readonly primary: ColorInput + readonly secondary: ColorInput readonly selectedListItemText: ColorInput - readonly text: ColorInput - readonly textMuted: ColorInput + readonly text: RGBA + readonly textMuted: RGBA readonly error: ColorInput } export type DiffViewerFileTreeProps = { + readonly width: number readonly files: readonly FileTreeItem[] readonly loading: boolean readonly error: unknown readonly theme: DiffViewerFileTreeTheme readonly focused?: boolean readonly highlightedNode?: number + readonly selectedFileIndex?: number + readonly reviewedFileNames?: ReadonlySet readonly expandedNodes?: ReadonlySet } @@ -43,21 +49,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { requestAnimationFrame(scrollSelectedIntoView) }) + const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75) + return ( - + (scroll = element)} - flexGrow={1} - minHeight={0} verticalScrollbarOptions={{ visible: false }} horizontalScrollbarOptions={{ visible: false }} > @@ -70,26 +67,24 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { 0}> - {(row) => { + {(row, index) => { const highlighted = () => props.focused && props.highlightedNode === row.id - const prefix = () => - `${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}` + const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex + const reviewed = () => { + const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file + return file !== undefined && props.reviewedFileNames?.has(file) + } + const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes) + const status = () => fileTreeRowStatus(row, props.files) const name = () => Locale.truncate( row.name, - Math.max(1, FILE_TREE_WIDTH - FILE_TREE_HORIZONTAL_PADDING - prefix().length), + Math.max(1, props.width - FILE_TREE_HORIZONTAL_PADDING - prefix().length - status().length), ) return ( - + @@ -100,16 +95,22 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { fg={ highlighted() ? props.theme.background - : row.kind === "directory" + : reviewed() ? props.theme.textMuted - : props.theme.text + : selected() + ? props.theme.primary + : row.kind === "directory" + ? tint(props.theme.text, props.theme.background, 0.35) + : props.theme.text } - bg={highlighted() ? props.theme.primary : undefined} wrapMode="none" > {name()} + + {status()} + ) }} @@ -117,7 +118,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { - + ) } @@ -131,3 +132,33 @@ function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, inde scroll.scrollTo(index - scroll.viewport.height + 1) } } + +function fileTreeRowPrefix( + rows: readonly FileTreeRow[], + index: number, + row: FileTreeRow, + expandedNodes: ReadonlySet | undefined, +) { + const indentation = Array.from({ length: row.depth }, (_, depth) => { + if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " " + return hasLaterSibling(rows, index, depth) ? "│ " : " " + }).join("") + const topRoot = index === 0 && row.depth === 0 + const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ " + const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : "" + + return `${indentation}${branch}${marker}` +} + +function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) { + return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth +} + +function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[]) { + if (row.fileIndex === undefined) return "" + const status = files[row.fileIndex]?.status + if (status === "modified") return "M".padStart(FILE_TREE_STATUS_WIDTH) + if (status === "added") return "A".padStart(FILE_TREE_STATUS_WIDTH) + if (status === "deleted") return "D".padStart(FILE_TREE_STATUS_WIDTH) + return "?".padStart(FILE_TREE_STATUS_WIDTH) +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-ui.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-ui.tsx new file mode 100644 index 0000000000..f56e8aaead --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-ui.tsx @@ -0,0 +1,94 @@ +import type { BorderSides, ColorInput } from "@opentui/core" +import type { JSX } from "@opentui/solid" +import { useTheme } from "@tui/context/theme" +import { createContext, splitProps, useContext } from "solid-js" + +export type Axis = "x" | "y" +export type SeparatorEdge = "edge" | "edge-in" | "edge-out" +export type PanelBorder = "start" | "end" | "both" | "none" + +const PanelGroupContext = createContext<{ axis: Axis }>() + +function crossAxis(axis: Axis) { + return axis === "x" ? "y" : "x" +} + +function usePanelGroup() { + return useContext(PanelGroupContext) +} + +export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis }) { + const [local, boxProps] = splitProps(props, ["axis", "children"]) + return ( + + + {local.children} + + + ) +} + +export function Panel(props: Omit & { border?: PanelBorder }) { + const group = usePanelGroup() + const { theme } = useTheme() + const [local, boxProps] = splitProps(props, ["border"]) + const border = local.border ?? "start" + const borderProps = border === "none" + ? {} + : { + border: panelBorderSides(group?.axis ?? "y", border), + borderColor: theme.border, + } + + return ( + + ) +} + +function panelBorderSides(axis: Axis, border: Exclude): BorderSides[] { + if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"] + return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"] +} + +export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) { + const group = usePanelGroup() + const { theme } = useTheme() + const color = () => props.color ?? theme.border + const axis = () => props.axis ?? crossAxis(group?.axis ?? "y") + if (axis() === "y") { + if (!props.start && !props.end) return + return ( + + {props.start && {verticalEdge(props.start, "start")}} + + {props.end && {verticalEdge(props.end, "end")}} + + ) + } + if (!props.start && !props.end) return + return ( + + {props.start && {horizontalEdge(props.start, "start")}} + + {props.end && {horizontalEdge(props.end, "end")}} + + ) +} + +function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") { + if (edge === "edge") return side === "start" ? "├" : "┤" + if (edge === "edge-in") return "┴" + return "┬" +} + +function verticalEdge(edge: SeparatorEdge, side: "start" | "end") { + if (edge === "edge") return side === "start" ? "┬" : "┴" + if (edge === "edge-in") return "┤" + return "├" +} diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx index 8eb9d91955..1ea8836f01 100644 --- a/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx @@ -9,19 +9,24 @@ import { useTerminalDimensions } from "@opentui/solid" import path from "path" import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" import { DiffViewerFileTree } from "./diff-viewer-file-tree" +import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" import { DialogSelect } from "@tui/ui/dialog-select" import { allExpandedFileTreeDirectories, buildFileTree, flattenFileTree, moveFileTreeSelection, + moveFileTreeSelectionToFirstChild, moveFileTreeSelectionToFile, + moveFileTreeSelectionToParent, setFileTreeDirectoryExpanded, toggleFileTreeDirectory, } from "./diff-viewer-file-tree-utils" const ROUTE = "diff" const MIN_SPLIT_WIDTH = 100 +const FILE_TREE_WIDTH = 32 +const PLAIN_TEXT_FILETYPE = "opencode-plain-text" type DiffMode = "git" | "last-turn" type DiffViewerFocus = "patches" | "files" @@ -100,6 +105,8 @@ function DiffViewer(props: { api: TuiPluginApi }) { const [highlightedFileNode, setHighlightedFileNode] = createSignal() const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal() const [activePatchFileIndex, setActivePatchFileIndex] = createSignal() + const [selectedFileIndex, setSelectedFileIndex] = createSignal() + const [reviewedFileNames, setReviewedFileNames] = createSignal>(new Set()) const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) const focusRunner = (input: Record void>) => () => input[focus()]() const switchFocusShortcut = useCommandShortcut("diff.switch_focus") @@ -109,6 +116,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { const singlePatchShortcut = useCommandShortcut("diff.single_patch") const switchDiffShortcut = useCommandShortcut("diff.switch_diff") const toggleViewShortcut = useCommandShortcut("diff.toggle_view") + const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed") let scroll: ScrollBoxRenderable | undefined const patchNodeByFileIndex = new Map() const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal() @@ -118,6 +126,8 @@ function DiffViewer(props: { api: TuiPluginApi }) { setHighlightedFileNode(undefined) setLastHighlightedFileNode(undefined) setActivePatchFileIndex(undefined) + setSelectedFileIndex(undefined) + setReviewedFileNames(new Set()) }) const ensureHighlightedFileNode = () => { @@ -144,11 +154,12 @@ function DiffViewer(props: { api: TuiPluginApi }) { setActivePatchFileIndex(undefined) } - const scrollPatchNodeToTop = (patchNode: BoxRenderable) => { + const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => { if (!scroll) return - scroll.scrollBy(patchNode.y - scroll.viewport.y) + const offset = fileIndex === 0 ? 0 : 1 + scroll.scrollBy(patchNode.y - scroll.viewport.y + offset) requestAnimationFrame(() => { - if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y) + if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y + offset) }) } @@ -168,8 +179,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { const scrollToFileIndex = (fileIndex: number | undefined) => { if (fileIndex === undefined) return setActivePatchFileIndex(fileIndex) + setSelectedFileIndex(fileIndex) const patchNode = patchNodeByFileIndex.get(fileIndex) - if (patchNode) scrollPatchNodeToTop(patchNode) + if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex) } const jumpToFileIndex = (fileIndex: number | undefined) => { @@ -227,9 +239,9 @@ function DiffViewer(props: { api: TuiPluginApi }) { patchNodeByFileIndex.set(fileIndex, element) if (pendingPatchScrollFileIndex() !== fileIndex) return requestAnimationFrame(() => { - scrollPatchNodeToTop(element) + scrollPatchNodeToTop(element, fileIndex) requestAnimationFrame(() => { - scrollPatchNodeToTop(element) + scrollPatchNodeToTop(element, fileIndex) setPendingPatchScrollFileIndex(undefined) }) }) @@ -244,6 +256,21 @@ function DiffViewer(props: { api: TuiPluginApi }) { setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode())) } + const toggleSelectedFileReviewed = () => { + const fileIndex = + focus() === "files" + ? fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex + : (selectedFileIndex() ?? activePatchFileIndex() ?? currentPatchFileIndex()) + const file = fileIndex === undefined ? undefined : files()[fileIndex]?.file + if (!file) return + setReviewedFileNames((reviewed) => { + const next = new Set(reviewed) + if (next.has(file)) next.delete(file) + else next.add(file) + return next + }) + } + const commands = [ { name: "diff.close", @@ -326,6 +353,11 @@ function DiffViewer(props: { api: TuiPluginApi }) { category: "VCS", run: focusRunner({ files() { + const highlighted = highlightedFileNode() + if (highlighted !== undefined && expandedFileNodes().has(highlighted)) { + setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted)) + return + } setExpandedFileNodes((expanded) => setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true), ) @@ -339,6 +371,12 @@ function DiffViewer(props: { api: TuiPluginApi }) { category: "VCS", run: focusRunner({ files() { + const highlighted = highlightedFileNode() + const node = highlighted === undefined ? undefined : fileTree().nodes[highlighted] + if (node?.kind !== "directory" || !expandedFileNodes().has(node.id)) { + setHighlighted(moveFileTreeSelectionToParent(fileRows(), highlighted)) + return + } setExpandedFileNodes((expanded) => setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false), ) @@ -362,6 +400,14 @@ function DiffViewer(props: { api: TuiPluginApi }) { jumpRelativePatchFile(-1) }, }, + { + name: "diff.mark_reviewed", + title: "Toggle selected diff file reviewed", + category: "VCS", + run() { + toggleSelectedFileReviewed() + }, + }, { name: "diff.switch_focus", title: "Switch diff viewer focus", @@ -460,6 +506,7 @@ function DiffViewer(props: { api: TuiPluginApi }) { { key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" }, { key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" }, { key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" }, + { key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" }, ...props.api.tuiConfig.keybinds.gather( "diff", commands.map((command) => command.name), @@ -468,186 +515,193 @@ function DiffViewer(props: { api: TuiPluginApi }) { })) return ( - - - - Diff + + + + Diff {mode() === "last-turn" ? "last turn" : "working tree"} + + + {files().length} {files().length === 1 ? "file" : "files"} + + + + + + + + Loading diff... + + + + + + + + + + + + + + Failed to load diff + + + + + No diff to show + + + 0}> + (scroll = element)} + flexGrow={1} + minHeight={0} + verticalScrollbarOptions={{ visible: false }} + horizontalScrollbarOptions={{ visible: false }} + > + + {(entry, index) => { + const reviewed = () => reviewedFileNames().has(entry.file.file) + return ( + registerPatchNode(entry.fileIndex, element)}> + {index() !== 0 ? : null} + + {entry.file.file} + + + +{entry.file.additions} + + + -{entry.file.deletions} + + + + No patch available for this file.} + > + {(patch) => ( + + + + )} + + + ) + }} + + + + + + + + + - - - - - Loading diff... - - - - - - - - - - - - - Failed to load diff - - - - - No diff to show - - - 0}> - (scroll = element)} - flexGrow={1} - minHeight={0} - verticalScrollbarOptions={{ visible: false }} - horizontalScrollbarOptions={{ visible: false }} - > - - {(entry) => ( - registerPatchNode(entry.fileIndex, element)} - marginBottom={1} - backgroundColor={theme().backgroundPanel} - > - - {entry.file.file} - +{entry.file.additions} - -{entry.file.deletions} - - No patch available for this file.} - > - {(patch) => ( - - )} - - - )} - - - - - - - - - - - - {(shortcut) => ( - - {shortcut()} focus file tree - - )} - - - {(shortcut) => ( - - {shortcut()} next file - - )} - - - {(shortcut) => ( - - {shortcut()} previous file - - )} - - - {(shortcut) => ( - - {shortcut()}{" "} - {showFileTree() ? "hide file tree" : "show file tree"} - - )} - - - {(shortcut) => ( - - {shortcut()}{" "} - {singlePatch() ? "all patches" : "single patch"} - - )} - - - {(shortcut) => ( - - {shortcut()} switch diff - - )} - - - {(shortcut) => ( - - {shortcut()}{" "} - {view() === "split" ? "unified view" : "split view"} - - )} - - + + + {(shortcut) => ( + + {shortcut()} focus file tree + + )} + + + {(shortcut) => ( + + {shortcut()} next file + + )} + + + {(shortcut) => ( + + {shortcut()} previous file + + )} + + + {(shortcut) => ( + + {shortcut()}{" "} + {showFileTree() ? "hide file tree" : "show file tree"} + + )} + + + {(shortcut) => ( + + {shortcut()}{" "} + {singlePatch() ? "all patches" : "single patch"} + + )} + + + {(shortcut) => ( + + {shortcut()} switch diff + + )} + + + {(shortcut) => ( + + {shortcut()}{" "} + {view() === "split" ? "unified view" : "split view"} + + )} + + + {(shortcut) => ( + + {shortcut()} mark reviewed + + )} + + + ) } 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 2d11f672fe..945c28130e 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 @@ -4,7 +4,9 @@ import { buildFileTree, flattenFileTree, moveFileTreeSelection, + moveFileTreeSelectionToFirstChild, moveFileTreeSelectionToFile, + moveFileTreeSelectionToParent, setFileTreeDirectoryExpanded, toggleFileTreeDirectory, } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils" @@ -177,6 +179,47 @@ describe("diff viewer file tree utilities", () => { 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" }]), 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 689bc7fd9c..0edf2edf3e 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 @@ -3,6 +3,10 @@ import { describe, expect, test } from "bun:test" import { RGBA } from "@opentui/core" import { testRender } from "@opentui/solid" import type { JSX } from "solid-js" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" +import { KVProvider } from "../../../src/cli/cmd/tui/context/kv" +import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme" +import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config" import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree" import { allExpandedFileTreeDirectories, @@ -14,6 +18,7 @@ const theme = { backgroundPanel: RGBA.fromHex("#111111"), backgroundElement: RGBA.fromHex("#333333"), primary: RGBA.fromHex("#00ffff"), + secondary: RGBA.fromHex("#0088ff"), selectedListItemText: RGBA.fromHex("#ffffff"), text: RGBA.fromHex("#ffffff"), textMuted: RGBA.fromHex("#888888"), @@ -23,44 +28,50 @@ const theme = { describe("DiffViewerFileTree", () => { test("renders sorted hierarchical file rows", async () => { const app = await testRender( - () => ( - + () => withTheme( + () => ( + + ), ), { width: 40, height: 20 }, ) try { - await app.renderOnce() + await renderOnceSettled(app) const lines = visibleLines(app.captureCharFrame()) - expect(lines).toEqual(["▾ a", " alpha.ts", " zeta.ts", "▾ b", " alpha.ts", " file.ts", " z-file.ts"]) + expect(lines).toEqual([ + "▾ a", + "│ ├─ alpha.ts ?", + "│ └─ zeta.ts ?", + "├─ ▾ b", + "│ ├─ alpha.ts ?", + "│ └─ file.ts ?", + ]) } finally { app.renderer.destroy() } }) test("keeps loading and error quiet while rendering an empty settled state", async () => { - const loading = await renderFrame(() => ( - - )) + const loading = await renderFrame(() => ) const failed = await renderFrame(() => ( - - )) - const empty = await renderFrame(() => ( - + )) + const empty = await renderFrame(() => ) expect(loading).not.toContain("Loading diff...") expect(loading).not.toContain("No files") @@ -75,18 +86,11 @@ describe("DiffViewerFileTree", () => { const focused = visibleLines( await renderFrame(() => ( - + )), ) const unfocused = visibleLines( - await renderFrame(() => ), + await renderFrame(() => ), ) expect(focused).toContain("▾ src/config") @@ -105,16 +109,17 @@ describe("DiffViewerFileTree", () => { expect( visibleLines( await renderFrame(() => ( - + )), ), - ).toEqual(["▸ src/config", " README.md"]) + ).toEqual(["▸ src/config"]) expect( visibleLines( await renderFrame(() => ( { /> )), ), - ).toEqual(["▾ src/config", " tui.ts", " README.md"]) + ).toEqual(["▾ src/config", "│ └─ tui.ts ?"]) }) }) async function renderFrame(component: () => JSX.Element) { - const app = await testRender(component, { width: 40, height: 10 }) + const app = await testRender(() => withTheme(component), { width: 40, height: 10 }) try { - await app.renderOnce() + await renderOnceSettled(app) return app.captureCharFrame() } finally { app.renderer.destroy() } } +async function renderOnceSettled(app: Awaited>) { + await app.renderOnce() + await new Promise((resolve) => setTimeout(resolve, 25)) + await app.renderOnce() +} + +function withTheme(component: () => JSX.Element) { + return ( + + + {component()} + + + ) +} + 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)) + .filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line)) }