feat(tui): design revamp of diff viewer (#28728)

This commit is contained in:
James Long 2026-05-21 18:34:32 -04:00 committed by GitHub
commit ee008923f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 516 additions and 257 deletions

View file

@ -5,6 +5,7 @@
export type FileTreeItem = { export type FileTreeItem = {
readonly file: string readonly file: string
readonly status?: "added" | "deleted" | "modified"
} }
export type FileTreeNode = { 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 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( export function moveFileTreeSelectionToFile(
rows: readonly FileTreeRow[], rows: readonly FileTreeRow[],
selected: number | undefined, selected: number | undefined,

View file

@ -1,30 +1,36 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import type { ColorInput, ScrollBoxRenderable } from "@opentui/core" import type { ColorInput, RGBA, ScrollBoxRenderable } from "@opentui/core"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { tint } from "@tui/context/theme"
import { createEffect, createMemo, For, Match, Switch } from "solid-js" import { createEffect, createMemo, For, Match, Switch } from "solid-js"
import { buildFileTree, flattenFileTree, type FileTreeItem } from "./diff-viewer-file-tree-utils" import { buildFileTree, flattenFileTree, type FileTreeItem, 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_HORIZONTAL_PADDING = 2
const FILE_TREE_STATUS_WIDTH = 2
export type DiffViewerFileTreeTheme = { export type DiffViewerFileTreeTheme = {
readonly background: ColorInput readonly background: RGBA
readonly backgroundPanel: ColorInput readonly backgroundPanel: ColorInput
readonly backgroundElement: ColorInput readonly backgroundElement: ColorInput
readonly primary: ColorInput readonly primary: ColorInput
readonly secondary: ColorInput
readonly selectedListItemText: ColorInput readonly selectedListItemText: ColorInput
readonly text: ColorInput readonly text: RGBA
readonly textMuted: ColorInput readonly textMuted: RGBA
readonly error: ColorInput readonly error: ColorInput
} }
export type DiffViewerFileTreeProps = { export type DiffViewerFileTreeProps = {
readonly width: number
readonly files: readonly FileTreeItem[] readonly files: readonly FileTreeItem[]
readonly loading: boolean readonly loading: boolean
readonly error: unknown readonly error: unknown
readonly theme: DiffViewerFileTreeTheme readonly theme: DiffViewerFileTreeTheme
readonly focused?: boolean readonly focused?: boolean
readonly highlightedNode?: number readonly highlightedNode?: number
readonly selectedFileIndex?: number
readonly reviewedFileNames?: ReadonlySet<string>
readonly expandedNodes?: ReadonlySet<number> readonly expandedNodes?: ReadonlySet<number>
} }
@ -43,21 +49,12 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
requestAnimationFrame(scrollSelectedIntoView) requestAnimationFrame(scrollSelectedIntoView)
}) })
const fadedColor = () => tint(props.theme.text, props.theme.background, 0.75)
return ( return (
<box <Panel border="both" width={props.width}>
width={FILE_TREE_WIDTH}
flexShrink={0}
backgroundColor={props.theme.backgroundPanel}
paddingLeft={1}
paddingRight={1}
paddingTop={1}
gap={1}
minHeight={0}
>
<scrollbox <scrollbox
ref={(element: ScrollBoxRenderable) => (scroll = element)} ref={(element: ScrollBoxRenderable) => (scroll = element)}
flexGrow={1}
minHeight={0}
verticalScrollbarOptions={{ visible: false }} verticalScrollbarOptions={{ visible: false }}
horizontalScrollbarOptions={{ visible: false }} horizontalScrollbarOptions={{ visible: false }}
> >
@ -70,26 +67,24 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</Match> </Match>
<Match when={props.files.length > 0}> <Match when={props.files.length > 0}>
<For each={rows()}> <For each={rows()}>
{(row) => { {(row, index) => {
const highlighted = () => props.focused && props.highlightedNode === row.id const highlighted = () => props.focused && props.highlightedNode === row.id
const prefix = () => const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex
`${" ".repeat(row.depth)}${row.kind === "directory" ? (props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ ") : " "}` 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 = () => const name = () =>
Locale.truncate( Locale.truncate(
row.name, 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 ( return (
<box flexDirection="row" width="100%"> <box flexDirection="row" width="100%" backgroundColor={highlighted() ? props.theme.primary : undefined}>
<text <text
fg={ fg={highlighted() ? props.theme.background : fadedColor()}
highlighted()
? props.theme.background
: row.kind === "directory"
? props.theme.textMuted
: props.theme.text
}
bg={highlighted() ? props.theme.primary : undefined}
wrapMode="none" wrapMode="none"
flexShrink={0} flexShrink={0}
> >
@ -100,16 +95,22 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
fg={ fg={
highlighted() highlighted()
? props.theme.background ? props.theme.background
: row.kind === "directory" : reviewed()
? props.theme.textMuted ? 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" wrapMode="none"
> >
{name()} {name()}
</text> </text>
</box> </box>
<text fg={highlighted() ? props.theme.background : props.theme.textMuted} wrapMode="none" flexShrink={0}>
{status()}
</text>
</box> </box>
) )
}} }}
@ -117,7 +118,7 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
</Match> </Match>
</Switch> </Switch>
</scrollbox> </scrollbox>
</box> </Panel>
) )
} }
@ -131,3 +132,33 @@ function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, inde
scroll.scrollTo(index - scroll.viewport.height + 1) scroll.scrollTo(index - scroll.viewport.height + 1)
} }
} }
function fileTreeRowPrefix(
rows: readonly FileTreeRow[],
index: number,
row: FileTreeRow,
expandedNodes: ReadonlySet<number> | 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)
}

View file

@ -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 (
<PanelGroupContext.Provider value={{ axis: local.axis }}>
<box minWidth={0} minHeight={0} padding={0} flexDirection={local.axis === "x" ? "row" : "column"} {...boxProps}>
{local.children}
</box>
</PanelGroupContext.Provider>
)
}
export function Panel(props: Omit<JSX.IntrinsicElements["box"], "border"> & { 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 (
<box
minWidth={0}
minHeight={0}
flexDirection={crossAxis(group?.axis || "y") === "x" ? "row" : "column"}
{...borderProps}
{...boxProps}
/>
)
}
function panelBorderSides(axis: Axis, border: Exclude<PanelBorder, "none">): 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 <box width={1} flexShrink={0} border={["left"]} borderColor={color()} />
return (
<box width={1} flexShrink={0} flexDirection="column">
{props.start && <text fg={color()}>{verticalEdge(props.start, "start")}</text>}
<box flexGrow={1} border={["left"]} borderColor={color()} />
{props.end && <text fg={color()}>{verticalEdge(props.end, "end")}</text>}
</box>
)
}
if (!props.start && !props.end) return <box height={1} flexShrink={0} border={["top"]} borderColor={color()} />
return (
<box height={1} flexShrink={0} flexDirection="row">
{props.start && <text fg={color()}>{horizontalEdge(props.start, "start")}</text>}
<box flexGrow={1} border={["top"]} borderColor={color()} />
{props.end && <text fg={color()}>{horizontalEdge(props.end, "end")}</text>}
</box>
)
}
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 "├"
}

View file

@ -9,19 +9,24 @@ import { useTerminalDimensions } from "@opentui/solid"
import path from "path" import path from "path"
import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree" import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { DialogSelect } from "@tui/ui/dialog-select" import { DialogSelect } from "@tui/ui/dialog-select"
import { import {
allExpandedFileTreeDirectories, allExpandedFileTreeDirectories,
buildFileTree, buildFileTree,
flattenFileTree, flattenFileTree,
moveFileTreeSelection, moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToFile, moveFileTreeSelectionToFile,
moveFileTreeSelectionToParent,
setFileTreeDirectoryExpanded, setFileTreeDirectoryExpanded,
toggleFileTreeDirectory, toggleFileTreeDirectory,
} from "./diff-viewer-file-tree-utils" } from "./diff-viewer-file-tree-utils"
const ROUTE = "diff" const ROUTE = "diff"
const MIN_SPLIT_WIDTH = 100 const MIN_SPLIT_WIDTH = 100
const FILE_TREE_WIDTH = 32
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
type DiffMode = "git" | "last-turn" type DiffMode = "git" | "last-turn"
type DiffViewerFocus = "patches" | "files" type DiffViewerFocus = "patches" | "files"
@ -100,6 +105,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>() const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>() const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>() const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]() const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
const switchFocusShortcut = useCommandShortcut("diff.switch_focus") const switchFocusShortcut = useCommandShortcut("diff.switch_focus")
@ -109,6 +116,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const singlePatchShortcut = useCommandShortcut("diff.single_patch") const singlePatchShortcut = useCommandShortcut("diff.single_patch")
const switchDiffShortcut = useCommandShortcut("diff.switch_diff") const switchDiffShortcut = useCommandShortcut("diff.switch_diff")
const toggleViewShortcut = useCommandShortcut("diff.toggle_view") const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
let scroll: ScrollBoxRenderable | undefined let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>() const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>() const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
@ -118,6 +126,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
setHighlightedFileNode(undefined) setHighlightedFileNode(undefined)
setLastHighlightedFileNode(undefined) setLastHighlightedFileNode(undefined)
setActivePatchFileIndex(undefined) setActivePatchFileIndex(undefined)
setSelectedFileIndex(undefined)
setReviewedFileNames(new Set<string>())
}) })
const ensureHighlightedFileNode = () => { const ensureHighlightedFileNode = () => {
@ -144,11 +154,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
setActivePatchFileIndex(undefined) setActivePatchFileIndex(undefined)
} }
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => { const scrollPatchNodeToTop = (patchNode: BoxRenderable, fileIndex: number) => {
if (!scroll) return 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(() => { 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) => { const scrollToFileIndex = (fileIndex: number | undefined) => {
if (fileIndex === undefined) return if (fileIndex === undefined) return
setActivePatchFileIndex(fileIndex) setActivePatchFileIndex(fileIndex)
setSelectedFileIndex(fileIndex)
const patchNode = patchNodeByFileIndex.get(fileIndex) const patchNode = patchNodeByFileIndex.get(fileIndex)
if (patchNode) scrollPatchNodeToTop(patchNode) if (patchNode) scrollPatchNodeToTop(patchNode, fileIndex)
} }
const jumpToFileIndex = (fileIndex: number | undefined) => { const jumpToFileIndex = (fileIndex: number | undefined) => {
@ -227,9 +239,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
patchNodeByFileIndex.set(fileIndex, element) patchNodeByFileIndex.set(fileIndex, element)
if (pendingPatchScrollFileIndex() !== fileIndex) return if (pendingPatchScrollFileIndex() !== fileIndex) return
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollPatchNodeToTop(element) scrollPatchNodeToTop(element, fileIndex)
requestAnimationFrame(() => { requestAnimationFrame(() => {
scrollPatchNodeToTop(element) scrollPatchNodeToTop(element, fileIndex)
setPendingPatchScrollFileIndex(undefined) setPendingPatchScrollFileIndex(undefined)
}) })
}) })
@ -244,6 +256,21 @@ function DiffViewer(props: { api: TuiPluginApi }) {
setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode())) 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 = [ const commands = [
{ {
name: "diff.close", name: "diff.close",
@ -326,6 +353,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
category: "VCS", category: "VCS",
run: focusRunner({ run: focusRunner({
files() { files() {
const highlighted = highlightedFileNode()
if (highlighted !== undefined && expandedFileNodes().has(highlighted)) {
setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted))
return
}
setExpandedFileNodes((expanded) => setExpandedFileNodes((expanded) =>
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true), setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true),
) )
@ -339,6 +371,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
category: "VCS", category: "VCS",
run: focusRunner({ run: focusRunner({
files() { 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) => setExpandedFileNodes((expanded) =>
setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false), setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false),
) )
@ -362,6 +400,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
jumpRelativePatchFile(-1) jumpRelativePatchFile(-1)
}, },
}, },
{
name: "diff.mark_reviewed",
title: "Toggle selected diff file reviewed",
category: "VCS",
run() {
toggleSelectedFileReviewed()
},
},
{ {
name: "diff.switch_focus", name: "diff.switch_focus",
title: "Switch diff viewer 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: "k,up", cmd: "diff.up", desc: "Move diff viewer up" },
{ key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" }, { 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: "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( ...props.api.tuiConfig.keybinds.gather(
"diff", "diff",
commands.map((command) => command.name), commands.map((command) => command.name),
@ -468,186 +515,193 @@ function DiffViewer(props: { api: TuiPluginApi }) {
})) }))
return ( return (
<box <box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
position="absolute" <PanelGroup axis="y" width="100%" height="100%">
zIndex={2500} <Panel border="none" flexShrink={0} padding={0} paddingLeft={1}>
left={0} <text fg={theme().text}>Diff </text>
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> <text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
<box flexGrow={1} />
<text fg={theme().textMuted}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
</Panel>
<box flexGrow={1} minHeight={0}>
<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}>
<PanelGroup axis="x">
<Show when={showFileTree()}>
<DiffViewerFileTree
files={files()}
loading={diff.loading}
error={diff.error}
theme={theme()}
focused={focus() === "files"}
width={FILE_TREE_WIDTH}
highlightedNode={highlightedFileNode()}
selectedFileIndex={selectedFileIndex()}
reviewedFileNames={reviewedFileNames()}
expandedNodes={expandedFileNodes()}
/>
</Show>
<Panel flexGrow={1} minHeight={0} border="none">
<Separator axis="x" start="edge-out" />
<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, index) => {
const reviewed = () => reviewedFileNames().has(entry.file.file)
return (
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
{index() !== 0 ? <Separator axis="x" start="edge" /> : null}
<box
flexDirection="row"
gap={1}
flexShrink={0}
paddingLeft={2}
paddingRight={1}
border={["left"]}
borderColor={theme().border}
>
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
<box flexGrow={1} />
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
+{entry.file.additions}
</text>
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
-{entry.file.deletions}
</text>
</box>
<Separator axis="x" start="edge" />
<Show
when={entry.file.patch}
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
>
{(patch) => (
<box border={["left"]} borderColor={theme().border}>
<diff
diff={patch()}
view={view()}
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
syntaxStyle={themeState.syntax()}
showLineNumbers={true}
width="100%"
wrapMode="char"
fg={reviewed() ? theme().textMuted : theme().text}
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
lineNumberFg={theme().diffLineNumber}
addedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
}
removedLineNumberBg={
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
}
/>
</box>
)}
</Show>
</box>
)
}}
</For>
</scrollbox>
</Match>
</Switch>
<Separator axis="x" start="edge-in" />
</Panel>
</PanelGroup>
</Match>
</Switch>
</box> </box>
</box>
<Switch> <Panel flexShrink={0} gap={2} paddingLeft={1} border="none">
<Match when={diff.loading}> <Show when={switchFocusShortcut()}>
<box flexGrow={1} alignItems="center" justifyContent="center"> {(shortcut) => (
<text fg={theme().textMuted}>Loading diff...</text> <text fg={theme().text}>
</box> {shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
</Match> </text>
<Match when={!diff.loading}> )}
<box flexDirection="row" flexGrow={1} minHeight={0} gap={1}> </Show>
<Show when={showFileTree()}> <Show when={nextFileShortcut()}>
<DiffViewerFileTree {(shortcut) => (
files={files()} <text fg={theme().text}>
loading={diff.loading} {shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
error={diff.error} </text>
theme={theme()} )}
focused={focus() === "files"} </Show>
highlightedNode={highlightedFileNode()} <Show when={previousFileShortcut()}>
expandedNodes={expandedFileNodes()} {(shortcut) => (
/> <text fg={theme().text}>
</Show> {shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
</text>
<box )}
flexGrow={1} </Show>
minWidth={0} <Show when={toggleFileTreeShortcut()}>
backgroundColor={theme().background} {(shortcut) => (
paddingLeft={0} <text fg={theme().text}>
paddingRight={2} {shortcut()}{" "}
gap={1} <span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
> </text>
<Switch> )}
<Match when={diff.error}> </Show>
<box paddingTop={1}> <Show when={singlePatchShortcut()}>
<text fg={theme().error}>Failed to load diff</text> {(shortcut) => (
</box> <text fg={theme().text}>
</Match> {shortcut()}{" "}
<Match when={files().length === 0}> <span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
<box paddingTop={1}> </text>
<text fg={theme().textMuted}>No diff to show</text> )}
</box> </Show>
</Match> <Show when={switchDiffShortcut()}>
<Match when={files().length > 0}> {(shortcut) => (
<scrollbox <text fg={theme().text}>
ref={(element: ScrollBoxRenderable) => (scroll = element)} {shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
flexGrow={1} </text>
minHeight={0} )}
verticalScrollbarOptions={{ visible: false }} </Show>
horizontalScrollbarOptions={{ visible: false }} <Show when={toggleViewShortcut()}>
> {(shortcut) => (
<For each={visiblePatchFiles()}> <text fg={theme().text}>
{(entry) => ( {shortcut()}{" "}
<box <span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)} </text>
marginBottom={1} )}
backgroundColor={theme().backgroundPanel} </Show>
> <Show when={markReviewedShortcut()}>
<box {(shortcut) => (
flexDirection="row" <text fg={theme().text}>
gap={2} {shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span>
flexShrink={0} </text>
paddingTop={1} )}
paddingBottom={1} </Show>
paddingLeft={2} </Panel>
paddingRight={1} </PanelGroup>
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> </box>
) )
} }

View file

@ -4,7 +4,9 @@ import {
buildFileTree, buildFileTree,
flattenFileTree, flattenFileTree,
moveFileTreeSelection, moveFileTreeSelection,
moveFileTreeSelectionToFirstChild,
moveFileTreeSelectionToFile, moveFileTreeSelectionToFile,
moveFileTreeSelectionToParent,
setFileTreeDirectoryExpanded, setFileTreeDirectoryExpanded,
toggleFileTreeDirectory, toggleFileTreeDirectory,
} from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree-utils" } 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() 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", () => { test("moves file selection relative to the highlighted row", () => {
const rows = flattenFileTree( const rows = flattenFileTree(
buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]), buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]),

View file

@ -3,6 +3,10 @@ import { describe, expect, test } from "bun:test"
import { RGBA } from "@opentui/core" import { RGBA } from "@opentui/core"
import { testRender } from "@opentui/solid" import { testRender } from "@opentui/solid"
import type { JSX } from "solid-js" 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 { DiffViewerFileTree } from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer-file-tree"
import { import {
allExpandedFileTreeDirectories, allExpandedFileTreeDirectories,
@ -14,6 +18,7 @@ const theme = {
backgroundPanel: RGBA.fromHex("#111111"), backgroundPanel: RGBA.fromHex("#111111"),
backgroundElement: RGBA.fromHex("#333333"), backgroundElement: RGBA.fromHex("#333333"),
primary: RGBA.fromHex("#00ffff"), primary: RGBA.fromHex("#00ffff"),
secondary: RGBA.fromHex("#0088ff"),
selectedListItemText: RGBA.fromHex("#ffffff"), selectedListItemText: RGBA.fromHex("#ffffff"),
text: RGBA.fromHex("#ffffff"), text: RGBA.fromHex("#ffffff"),
textMuted: RGBA.fromHex("#888888"), textMuted: RGBA.fromHex("#888888"),
@ -23,44 +28,50 @@ const theme = {
describe("DiffViewerFileTree", () => { describe("DiffViewerFileTree", () => {
test("renders sorted hierarchical file rows", async () => { test("renders sorted hierarchical file rows", async () => {
const app = await testRender( const app = await testRender(
() => ( () => withTheme(
<DiffViewerFileTree () => (
files={[ <DiffViewerFileTree
{ file: "z-file.ts" }, width={32}
{ file: "b/file.ts" }, files={[
{ file: "a/zeta.ts" }, { file: "z-file.ts" },
{ file: "b/alpha.ts" }, { file: "b/file.ts" },
{ file: "a/alpha.ts" }, { file: "a/zeta.ts" },
]} { file: "b/alpha.ts" },
loading={false} { file: "a/alpha.ts" },
error={undefined} ]}
theme={theme} loading={false}
focused={true} error={undefined}
/> theme={theme}
focused={true}
/>
),
), ),
{ width: 40, height: 20 }, { width: 40, height: 20 },
) )
try { try {
await app.renderOnce() await renderOnceSettled(app)
const lines = visibleLines(app.captureCharFrame()) 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 { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
}) })
test("keeps loading and error quiet while rendering an empty settled state", async () => { test("keeps loading and error quiet while rendering an empty settled state", async () => {
const loading = await renderFrame(() => ( const loading = await renderFrame(() => <DiffViewerFileTree width={32} files={[]} loading={true} error={undefined} theme={theme} />)
<DiffViewerFileTree files={[]} loading={true} error={undefined} theme={theme} />
))
const failed = await renderFrame(() => ( const failed = await renderFrame(() => (
<DiffViewerFileTree files={[]} loading={false} error={new Error("nope")} theme={theme} /> <DiffViewerFileTree width={32} files={[]} loading={false} error={new Error("nope")} theme={theme} />
))
const empty = await renderFrame(() => (
<DiffViewerFileTree files={[]} loading={false} error={undefined} theme={theme} />
)) ))
const empty = await renderFrame(() => <DiffViewerFileTree width={32} files={[]} loading={false} error={undefined} theme={theme} />)
expect(loading).not.toContain("Loading diff...") expect(loading).not.toContain("Loading diff...")
expect(loading).not.toContain("No files") expect(loading).not.toContain("No files")
@ -75,18 +86,11 @@ describe("DiffViewerFileTree", () => {
const focused = visibleLines( const focused = visibleLines(
await renderFrame(() => ( await renderFrame(() => (
<DiffViewerFileTree <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} focused highlightedNode={src.id} />
files={files}
loading={false}
error={undefined}
theme={theme}
focused
highlightedNode={src.id}
/>
)), )),
) )
const unfocused = visibleLines( const unfocused = visibleLines(
await renderFrame(() => <DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} />), await renderFrame(() => <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} />),
) )
expect(focused).toContain("▾ src/config") expect(focused).toContain("▾ src/config")
@ -105,16 +109,17 @@ describe("DiffViewerFileTree", () => {
expect( expect(
visibleLines( visibleLines(
await renderFrame(() => ( await renderFrame(() => (
<DiffViewerFileTree files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} /> <DiffViewerFileTree width={32} files={files} loading={false} error={undefined} theme={theme} expandedNodes={collapsed} />
)), )),
), ),
).toEqual(["▸ src/config", " README.md"]) ).toEqual(["▸ src/config"])
expect( expect(
visibleLines( visibleLines(
await renderFrame(() => ( await renderFrame(() => (
<DiffViewerFileTree <DiffViewerFileTree
files={files} files={files}
width={32}
loading={false} loading={false}
error={undefined} error={undefined}
theme={theme} theme={theme}
@ -122,25 +127,41 @@ describe("DiffViewerFileTree", () => {
/> />
)), )),
), ),
).toEqual(["▾ src/config", " tui.ts", " README.md"]) ).toEqual(["▾ src/config", "│ └─ tui.ts ?"])
}) })
}) })
async function renderFrame(component: () => JSX.Element) { 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 { try {
await app.renderOnce() await renderOnceSettled(app)
return app.captureCharFrame() return app.captureCharFrame()
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
} }
async function renderOnceSettled(app: Awaited<ReturnType<typeof testRender>>) {
await app.renderOnce()
await new Promise((resolve) => setTimeout(resolve, 25))
await app.renderOnce()
}
function withTheme(component: () => JSX.Element) {
return (
<TuiConfigProvider config={createTuiResolvedConfig()}>
<KVProvider>
<ThemeProvider mode="dark">{component()}</ThemeProvider>
</KVProvider>
</TuiConfigProvider>
)
}
function visibleLines(frame: string) { function visibleLines(frame: string) {
return frame return frame
.split("\n") .split("\n")
.map((line) => line.trimEnd()) .map((line) => line.trimEnd())
.map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, "")) .map((line) => line.replace(/^ ?│ ?/, "").replace(/[ │]*$/, ""))
.map((line) => (line.startsWith(" ") ? line.slice(1) : line)) .map((line) => (line.startsWith(" ") ? line.slice(1) : line))
.filter((line) => line.length > 0 && !/^┌|^└/.test(line)) .filter((line) => line.length > 0 && !/^┌|^└|^─+$/.test(line))
} }