diff --git a/packages/opencode/src/cli/cmd/tui/config/keybind.ts b/packages/opencode/src/cli/cmd/tui/config/keybind.ts index a375573828..9e87f67ac4 100644 --- a/packages/opencode/src/cli/cmd/tui/config/keybind.ts +++ b/packages/opencode/src/cli/cmd/tui/config/keybind.ts @@ -59,6 +59,17 @@ export const Definitions = { command_list: keybind("ctrl+p", "List available commands"), help_show: keybind("none", "Open help dialog"), docs_open: keybind("none", "Open documentation"), + diff_close: keybind("escape,q", "Close diff viewer"), + diff_toggle: keybind("enter,space", "Toggle diff viewer item"), + diff_expand: keybind("right", "Expand diff viewer item"), + diff_collapse: keybind("left", "Collapse diff viewer item"), + diff_switch_focus: keybind("tab", "Switch diff viewer focus"), + diff_next_file: keybind("n", "Jump to next diff file"), + diff_previous_file: keybind("p", "Jump to previous diff file"), + diff_toggle_file_tree: keybind("b", "Toggle diff viewer file tree"), + diff_single_patch: keybind("s", "Toggle single patch view"), + diff_switch_diff: keybind("d", "Switch diff viewer source"), + diff_toggle_view: keybind("v", "Toggle diff viewer split or unified view"), editor_open: keybind("e", "Open external editor"), theme_list: keybind("t", "List available themes"), @@ -245,6 +256,17 @@ export const CommandMap = { command_list: "command.palette.show", help_show: "help.show", docs_open: "docs.open", + diff_close: "diff.close", + diff_toggle: "diff.toggle", + diff_expand: "diff.expand", + diff_collapse: "diff.collapse", + diff_switch_focus: "diff.switch_focus", + diff_next_file: "diff.next_file", + diff_previous_file: "diff.previous_file", + diff_toggle_file_tree: "diff.toggle_file_tree", + diff_single_patch: "diff.single_patch", + diff_switch_diff: "diff.switch_diff", + diff_toggle_view: "diff.toggle_view", editor_open: "prompt.editor", theme_list: "theme.switch", theme_switch_mode: "theme.switch_mode", 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 new file mode 100644 index 0000000000..44e6a3fdd2 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils.ts @@ -0,0 +1,155 @@ +// Paths branch softly through the screen, +// A quiet tree of changed designs; +// Each leaf remembers what has been, +// And waits where careful light aligns. + +export type FileTreeItem = { + readonly file: string +} + +export type FileTreeNode = { + readonly id: number + readonly name: string + readonly parent: number | undefined + readonly children: number[] + readonly depth: number + readonly kind: "directory" | "file" + readonly fileIndex?: number +} + +export type FileTree = { + readonly roots: number[] + readonly nodes: FileTreeNode[] +} + +export type FileTreeRow = { + readonly id: number + readonly depth: number + readonly kind: "directory" | "file" + readonly name: string + readonly fileIndex?: number +} + +export function buildFileTree(files: readonly FileTreeItem[]): FileTree { + const roots: number[] = [] + const nodes: FileTreeNode[] = [] + const directoryByPath = new Map() + + files.forEach((file, fileIndex) => { + const segments = file.file.split("/").filter(Boolean) + if (segments.length === 0) return + + const parent = segments.slice(0, -1).reduce( + (state, segment) => { + const directoryPath = state.path ? `${state.path}/${segment}` : segment + const existing = directoryByPath.get(directoryPath) + if (existing !== undefined) return { id: existing, path: directoryPath, depth: state.depth + 1 } + + const id = addFileTreeNode(nodes, roots, { + name: segment, + parent: state.id, + depth: state.depth, + kind: "directory", + }) + directoryByPath.set(directoryPath, id) + return { id, path: directoryPath, depth: state.depth + 1 } + }, + { id: undefined as number | undefined, path: "", depth: 0 }, + ) + + addFileTreeNode(nodes, roots, { + name: segments[segments.length - 1]!, + parent: parent.id, + depth: parent.depth, + kind: "file", + fileIndex, + }) + }) + + const tree = { roots, nodes } + tree.roots.sort((left, right) => compareFileTreeNodes(tree, left, right)) + tree.nodes.forEach((node) => node.children.sort((left, right) => compareFileTreeNodes(tree, left, right))) + return tree +} + +export function flattenFileTree(tree: FileTree, expanded?: ReadonlySet): FileTreeRow[] { + const rows: FileTreeRow[] = [] + const visit = (id: number) => { + const node = tree.nodes[id]! + rows.push({ + id: node.id, + depth: node.depth, + kind: node.kind, + name: node.name, + fileIndex: node.fileIndex, + }) + if (node.kind === "directory" && (!expanded || expanded.has(node.id))) node.children.forEach(visit) + } + tree.roots.forEach(visit) + return rows +} + +export function compareFileTreeNodes(tree: FileTree, left: number, right: number) { + const leftNode = tree.nodes[left]! + const rightNode = tree.nodes[right]! + if (leftNode.kind !== rightNode.kind) return leftNode.kind === "directory" ? -1 : 1 + if (leftNode.name < rightNode.name) return -1 + if (leftNode.name > rightNode.name) return 1 + return left - right +} + +export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: number | undefined, offset: number) { + if (rows.length === 0) return undefined + const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected) + if (index === -1) return rows[0]!.id + return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id +} + +export function moveFileTreeSelectionToFile( + rows: readonly FileTreeRow[], + selected: number | undefined, + offset: number, +) { + const fileRows = rows.filter((row) => row.fileIndex !== undefined) + if (fileRows.length === 0) return undefined + const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected) + if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id + const next = + offset < 0 + ? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex) + : fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex) + return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id) +} + +export function allExpandedFileTreeDirectories(tree: FileTree) { + return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id)) +} + +export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet, selected: number | undefined) { + if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded + const next = new Set(expanded) + if (next.has(selected)) next.delete(selected) + else next.add(selected) + return next +} + +export function setFileTreeDirectoryExpanded( + tree: FileTree, + expanded: ReadonlySet, + selected: number | undefined, + value: boolean, +) { + if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded + const next = new Set(expanded) + if (value) next.add(selected) + else next.delete(selected) + return next +} + +function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit) { + const id = nodes.length + nodes.push({ ...input, id, children: [] }) + if (input.parent === undefined) roots.push(id) + else nodes[input.parent]!.children.push(id) + return id +} 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 new file mode 100644 index 0000000000..e84d506c16 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree.tsx @@ -0,0 +1,103 @@ +/** @jsxImportSource @opentui/solid */ +import type { ColorInput, ScrollBoxRenderable } from "@opentui/core" +import { createEffect, createMemo, For, Match, Switch } from "solid-js" +import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils" + +export type DiffViewerFileTreeTheme = { + readonly background: ColorInput + readonly backgroundPanel: ColorInput + readonly backgroundElement: ColorInput + readonly primary: ColorInput + readonly selectedListItemText: ColorInput + readonly text: ColorInput + readonly textMuted: ColorInput + readonly error: ColorInput +} + +export type DiffViewerFileTreeProps = { + readonly files: readonly FileTreeItem[] + readonly loading: boolean + readonly error: unknown + readonly theme: DiffViewerFileTreeTheme + readonly focused?: boolean + readonly highlightedNode?: number + readonly expandedNodes?: ReadonlySet +} + +export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { + const tree = createMemo(() => buildFileTree(props.files)) + const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes)) + let scroll: ScrollBoxRenderable | undefined + + createEffect(() => { + const node = props.highlightedNode + if (node === undefined) return + const selectedIndex = rows().findIndex((row) => row.id === node) + if (selectedIndex === -1) return + const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, selectedIndex) + scrollSelectedIntoView() + requestAnimationFrame(scrollSelectedIntoView) + }) + + return ( + + (scroll = element)} + flexGrow={1} + minHeight={0} + verticalScrollbarOptions={{ visible: false }} + horizontalScrollbarOptions={{ visible: false }} + > + + + + + + No files + + 0}> + + {(row) => { + const highlighted = () => props.focused && props.highlightedNode === row.id + return ( + + + {`${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}`} + + + {row.name} + + + ) + }} + + + + + + ) +} + +function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) { + if (!scroll) return + if (index < scroll.scrollTop) { + scroll.scrollTo(index) + return + } + if (index >= scroll.scrollTop + scroll.viewport.height) { + scroll.scrollTo(index - scroll.viewport.height + 1) + } +} 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 new file mode 100644 index 0000000000..b6dd872cee --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx @@ -0,0 +1,686 @@ +/** @jsxImportSource @opentui/solid */ +import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" +import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core" +import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { useBindings, useCommandShortcut } from "@tui/keymap" +import { useTheme } from "@tui/context/theme" +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 { DialogSelect } from "@tui/ui/dialog-select" +import { + allExpandedFileTreeDirectories, + buildFileTree, + flattenFileTree, + moveFileTreeSelection, + moveFileTreeSelectionToFile, + setFileTreeDirectoryExpanded, + toggleFileTreeDirectory, +} from "./diff-viewer-file-tree-utils" + +const ROUTE = "diff" +const MIN_SPLIT_WIDTH = 100 +type DiffMode = "git" | "last-turn" +type DiffViewerFocus = "patches" | "files" + +type DiffFile = { + readonly file: string + readonly patch?: string + readonly additions: number + readonly deletions: number + readonly status: "added" | "deleted" | "modified" +} + +const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] => + diffs.flatMap((item) => + item.file + ? [ + { + file: item.file, + patch: item.patch, + additions: item.additions, + deletions: item.deletions, + status: item.status ?? "modified", + } satisfies DiffFile, + ] + : [], + ) + +function filetype(input?: string) { + if (!input) return "none" + const language = LANGUAGE_EXTENSIONS[path.extname(input)] + if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" + return language +} + +function DiffViewer(props: { api: TuiPluginApi }) { + const dimensions = useTerminalDimensions() + const themeState = useTheme() + const theme = () => props.api.theme.current + const params = () => + ("params" in props.api.route.current ? props.api.route.current.params : undefined) as + | { mode?: DiffMode; sessionID?: string; messageID?: string } + | undefined + const mode = () => params()?.mode ?? "git" + const diffInput = createMemo(() => ({ + mode: mode(), + sessionID: params()?.sessionID, + messageID: params()?.messageID, + })) + const [diff] = createResource(diffInput, async (input) => { + if (input.mode === "last-turn") { + const sessionID = input.sessionID + if (!sessionID) return [] + const result = await props.api.client.session.diff( + { sessionID, messageID: input.messageID }, + { throwOnError: true }, + ) + return normalizeDiffs(result.data ?? []) + } + + const result = await props.api.client.vcs.diff({ mode: "git" }, { throwOnError: true }) + return normalizeDiffs(result.data ?? []) + }) + const files = createMemo(() => diff() ?? []) + const [focus, setFocus] = createSignal("patches") + const [showFileTree, setShowFileTree] = createSignal(true) + const [singlePatch, setSinglePatch] = createSignal(false) + const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4) + const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH) + const defaultView = createMemo(() => { + if (props.api.tuiConfig.diff_style === "stacked") return "unified" + return splitAvailable() ? "split" : "unified" + }) + const [viewOverride, setViewOverride] = createSignal<"split" | "unified">() + const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified")) + const fileTree = createMemo(() => buildFileTree(files())) + const [expandedFileNodes, setExpandedFileNodes] = createSignal>(new Set()) + const [highlightedFileNode, setHighlightedFileNode] = createSignal() + const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal() + const [activePatchFileIndex, setActivePatchFileIndex] = createSignal() + const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) + const focusRunner = (input: Record void>) => () => input[focus()]() + const switchFocusShortcut = useCommandShortcut("diff.switch_focus") + const nextFileShortcut = useCommandShortcut("diff.next_file") + const previousFileShortcut = useCommandShortcut("diff.previous_file") + const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree") + const singlePatchShortcut = useCommandShortcut("diff.single_patch") + const switchDiffShortcut = useCommandShortcut("diff.switch_diff") + const toggleViewShortcut = useCommandShortcut("diff.toggle_view") + let scroll: ScrollBoxRenderable | undefined + const patchNodeByFileIndex = new Map() + const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal() + + createEffect(() => { + setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) + setHighlightedFileNode(undefined) + setLastHighlightedFileNode(undefined) + setActivePatchFileIndex(undefined) + }) + + const ensureHighlightedFileNode = () => { + const highlighted = highlightedFileNode() + if (highlighted !== undefined && fileRows().some((row) => row.id === highlighted)) return + const lastHighlighted = lastHighlightedFileNode() + const next = + lastHighlighted !== undefined && fileRows().some((row) => row.id === lastHighlighted) + ? lastHighlighted + : fileRows()[0]?.id + setHighlightedFileNode(next) + } + + const setHighlighted = (node: number | undefined) => { + setHighlightedFileNode(node) + if (node !== undefined) setLastHighlightedFileNode(node) + } + + const moveFileSelection = (offset: number) => + setHighlighted(moveFileTreeSelection(fileRows(), highlightedFileNode(), offset)) + + const clearFileTreePatchState = () => { + setHighlightedFileNode(undefined) + setActivePatchFileIndex(undefined) + } + + const scrollPatchNodeToTop = (patchNode: BoxRenderable) => { + if (!scroll) return + scroll.scrollBy(patchNode.y - scroll.viewport.y) + requestAnimationFrame(() => { + if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y) + }) + } + + const revealFileTreeFile = (fileIndex: number) => { + const node = fileTree().nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex) + if (!node) return + setExpandedFileNodes((expanded) => { + const next = new Set(expanded) + for (let parent = node.parent; parent !== undefined; parent = fileTree().nodes[parent]?.parent) { + next.add(parent) + } + return next + }) + setHighlighted(node.id) + } + + const scrollToFileIndex = (fileIndex: number | undefined) => { + if (fileIndex === undefined) return + setActivePatchFileIndex(fileIndex) + const patchNode = patchNodeByFileIndex.get(fileIndex) + if (patchNode) scrollPatchNodeToTop(patchNode) + } + + const jumpToFileIndex = (fileIndex: number | undefined) => { + if (fileIndex === undefined) return + revealFileTreeFile(fileIndex) + scrollToFileIndex(fileIndex) + } + + const currentPatchFileIndex = () => { + if (!scroll) return undefined + const entries = files() + .map((_, fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) })) + .filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node)) + .sort((left, right) => left.node.y - right.node.y) + return entries.findLast((entry) => entry.node.y <= scroll!.viewport.y + 1)?.fileIndex ?? entries[0]?.fileIndex + } + + const jumpRelativePatchFile = (offset: number) => { + const current = focus() === "files" ? highlightedFileNode() : undefined + const nextFromSelection = + current === undefined ? undefined : moveFileTreeSelectionToFile(fileRows(), current, offset) + if (nextFromSelection !== undefined) { + jumpToFileIndex(fileRows().find((row) => row.id === nextFromSelection)?.fileIndex) + return + } + const currentFileIndex = activePatchFileIndex() ?? currentPatchFileIndex() + const currentRow = fileRows().find((row) => row.fileIndex === currentFileIndex) + scrollToFileIndex( + fileRows().find((row) => row.id === moveFileTreeSelectionToFile(fileRows(), currentRow?.id, offset))?.fileIndex, + ) + } + + const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex + const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex + const visiblePatchFiles = createMemo(() => { + if (!singlePatch()) return files().map((file, fileIndex) => ({ file, fileIndex })) + const fileIndex = activePatchFileIndex() ?? currentPatchFileIndex() ?? firstPatchFileIndex() + const file = fileIndex === undefined ? undefined : files()[fileIndex] + return file && fileIndex !== undefined ? [{ file, fileIndex }] : [] + }) + + const ensureHighlightedPatchFile = () => { + if (activePatchFileIndex() !== undefined) return + const fileIndex = currentPatchFileIndex() ?? firstPatchFileIndex() + if (fileIndex !== undefined) setActivePatchFileIndex(fileIndex) + } + + const scrollToHighlightedPatchFile = () => { + const fileIndex = activePatchFileIndex() + if (fileIndex === undefined) return + setPendingPatchScrollFileIndex(fileIndex) + } + + const registerPatchNode = (fileIndex: number, element: BoxRenderable) => { + patchNodeByFileIndex.set(fileIndex, element) + if (pendingPatchScrollFileIndex() !== fileIndex) return + requestAnimationFrame(() => { + scrollPatchNodeToTop(element) + requestAnimationFrame(() => { + scrollPatchNodeToTop(element) + setPendingPatchScrollFileIndex(undefined) + }) + }) + } + + const toggleSelectedFileTreeRow = () => { + const highlighted = fileRows().find((row) => row.id === highlightedFileNode()) + if (highlighted?.fileIndex !== undefined) { + jumpToFileIndex(highlighted.fileIndex) + return + } + setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode())) + } + + const commands = [ + { + name: "diff.close", + title: "Close diff viewer", + category: "VCS", + run() { + props.api.route.navigate("home") + }, + }, + { + name: "diff.down", + title: "Move diff viewer down", + category: "VCS", + run: focusRunner({ + files() { + moveFileSelection(1) + }, + patches() { + clearFileTreePatchState() + scroll?.scrollBy(1) + }, + }), + }, + { + name: "diff.up", + title: "Move diff viewer up", + category: "VCS", + run: focusRunner({ + files() { + moveFileSelection(-1) + }, + patches() { + clearFileTreePatchState() + scroll?.scrollBy(-1) + }, + }), + }, + { + name: "diff.page.down", + title: "Page diff viewer down", + category: "VCS", + run: focusRunner({ + files() { + moveFileSelection(8) + }, + patches() { + clearFileTreePatchState() + if (scroll) scroll.scrollBy(scroll.height) + }, + }), + }, + { + name: "diff.page.up", + title: "Page diff viewer up", + category: "VCS", + run: focusRunner({ + files() { + moveFileSelection(-8) + }, + patches() { + clearFileTreePatchState() + if (scroll) scroll.scrollBy(-scroll.height) + }, + }), + }, + { + name: "diff.toggle", + title: "Toggle diff viewer item", + category: "VCS", + run: focusRunner({ + files() { + toggleSelectedFileTreeRow() + }, + patches() {}, + }), + }, + { + name: "diff.expand", + title: "Expand diff viewer item", + category: "VCS", + run: focusRunner({ + files() { + setExpandedFileNodes((expanded) => + setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true), + ) + }, + patches() {}, + }), + }, + { + name: "diff.collapse", + title: "Collapse diff viewer item", + category: "VCS", + run: focusRunner({ + files() { + setExpandedFileNodes((expanded) => + setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false), + ) + }, + patches() {}, + }), + }, + { + name: "diff.next_file", + title: "Jump to next diff file", + category: "VCS", + run() { + jumpRelativePatchFile(1) + }, + }, + { + name: "diff.previous_file", + title: "Jump to previous diff file", + category: "VCS", + run() { + jumpRelativePatchFile(-1) + }, + }, + { + name: "diff.switch_focus", + title: "Switch diff viewer focus", + category: "VCS", + run() { + if (!showFileTree()) return + setFocus((current) => { + if (current === "files") return "patches" + ensureHighlightedFileNode() + return "files" + }) + }, + }, + { + name: "diff.toggle_file_tree", + title: "Toggle diff viewer file tree", + category: "VCS", + run() { + setShowFileTree((value) => { + if (value) setFocus("patches") + return !value + }) + }, + }, + { + name: "diff.single_patch", + title: "Toggle single patch view", + category: "VCS", + run() { + setSinglePatch((value) => { + const next = !value + if (next) ensureHighlightedPatchFile() + else scrollToHighlightedPatchFile() + return next + }) + }, + }, + { + name: "diff.switch_diff", + title: "Switch diff viewer source", + category: "VCS", + run() { + openSwitchDiffDialog() + }, + }, + { + name: "diff.toggle_view", + title: "Toggle diff viewer split or unified view", + category: "VCS", + run() { + if (!splitAvailable()) return + setViewOverride(view() === "split" ? "unified" : "split") + }, + }, + ] + + const switchDiffOptions = createMemo(() => [ + { + title: "Working tree", + value: "git" as const, + description: "Show current git changes", + }, + { + title: "Last turn", + value: "last-turn" as const, + description: "Show changes from the last assistant turn", + }, + ]) + + const openSwitchDiffDialog = () => { + props.api.ui.dialog.replace(() => ( + ({ + ...option, + onSelect(dialog) { + dialog.clear() + props.api.route.navigate(ROUTE, { + mode: option.value, + sessionID: params()?.sessionID, + messageID: params()?.messageID, + }) + }, + }))} + /> + )) + } + + useBindings(() => ({ + commands, + bindings: [ + { key: "j,down", cmd: "diff.down", desc: "Move diff viewer down" }, + { 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" }, + ...props.api.tuiConfig.keybinds.gather( + "diff", + commands.map((command) => command.name), + ), + ], + })) + + return ( + + + + Diff + {mode() === "last-turn" ? "last turn" : "working tree"} + + + + + + + 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"} + + )} + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.route.register([ + { + name: ROUTE, + render: () => , + }, + ]) + + api.keymap.registerLayer({ + commands: [ + { + name: "diff.open", + title: "Open diff viewer", + slashName: "diff", + category: "VCS", + namespace: "palette", + run() { + api.route.navigate(ROUTE, { + mode: "git", + sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined, + }) + api.ui.dialog.clear() + }, + }, + ], + }) +} + +export default { + id: "diff-viewer", + tui, +} diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 5e0aec3ba1..6d3727399b 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -10,6 +10,7 @@ import PluginManager from "../feature-plugins/system/plugins" import Notifications from "../feature-plugins/system/notifications" import SessionV2Debug from "../feature-plugins/system/session-v2" import WhichKey from "../feature-plugins/system/which-key" +import DiffViewer from "../feature-plugins/system/diff-viewer" import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { RuntimeFlags } from "@/effect/runtime-flags" @@ -19,7 +20,7 @@ export type InternalTuiPlugin = Omit & { enabled?: boolean } -export function internalTuiPlugins(flags: Pick): InternalTuiPlugin[] { +export function internalTuiPlugins(flags: Pick): InternalTuiPlugin[] { return [ HomeFooter, HomeTips, @@ -32,6 +33,7 @@ export function internalTuiPlugins(flags: Pick()("@opencode/Runtime autoShare: bool("OPENCODE_AUTO_SHARE"), pure: bool("OPENCODE_PURE"), disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"), + diffViewer: bool("OPENCODE_DIFF_VIEWER"), disableChannelDb: bool("OPENCODE_DISABLE_CHANNEL_DB"), disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"), disableExternalSkills: bool("OPENCODE_DISABLE_EXTERNAL_SKILLS"), 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 new file mode 100644 index 0000000000..cd42edba11 --- /dev/null +++ b/packages/opencode/test/cli/tui/diff-viewer-file-tree-utils.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test" +import { + allExpandedFileTreeDirectories, + buildFileTree, + flattenFileTree, + moveFileTreeSelection, + moveFileTreeSelectionToFile, + setFileTreeDirectoryExpanded, + toggleFileTreeDirectory, +} from "../../../src/cli/cmd/tui/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("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", + " directory: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", 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 }, + ]) + }) + + 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 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("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", "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) + }) +}) 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 new file mode 100644 index 0000000000..b1cc7685a8 --- /dev/null +++ b/packages/opencode/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -0,0 +1,143 @@ +/** @jsxImportSource @opentui/solid */ +import { describe, expect, test } from "bun:test" +import { RGBA } from "@opentui/core" +import { testRender } from "@opentui/solid" +import type { JSX } from "solid-js" +import { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree" +import { + allExpandedFileTreeDirectories, + buildFileTree, +} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils" + +const theme = { + background: RGBA.fromHex("#000000"), + backgroundPanel: RGBA.fromHex("#111111"), + backgroundElement: RGBA.fromHex("#333333"), + primary: RGBA.fromHex("#00ffff"), + selectedListItemText: RGBA.fromHex("#ffffff"), + text: RGBA.fromHex("#ffffff"), + textMuted: RGBA.fromHex("#888888"), + error: RGBA.fromHex("#ff0000"), +} + +describe("DiffViewerFileTree", () => { + test("renders sorted hierarchical file rows", async () => { + const app = await testRender( + () => ( + + ), + { width: 40, height: 20 }, + ) + + try { + await app.renderOnce() + const lines = visibleLines(app.captureCharFrame()) + + expect(lines).toEqual([ + "▾ a", + " alpha.ts", + " zeta.ts", + "▾ b", + " alpha.ts", + " file.ts", + " z-file.ts", + ]) + } finally { + app.renderer.destroy() + } + }) + + test("keeps loading and error quiet while rendering an empty settled state", async () => { + const loading = await renderFrame(() => ) + const failed = await renderFrame(() => ( + + )) + const empty = await renderFrame(() => ) + + expect(loading).not.toContain("Loading diff...") + expect(loading).not.toContain("No files") + expect(failed).not.toContain("Failed to load diff") + expect(failed).not.toContain("No files") + expect(empty).toContain("No files") + }) + + test("does not render text markers for highlighted rows", async () => { + const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }] + const src = buildFileTree(files).nodes.find((node) => node.kind === "directory" && node.name === "src")! + + const focused = visibleLines( + await renderFrame(() => ( + + )), + ) + const unfocused = visibleLines( + await renderFrame(() => ), + ) + + expect(focused).toContain("▾ src") + expect(unfocused).toContain("▾ src") + expect(focused.some((line) => line.includes("*"))).toBe(false) + expect(unfocused.some((line) => line.includes("*"))).toBe(false) + }) + + test("renders collapsed and expanded directory rows", async () => { + const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }] + const tree = buildFileTree(files) + const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")! + const collapsed = allExpandedFileTreeDirectories(tree) + collapsed.delete(src.id) + + expect( + visibleLines( + await renderFrame(() => ( + + )), + ), + ).toEqual(["▸ src", " README.md"]) + + expect( + visibleLines( + await renderFrame(() => ( + + )), + ), + ).toEqual(["▾ src", " ▾ config", " tui.ts", " README.md"]) + }) +}) + +async function renderFrame(component: () => JSX.Element) { + const app = await testRender(component, { width: 40, height: 10 }) + try { + await app.renderOnce() + return app.captureCharFrame() + } finally { + app.renderer.destroy() + } +} + +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)) +}