Skip to content

Commit 17d66ee

Browse files
authored
feat(tui): initial impl of diff viewer (anomalyco#28476)
1 parent 13006d6 commit 17d66ee

8 files changed

Lines changed: 1298 additions & 1 deletion

File tree

packages/opencode/src/cli/cmd/tui/config/keybind.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,17 @@ export const Definitions = {
5959
command_list: keybind("ctrl+p", "List available commands"),
6060
help_show: keybind("none", "Open help dialog"),
6161
docs_open: keybind("none", "Open documentation"),
62+
diff_close: keybind("escape,q", "Close diff viewer"),
63+
diff_toggle: keybind("enter,space", "Toggle diff viewer item"),
64+
diff_expand: keybind("right", "Expand diff viewer item"),
65+
diff_collapse: keybind("left", "Collapse diff viewer item"),
66+
diff_switch_focus: keybind("tab", "Switch diff viewer focus"),
67+
diff_next_file: keybind("n", "Jump to next diff file"),
68+
diff_previous_file: keybind("p", "Jump to previous diff file"),
69+
diff_toggle_file_tree: keybind("b", "Toggle diff viewer file tree"),
70+
diff_single_patch: keybind("s", "Toggle single patch view"),
71+
diff_switch_diff: keybind("d", "Switch diff viewer source"),
72+
diff_toggle_view: keybind("v", "Toggle diff viewer split or unified view"),
6273

6374
editor_open: keybind("<leader>e", "Open external editor"),
6475
theme_list: keybind("<leader>t", "List available themes"),
@@ -245,6 +256,17 @@ export const CommandMap = {
245256
command_list: "command.palette.show",
246257
help_show: "help.show",
247258
docs_open: "docs.open",
259+
diff_close: "diff.close",
260+
diff_toggle: "diff.toggle",
261+
diff_expand: "diff.expand",
262+
diff_collapse: "diff.collapse",
263+
diff_switch_focus: "diff.switch_focus",
264+
diff_next_file: "diff.next_file",
265+
diff_previous_file: "diff.previous_file",
266+
diff_toggle_file_tree: "diff.toggle_file_tree",
267+
diff_single_patch: "diff.single_patch",
268+
diff_switch_diff: "diff.switch_diff",
269+
diff_toggle_view: "diff.toggle_view",
248270
editor_open: "prompt.editor",
249271
theme_list: "theme.switch",
250272
theme_switch_mode: "theme.switch_mode",
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
// Paths branch softly through the screen,
2+
// A quiet tree of changed designs;
3+
// Each leaf remembers what has been,
4+
// And waits where careful light aligns.
5+
6+
export type FileTreeItem = {
7+
readonly file: string
8+
}
9+
10+
export type FileTreeNode = {
11+
readonly id: number
12+
readonly name: string
13+
readonly parent: number | undefined
14+
readonly children: number[]
15+
readonly depth: number
16+
readonly kind: "directory" | "file"
17+
readonly fileIndex?: number
18+
}
19+
20+
export type FileTree = {
21+
readonly roots: number[]
22+
readonly nodes: FileTreeNode[]
23+
}
24+
25+
export type FileTreeRow = {
26+
readonly id: number
27+
readonly depth: number
28+
readonly kind: "directory" | "file"
29+
readonly name: string
30+
readonly fileIndex?: number
31+
}
32+
33+
export function buildFileTree(files: readonly FileTreeItem[]): FileTree {
34+
const roots: number[] = []
35+
const nodes: FileTreeNode[] = []
36+
const directoryByPath = new Map<string, number>()
37+
38+
files.forEach((file, fileIndex) => {
39+
const segments = file.file.split("/").filter(Boolean)
40+
if (segments.length === 0) return
41+
42+
const parent = segments.slice(0, -1).reduce(
43+
(state, segment) => {
44+
const directoryPath = state.path ? `${state.path}/${segment}` : segment
45+
const existing = directoryByPath.get(directoryPath)
46+
if (existing !== undefined) return { id: existing, path: directoryPath, depth: state.depth + 1 }
47+
48+
const id = addFileTreeNode(nodes, roots, {
49+
name: segment,
50+
parent: state.id,
51+
depth: state.depth,
52+
kind: "directory",
53+
})
54+
directoryByPath.set(directoryPath, id)
55+
return { id, path: directoryPath, depth: state.depth + 1 }
56+
},
57+
{ id: undefined as number | undefined, path: "", depth: 0 },
58+
)
59+
60+
addFileTreeNode(nodes, roots, {
61+
name: segments[segments.length - 1]!,
62+
parent: parent.id,
63+
depth: parent.depth,
64+
kind: "file",
65+
fileIndex,
66+
})
67+
})
68+
69+
const tree = { roots, nodes }
70+
tree.roots.sort((left, right) => compareFileTreeNodes(tree, left, right))
71+
tree.nodes.forEach((node) => node.children.sort((left, right) => compareFileTreeNodes(tree, left, right)))
72+
return tree
73+
}
74+
75+
export function flattenFileTree(tree: FileTree, expanded?: ReadonlySet<number>): FileTreeRow[] {
76+
const rows: FileTreeRow[] = []
77+
const visit = (id: number) => {
78+
const node = tree.nodes[id]!
79+
rows.push({
80+
id: node.id,
81+
depth: node.depth,
82+
kind: node.kind,
83+
name: node.name,
84+
fileIndex: node.fileIndex,
85+
})
86+
if (node.kind === "directory" && (!expanded || expanded.has(node.id))) node.children.forEach(visit)
87+
}
88+
tree.roots.forEach(visit)
89+
return rows
90+
}
91+
92+
export function compareFileTreeNodes(tree: FileTree, left: number, right: number) {
93+
const leftNode = tree.nodes[left]!
94+
const rightNode = tree.nodes[right]!
95+
if (leftNode.kind !== rightNode.kind) return leftNode.kind === "directory" ? -1 : 1
96+
if (leftNode.name < rightNode.name) return -1
97+
if (leftNode.name > rightNode.name) return 1
98+
return left - right
99+
}
100+
101+
export function moveFileTreeSelection(rows: readonly FileTreeRow[], selected: number | undefined, offset: number) {
102+
if (rows.length === 0) return undefined
103+
const index = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
104+
if (index === -1) return rows[0]!.id
105+
return rows[Math.max(0, Math.min(rows.length - 1, index + offset))]!.id
106+
}
107+
108+
export function moveFileTreeSelectionToFile(
109+
rows: readonly FileTreeRow[],
110+
selected: number | undefined,
111+
offset: number,
112+
) {
113+
const fileRows = rows.filter((row) => row.fileIndex !== undefined)
114+
if (fileRows.length === 0) return undefined
115+
const selectedIndex = selected === undefined ? -1 : rows.findIndex((row) => row.id === selected)
116+
if (selectedIndex === -1) return offset < 0 ? fileRows[fileRows.length - 1]!.id : fileRows[0]!.id
117+
const next =
118+
offset < 0
119+
? fileRows.findLast((row) => rows.findIndex((item) => item.id === row.id) < selectedIndex)
120+
: fileRows.find((row) => rows.findIndex((item) => item.id === row.id) > selectedIndex)
121+
return next?.id ?? (offset < 0 ? fileRows[0]!.id : fileRows[fileRows.length - 1]!.id)
122+
}
123+
124+
export function allExpandedFileTreeDirectories(tree: FileTree) {
125+
return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id))
126+
}
127+
128+
export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet<number>, selected: number | undefined) {
129+
if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
130+
const next = new Set(expanded)
131+
if (next.has(selected)) next.delete(selected)
132+
else next.add(selected)
133+
return next
134+
}
135+
136+
export function setFileTreeDirectoryExpanded(
137+
tree: FileTree,
138+
expanded: ReadonlySet<number>,
139+
selected: number | undefined,
140+
value: boolean,
141+
) {
142+
if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded
143+
const next = new Set(expanded)
144+
if (value) next.add(selected)
145+
else next.delete(selected)
146+
return next
147+
}
148+
149+
function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit<FileTreeNode, "id" | "children">) {
150+
const id = nodes.length
151+
nodes.push({ ...input, id, children: [] })
152+
if (input.parent === undefined) roots.push(id)
153+
else nodes[input.parent]!.children.push(id)
154+
return id
155+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/** @jsxImportSource @opentui/solid */
2+
import type { ColorInput, ScrollBoxRenderable } from "@opentui/core"
3+
import { createEffect, createMemo, For, Match, Switch } from "solid-js"
4+
import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils"
5+
6+
export type DiffViewerFileTreeTheme = {
7+
readonly background: ColorInput
8+
readonly backgroundPanel: ColorInput
9+
readonly backgroundElement: ColorInput
10+
readonly primary: ColorInput
11+
readonly selectedListItemText: ColorInput
12+
readonly text: ColorInput
13+
readonly textMuted: ColorInput
14+
readonly error: ColorInput
15+
}
16+
17+
export type DiffViewerFileTreeProps = {
18+
readonly files: readonly FileTreeItem[]
19+
readonly loading: boolean
20+
readonly error: unknown
21+
readonly theme: DiffViewerFileTreeTheme
22+
readonly focused?: boolean
23+
readonly highlightedNode?: number
24+
readonly expandedNodes?: ReadonlySet<number>
25+
}
26+
27+
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
28+
const tree = createMemo(() => buildFileTree(props.files))
29+
const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes))
30+
let scroll: ScrollBoxRenderable | undefined
31+
32+
createEffect(() => {
33+
const node = props.highlightedNode
34+
if (node === undefined) return
35+
const selectedIndex = rows().findIndex((row) => row.id === node)
36+
if (selectedIndex === -1) return
37+
const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, selectedIndex)
38+
scrollSelectedIntoView()
39+
requestAnimationFrame(scrollSelectedIntoView)
40+
})
41+
42+
return (
43+
<box
44+
width={32}
45+
flexShrink={0}
46+
backgroundColor={props.theme.backgroundPanel}
47+
paddingLeft={1}
48+
paddingRight={1}
49+
paddingTop={1}
50+
gap={1}
51+
minHeight={0}
52+
>
53+
<scrollbox
54+
ref={(element: ScrollBoxRenderable) => (scroll = element)}
55+
flexGrow={1}
56+
minHeight={0}
57+
verticalScrollbarOptions={{ visible: false }}
58+
horizontalScrollbarOptions={{ visible: false }}
59+
>
60+
<Switch>
61+
<Match when={props.loading || props.error}>
62+
<text />
63+
</Match>
64+
<Match when={props.files.length === 0}>
65+
<text fg={props.theme.text}>No files</text>
66+
</Match>
67+
<Match when={props.files.length > 0}>
68+
<For each={rows()}>
69+
{(row) => {
70+
const highlighted = () => props.focused && props.highlightedNode === row.id
71+
return (
72+
<box flexDirection="row">
73+
<text fg={row.kind === "directory" ? props.theme.textMuted : props.theme.text} wrapMode="none">
74+
{`${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}`}
75+
</text>
76+
<text
77+
fg={highlighted() ? props.theme.background : row.kind === "directory" ? props.theme.textMuted : props.theme.text}
78+
bg={highlighted() ? props.theme.primary : undefined}
79+
wrapMode="none"
80+
>
81+
{row.name}
82+
</text>
83+
</box>
84+
)
85+
}}
86+
</For>
87+
</Match>
88+
</Switch>
89+
</scrollbox>
90+
</box>
91+
)
92+
}
93+
94+
function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) {
95+
if (!scroll) return
96+
if (index < scroll.scrollTop) {
97+
scroll.scrollTo(index)
98+
return
99+
}
100+
if (index >= scroll.scrollTop + scroll.viewport.height) {
101+
scroll.scrollTo(index - scroll.viewport.height + 1)
102+
}
103+
}

0 commit comments

Comments
 (0)