feat(tui): initial impl of diff viewer (#28476)

This commit is contained in:
James Long
2026-05-20 11:16:56 -04:00
committed by GitHub
parent 13006d6d7c
commit 17d66ee4fe
8 changed files with 1298 additions and 1 deletions

View File

@@ -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("<leader>e", "Open external editor"),
theme_list: keybind("<leader>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",

View File

@@ -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<string, number>()
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<number>): 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<number>, 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<number>,
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<FileTreeNode, "id" | "children">) {
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
}

View File

@@ -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<number>
}
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 (
<box
width={32}
flexShrink={0}
backgroundColor={props.theme.backgroundPanel}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
gap={1}
minHeight={0}
>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<Switch>
<Match when={props.loading || props.error}>
<text />
</Match>
<Match when={props.files.length === 0}>
<text fg={props.theme.text}>No files</text>
</Match>
<Match when={props.files.length > 0}>
<For each={rows()}>
{(row) => {
const highlighted = () => props.focused && props.highlightedNode === row.id
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>
<text
fg={highlighted() ? props.theme.background : row.kind === "directory" ? props.theme.textMuted : props.theme.text}
bg={highlighted() ? props.theme.primary : undefined}
wrapMode="none"
>
{row.name}
</text>
</box>
)
}}
</For>
</Match>
</Switch>
</scrollbox>
</box>
)
}
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)
}
}

View File

@@ -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<DiffViewerFocus>("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<ReadonlySet<number>>(new Set())
const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
const focusRunner = (input: Record<DiffViewerFocus, () => 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<number, BoxRenderable>()
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
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(() => (
<DialogSelect
title="Switch diff"
skipFilter={true}
renderFilter={false}
current={mode()}
options={switchDiffOptions().map((option) => ({
...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 (
<box
position="absolute"
zIndex={2500}
left={0}
top={0}
width={dimensions().width}
height={dimensions().height}
backgroundColor={theme().background}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
paddingBottom={1}
gap={1}
>
<box flexDirection="row" justifyContent="space-between" flexShrink={0}>
<box flexDirection="row" gap={1}>
<text fg={theme().text}>Diff</text>
<text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
</box>
</box>
<Switch>
<Match when={diff.loading}>
<box flexGrow={1} alignItems="center" justifyContent="center">
<text fg={theme().textMuted}>Loading diff...</text>
</box>
</Match>
<Match when={!diff.loading}>
<box flexDirection="row" flexGrow={1} minHeight={0} gap={1}>
<Show when={showFileTree()}>
<DiffViewerFileTree
files={files()}
loading={diff.loading}
error={diff.error}
theme={theme()}
focused={focus() === "files"}
highlightedNode={highlightedFileNode()}
expandedNodes={expandedFileNodes()}
/>
</Show>
<box
flexGrow={1}
minWidth={0}
backgroundColor={theme().background}
paddingLeft={0}
paddingRight={2}
gap={1}
>
<Switch>
<Match when={diff.error}>
<box paddingTop={1}>
<text fg={theme().error}>Failed to load diff</text>
</box>
</Match>
<Match when={files().length === 0}>
<box paddingTop={1}>
<text fg={theme().textMuted}>No diff to show</text>
</box>
</Match>
<Match when={files().length > 0}>
<scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }}
>
<For each={visiblePatchFiles()}>
{(entry) => (
<box
ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}
marginBottom={1}
backgroundColor={theme().backgroundPanel}
>
<box
flexDirection="row"
gap={2}
flexShrink={0}
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={1}
backgroundColor={theme().backgroundPanel}
>
<text fg={theme().text}>{entry.file.file}</text>
<text fg={theme().diffAdded}>+{entry.file.additions}</text>
<text fg={theme().diffRemoved}>-{entry.file.deletions}</text>
</box>
<Show
when={entry.file.patch}
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
>
{(patch) => (
<diff
diff={patch()}
view={view()}
filetype={filetype(entry.file.file)}
syntaxStyle={themeState.syntax()}
showLineNumbers={true}
width="100%"
wrapMode="word"
fg={theme().text}
addedBg={theme().diffAddedBg}
removedBg={theme().diffRemovedBg}
contextBg={theme().diffContextBg}
addedSignColor={theme().diffHighlightAdded}
removedSignColor={theme().diffHighlightRemoved}
lineNumberFg={theme().diffLineNumber}
lineNumberBg={theme().diffContextBg}
addedLineNumberBg={theme().diffAddedLineNumberBg}
removedLineNumberBg={theme().diffRemovedLineNumberBg}
/>
)}
</Show>
</box>
)}
</For>
</scrollbox>
</Match>
</Switch>
</box>
</box>
</Match>
</Switch>
<box flexDirection="row" gap={2} flexShrink={0}>
<Show when={switchFocusShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
</text>
)}
</Show>
<Show when={nextFileShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
</text>
)}
</Show>
<Show when={previousFileShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
</text>
)}
</Show>
<Show when={toggleFileTreeShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()}{" "}
<span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
</text>
)}
</Show>
<Show when={singlePatchShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()}{" "}
<span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
</text>
)}
</Show>
<Show when={switchDiffShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
</text>
)}
</Show>
<Show when={toggleViewShortcut()}>
{(shortcut) => (
<text fg={theme().text}>
{shortcut()}{" "}
<span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
</text>
)}
</Show>
</box>
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.route.register([
{
name: ROUTE,
render: () => <DiffViewer api={api} />,
},
])
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,
}

View File

@@ -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<TuiPluginModule, "id"> & {
enabled?: boolean
}
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalEventSystem">): InternalTuiPlugin[] {
export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "diffViewer" | "experimentalEventSystem">): InternalTuiPlugin[] {
return [
HomeFooter,
HomeTips,
@@ -32,6 +33,7 @@ export function internalTuiPlugins(flags: Pick<RuntimeFlags.Info, "experimentalE
Notifications,
PluginManager,
WhichKey,
...(flags.diffViewer ? [DiffViewer] : []),
...(flags.experimentalEventSystem ? [SessionV2Debug] : []),
]
}

View File

@@ -15,6 +15,7 @@ export class Service extends ConfigService.Service<Service>()("@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"),

View File

@@ -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)
})
})

View File

@@ -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(
() => (
<DiffViewerFileTree
files={[
{ file: "z-file.ts" },
{ file: "b/file.ts" },
{ file: "a/zeta.ts" },
{ file: "b/alpha.ts" },
{ file: "a/alpha.ts" },
]}
loading={false}
error={undefined}
theme={theme}
focused={true}
/>
),
{ 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(() => <DiffViewerFileTree files={[]} loading={true} error={undefined} theme={theme} />)
const failed = await renderFrame(() => (
<DiffViewerFileTree files={[]} loading={false} error={new Error("nope")} theme={theme} />
))
const empty = await renderFrame(() => <DiffViewerFileTree files={[]} loading={false} error={undefined} theme={theme} />)
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(() => (
<DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} focused highlightedNode={src.id} />
)),
)
const unfocused = visibleLines(
await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />),
)
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(() => (
<DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
)),
),
).toEqual(["▸ src", " README.md"])
expect(
visibleLines(
await renderFrame(() => (
<DiffViewerFileTree
files={files}
loading={false}
error={undefined}
theme={theme}
expandedNodes={allExpandedFileTreeDirectories(tree)}
/>
)),
),
).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))
}