diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index af19d9c98dc..f52ed887883 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -1,4 +1,4 @@ -import { RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core" +import { BoxRenderable, RGBA, ScrollBoxRenderable, TextAttributes, type MouseEvent } from "@opentui/core" import { For, Index, @@ -398,7 +398,16 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo } return ( - + { + if (!(container instanceof BoxRenderable)) return + // Portal's wrapper otherwise follows the full-height app in root layout. + container.position = "absolute" + container.left = 0 + container.top = 0 + container.zIndex = 2500 + }} + > e", "Open external editor"), "theme.switch": keybind("t", "List available themes"), diff --git a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree-utils.ts b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree-utils.ts index 34ded01ba33..6b746397e17 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree-utils.ts +++ b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree-utils.ts @@ -1,8 +1,3 @@ -// 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 readonly status?: "added" | "deleted" | "modified" @@ -119,44 +114,20 @@ export function compareFileTreeNodes(tree: FileTree, left: number, right: number 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 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 fileTreeFileSelection(tree: FileTree, fileIndex: number) { const node = tree.nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex) if (!node) return undefined return { - highlightedNode: node.id, expandedNodes: fileTreeParentDirectories(tree, node.id), } } export function singlePatchFileIndex( selected: number | undefined, - active: number | undefined, current: number | undefined, first: number | undefined, ) { - return selected ?? active ?? current ?? first + return selected ?? current ?? first } export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) { @@ -186,19 +157,6 @@ export function toggleFileTreeDirectory(tree: FileTree, expanded: ReadonlySet, - selected: number | undefined, - value: boolean, -) { - if (selected === undefined || tree.nodes[selected]?.kind !== "directory") return expanded - const next = new Set(expanded) - if (value) next.add(selected) - else next.delete(selected) - return next -} - function addFileTreeNode(nodes: FileTreeNode[], roots: number[], input: Omit) { const id = nodes.length nodes.push({ ...input, id, children: [] }) diff --git a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx index 8e36d5b2df2..a5c17771053 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer-file-tree.tsx @@ -1,156 +1,259 @@ /** @jsxImportSource @opentui/solid */ -import type { ScrollBoxRenderable } from "@opentui/core" -import type { Plugin } from "@opencode-ai/plugin/tui" -import { Locale } from "../../util/locale" +import { MouseButton, TextAttributes, type MouseEvent, type ScrollBoxRenderable } from "@opentui/core" +import { truncateFilePath } from "../../ui/file-path" +import { stringWidth } from "../../util/string-width" +import { useTheme } from "../../context/theme" import { tint } from "../../theme/color" -import { createEffect, createMemo, For, Match, Switch } from "solid-js" +import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { buildFileTree, flattenFileTree, type FileTreeItem, type FileTreeRow } from "./diff-viewer-file-tree-utils" -import { Panel } from "./diff-viewer-ui" -const FILE_TREE_STATUS_WIDTH = 2 +const FILE_TREE_STATUS_WIDTH = 1 export type DiffViewerFileTreeProps = { - readonly context: Plugin.Context readonly width: number readonly files: readonly FileTreeItem[] readonly loading: boolean readonly error: unknown - readonly focused?: boolean - readonly highlightedNode?: number + readonly layout?: "tree" | "list" readonly selectedFileIndex?: number readonly reviewedFileNames?: ReadonlySet readonly expandedNodes?: ReadonlySet readonly onRowClick?: (row: FileTreeRow) => void + readonly onFileContextMenu?: (fileIndex: number, event: MouseEvent) => void + readonly source?: string + readonly onSwitchSource?: () => void + readonly footer?: JSX.Element } export function DiffViewerFileTree(props: DiffViewerFileTreeProps) { - const theme = props.context.theme + const theme = useTheme("elevated") + const [sourceHovered, setSourceHovered] = createSignal(false) + const list = () => props.layout === "list" const tree = createMemo(() => buildFileTree(props.files)) - const rows = createMemo(() => flattenFileTree(tree(), props.expandedNodes)) + const rows = createMemo(() => + list() + ? flattenFileTree(tree()).filter((row) => row.fileIndex !== undefined) + : flattenFileTree(tree(), props.expandedNodes), + ) + // Quieter than subdued text: markers are affordances, not content. + const faint = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.45)) + // Rails are pure texture; keep them barely above the surface. + const rail = createMemo(() => tint(theme.text.subdued, theme.background.default, 0.7)) + const reviewedCount = createMemo(() => props.files.filter((file) => props.reviewedFileNames?.has(file.file)).length) + const contentWidth = () => Math.max(0, props.width - 4 - FILE_TREE_STATUS_WIDTH - 1) 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) + const index = rows().findIndex((row) => row.fileIndex !== undefined && row.fileIndex === props.selectedFileIndex) + if (index === -1) return + const top = index * (list() ? 3 : 1) + const height = list() ? 2 : 1 + const scrollSelectedIntoView = () => scrollFileTreeRowIntoView(scroll, top, height) scrollSelectedIntoView() requestAnimationFrame(scrollSelectedIntoView) }) - const fadedColor = () => tint(theme.text.default, theme.background.default, 0.75) - return ( - - (scroll = element)} - verticalScrollbarOptions={{ visible: false }} - horizontalScrollbarOptions={{ visible: false }} + + + - - - - - - No files - - 0}> - - {(row, index) => { - const highlighted = () => props.focused && props.highlightedNode === row.id - const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex - const reviewed = () => { - const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file - return file !== undefined && (props.reviewedFileNames?.has(file) ?? false) - } - const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes) - const status = () => fileTreeRowStatus(row, props.files, reviewed()) - const name = () => - Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length)) - return ( - props.onRowClick?.(row)} - > - - {prefix()} - - - + setSourceHovered(true)} + onMouseOut={() => setSourceHovered(false)} + onMouseUp={(event) => { + if (event.button !== MouseButton.LEFT) return + event.stopPropagation() + props.onSwitchSource?.() + }} + > + {props.source ?? "Files"} + + + {reviewedCount()}/{props.files.length} reviewed + + + (scroll = element)} + flexGrow={1} + minHeight={0} + verticalScrollbarOptions={{ visible: false }} + horizontalScrollbarOptions={{ visible: false }} + > + + + + + + No files + + 0}> + + + {(row) => { + const [hovered, setHovered] = createSignal(false) + const selected = () => row.fileIndex !== undefined && props.selectedFileIndex === row.fileIndex + const reviewed = () => { + const file = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.file + return file !== undefined && (props.reviewedFileNames?.has(file) ?? false) + } + const foreground = () => { + if (row.kind === "directory") return theme.text.subdued + return reviewed() ? theme.text.subdued : theme.text.default + } + const background = () => { + // Elevated context maps this to a quiet neutral surface step, not the loud accent. + if (hovered()) return theme.background.action.primary.hovered + return theme.background.default + } + const marker = () => { + if (row.kind !== "directory") return "≡ " + return props.expandedNodes && !props.expandedNodes.has(row.id) ? "▸ " : "▾ " + } + // Rails run straight down from each ancestor folder; no end hooks. + const indent = createMemo(() => { + if (list()) return "" + return "│ ".repeat(Math.max(0, Math.min(row.depth, Math.floor((contentWidth() - 3) / 2)))) + }) + const status = () => fileTreeRowStatus(row, props.files, reviewed()) + const statusColor = () => { + if (reviewed()) return theme.text.subdued + const status = row.fileIndex === undefined ? undefined : props.files[row.fileIndex]?.status + if (status === "added") return theme.diff.text.added + if (status === "deleted") return theme.diff.text.removed + return theme.text.subdued + } + const name = () => { + const width = contentWidth() - stringWidth(indent()) - stringWidth(marker()) + if (row.kind === "directory") return truncateDirectoryChain(row.name, width) + return truncateFilePath(row.name, width) + } + const parent = () => { + const file = row.fileIndex === undefined ? "" : (props.files[row.fileIndex]?.file ?? "") + const directory = file.slice(0, Math.max(0, file.lastIndexOf("/"))) + return directory ? truncateDirectoryChain(directory, contentWidth() - stringWidth(marker())) : "" + } + return ( + setHovered(true)} + onMouseOut={() => setHovered(false)} + onMouseDown={(event) => { + if (row.fileIndex !== undefined) props.onFileContextMenu?.(row.fileIndex, event) + }} + onMouseUp={(event) => { + if (event.button !== MouseButton.LEFT) return + event.stopPropagation() + props.onRowClick?.(row) + }} > - {name()} - - - - {status()} - - - ) - }} - - - - - + + + {indent()} + {marker()} + + + + {name()} + + + + {status()} + + + + + {parent()} + + + + ) + }} + + + + + + + + {props.footer} + + + + ) } -function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, index: number) { - if (!scroll) return - if (index < scroll.scrollTop) { - scroll.scrollTo(index) +function scrollFileTreeRowIntoView(scroll: ScrollBoxRenderable | undefined, top: number, height: number) { + if (!scroll || scroll.isDestroyed) return + if (top < scroll.scrollTop) { + scroll.scrollTo(top) return } - if (index >= scroll.scrollTop + scroll.viewport.height) { - scroll.scrollTo(index - scroll.viewport.height + 1) + if (top + height > scroll.scrollTop + scroll.viewport.height) { + scroll.scrollTo(top + height - scroll.viewport.height) } } -function fileTreeRowPrefix( - rows: readonly FileTreeRow[], - index: number, - row: FileTreeRow, - expandedNodes: ReadonlySet | undefined, -) { - const indentation = Array.from({ length: row.depth }, (_, depth) => { - if (depth === 0 && !hasLaterSibling(rows, 0, 0)) return " " - return hasLaterSibling(rows, index, depth) ? "│ " : " " - }).join("") - const topRoot = index === 0 && row.depth === 0 - const branch = topRoot ? " " : hasLaterSibling(rows, index, row.depth) ? "├─ " : "└─ " - const marker = row.kind === "directory" ? (expandedNodes && !expandedNodes.has(row.id) ? "▸ " : "▾ ") : "" - - return `${indentation}${branch}${marker}` -} - -function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: number) { - return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth -} - function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) { if (row.fileIndex === undefined) return "" + if (reviewed) return "✓" const status = files[row.fileIndex]?.status - const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?" - return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH) + return status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?" +} + +// Collapsed chains drop whole leading segments instead of squeezing +// mid-segment, so "a/b/c/d" narrows to "…/c/d" rather than "…/b…/c/d". +function truncateDirectoryChain(name: string, maxWidth: number) { + if (stringWidth(name) <= maxWidth) return name + const kept: string[] = [] + let width = stringWidth("…/") + for (const segment of name.split("/").toReversed()) { + const next = stringWidth(segment) + (kept.length ? 1 : 0) + if (width + next > maxWidth) break + kept.unshift(segment) + width += next + } + if (kept.length === 0) return truncateFilePath(name, maxWidth) + return `…/${kept.join("/")}` } diff --git a/packages/tui/src/feature-plugins/system/diff-viewer-image.tsx b/packages/tui/src/feature-plugins/system/diff-viewer-image.tsx new file mode 100644 index 00000000000..e0830ea1e6d --- /dev/null +++ b/packages/tui/src/feature-plugins/system/diff-viewer-image.tsx @@ -0,0 +1,96 @@ +/** @jsxImportSource @opentui/solid */ +import { useTerminalDimensions } from "@opentui/solid" +import type { MouseEvent } from "@opentui/core" +import { createResource, createSignal, Match, onCleanup, Show, Switch } from "solid-js" +import { DialogImagePreview } from "../../component/dialog-image-preview" +import { useTheme } from "../../context/theme" +import { useDialog } from "../../ui/dialog" + +export function isDiffImageFile(file: string) { + return /\.(png|jpe?g|webp|gif)$/i.test(file) +} + +export function DiffViewerImage(props: { + file: string + load: (file: string, signal: AbortSignal) => Promise + label?: string +}) { + const theme = useTheme() + const dialog = useDialog() + const dimensions = useTerminalDimensions() + const height = () => Math.max(3, Math.min(8, Math.floor(dimensions().height / 4))) + const [image] = createResource( + () => { + const controller = new AbortController() + onCleanup(() => controller.abort()) + return { file: props.file, signal: controller.signal } + }, + (input) => props.load(input.file, input.signal), + ) + + return ( + + {props.label ?? "Working tree preview"} + + + + Could not load image + + + Loading image... + + + {(bytes) => { + const [failed, setFailed] = createSignal(false) + const [size, setSize] = createSignal() + const open = (event: MouseEvent) => { + if (event.button !== 0 || !size() || failed()) return + event.stopPropagation() + dialog.replace(() => ( + + )) + } + return ( + Could not decode image} + > + + setSize(`${loaded.width} x ${loaded.height}`)} + onError={() => setFailed(true)} + /> + + + {(value) => ( + + {value()} + + Click to enlarge + + + )} + + + ) + }} + + + + + ) +} diff --git a/packages/tui/src/feature-plugins/system/diff-viewer-ui.tsx b/packages/tui/src/feature-plugins/system/diff-viewer-ui.tsx deleted file mode 100644 index 2336a07ce8b..00000000000 --- a/packages/tui/src/feature-plugins/system/diff-viewer-ui.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import type { BorderSides, ColorInput } from "@opentui/core" -import type { Plugin } from "@opencode-ai/plugin/tui" -import type { JSX } from "@opentui/solid" -import { createContext, Show, 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; context: Plugin.Context }>() - -function crossAxis(axis: Axis) { - return axis === "x" ? "y" : "x" -} - -function usePanelGroup() { - return useContext(PanelGroupContext) -} - -export function PanelGroup(props: JSX.IntrinsicElements["box"] & { axis: Axis; context: Plugin.Context }) { - const [local, boxProps] = splitProps(props, ["axis", "context", "children"]) - return ( - - - {local.children} - - - ) -} - -export function Panel( - props: Omit & { border?: PanelBorder; context?: Plugin.Context }, -) { - const group = usePanelGroup() - const [local, boxProps] = splitProps(props, ["border", "context"]) - const context = local.context ?? group?.context - if (!context) throw new Error("Panel context is missing") - const theme = context.theme - const border = local.border ?? "start" - const borderProps = - border === "none" - ? {} - : { - border: panelBorderSides(group?.axis ?? "y", border), - borderColor: theme.border.default, - } - - return ( - - ) -} - -function panelBorderSides(axis: Axis, border: Exclude): BorderSides[] { - if (axis === "x") return border === "both" ? ["top", "bottom"] : [border === "start" ? "top" : "bottom"] - return border === "both" ? ["left", "right"] : [border === "start" ? "left" : "right"] -} - -export function Separator(props: { axis?: Axis; color?: ColorInput; start?: SeparatorEdge; end?: SeparatorEdge }) { - const group = usePanelGroup() - if (!group) throw new Error("PanelGroup is missing") - const theme = group.context.theme - const color = () => props.color ?? theme.border.default - const axis = () => props.axis ?? crossAxis(group.axis) - if (axis() === "y") { - return ( - } - > - - {(edge) => {verticalEdge(edge(), "start")}} - - {(edge) => {verticalEdge(edge(), "end")}} - - - ) - } - return ( - } - > - - {(edge) => {horizontalEdge(edge(), "start")}} - - {(edge) => {horizontalEdge(edge(), "end")}} - - - ) -} - -function horizontalEdge(edge: SeparatorEdge, side: "start" | "end") { - if (edge === "edge") return side === "start" ? "├" : "┤" - if (edge === "edge-in") return "┴" - return "┬" -} - -function verticalEdge(edge: SeparatorEdge, side: "start" | "end") { - if (edge === "edge") return side === "start" ? "┬" : "┴" - if (edge === "edge-in") return "┤" - return "├" -} diff --git a/packages/tui/src/feature-plugins/system/diff-viewer.tsx b/packages/tui/src/feature-plugins/system/diff-viewer.tsx index 0c8fa330828..f21a7da5f04 100644 --- a/packages/tui/src/feature-plugins/system/diff-viewer.tsx +++ b/packages/tui/src/feature-plugins/system/diff-viewer.tsx @@ -2,13 +2,21 @@ import type { FileDiffInfo } from "@opencode-ai/client" import { Plugin } from "@opencode-ai/plugin/tui" import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context" -import { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core" +import { + MouseButton, + TextAttributes, + type BoxRenderable, + type MouseEvent, + type ScrollBoxRenderable, +} from "@opentui/core" import { filetype } from "../../util/filetype" -import { useTerminalDimensions } from "@opentui/solid" +import { useRenderer, useTerminalDimensions } from "@opentui/solid" import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { DiffViewerFileTree } from "./diff-viewer-file-tree" -import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" +import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image" import { DialogSelect } from "../../ui/dialog-select" +import { EmptyBorder } from "../../ui/border" +import { FilePath } from "../../ui/file-path" import { getScrollAcceleration } from "../../util/scroll" import { useConfig } from "../../config" import { useThemes } from "../../context/theme" @@ -19,12 +27,8 @@ import { fileTreeFileSelection, type FileTreeRow, flattenFileTree, - moveFileTreeSelection, - moveFileTreeSelectionToFirstChild, - moveFileTreeSelectionToParent, movePatchFileIndex, orderedPatchFileIndexes, - setFileTreeDirectoryExpanded, showDiffViewerFileTree, singlePatchFileIndex, toggleFileTreeDirectory, @@ -32,15 +36,16 @@ import { const ROUTE = "diff" const MIN_SPLIT_WIDTH = 100 -const FILE_TREE_WIDTH = 32 -const PLAIN_TEXT_FILETYPE = "opencode-plain-text" +const FILE_TREE_MIN_WIDTH = 30 +const FILE_TREE_MAX_WIDTH = 40 +const FILE_HEADER_HEIGHT = 2 const VCS_DIFF_CONTEXT_LINES = 12 type DiffMode = "working" | "branch" -type DiffViewerFocus = "patches" | "files" type DiffView = "split" | "unified" type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number } +type FileMenuState = { readonly fileIndex: number; readonly x: number; readonly y: number } -type DiffFile = { +export type DiffFile = { readonly file: string readonly patch?: string readonly additions: number @@ -62,16 +67,14 @@ function storedView(value: unknown): DiffView | undefined { } function diffSourceLabel(mode: DiffMode) { - if (mode === "branch") return "main branch" - return "working tree" + if (mode === "branch") return "Main branch" + return "Working tree" } function DiffViewer(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const config = useConfig() const dialog = props.context.ui.dialog - const theme = props.context.theme - const currentSyntax = useThemes().currentSyntax const params = () => { const route = props.context.ui.router.current() return (route.type === "plugin" ? route.data : undefined) as @@ -101,109 +104,133 @@ function DiffViewer(props: { context: Plugin.Context }) { }) return normalizeDiffs(result.data ?? []) }) - const files = createMemo(() => (diff.error ? [] : (diff() ?? []))) - const [focus, setFocus] = createSignal("patches") - const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true) - const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length)) - const [singlePatch, setSinglePatch] = createSignal(config.data.diffs?.single ?? false) - const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4) - const patchLeftBorder = createMemo(() => (showFileTree() ? ["left"] : [])) + + return ( + + + props.context.client.file.read({ path: file, location: diffInput().location }, { signal }) + } + onPreferencesChange={(value) => { + void config + .update((draft) => { + draft.diffs = { ...draft.diffs, ...value } + }) + .catch(() => {}) + }} + onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })} + onSwitchSource={(mode) => { + dialog.clear() + props.context.ui.router.navigate({ + type: "plugin", + name: ROUTE, + data: { mode, sessionID: params()?.sessionID, returnRoute: params()?.returnRoute }, + }) + }} + /> + + ) +} + +type DiffPreferences = { tree?: boolean; single?: boolean; view?: "auto" | DiffView } + +export function DiffViewerContent(props: { + context: Plugin.Context + files: readonly DiffFile[] + loading?: boolean + error?: unknown + mode: DiffMode + navigation?: "tree" | "list" + loadImage?: (file: string, signal: AbortSignal) => Promise + preferences?: DiffPreferences + onPreferencesChange?: (value: DiffPreferences) => void + onClose: () => void + onSwitchSource: (mode: DiffMode) => void +}) { + const renderer = useRenderer() + const dimensions = useTerminalDimensions() + const config = useConfig() + const dialog = props.context.ui.dialog + const theme = useThemes().current + const currentSyntax = useThemes().currentSyntax + const files = () => props.files + const mode = () => props.mode + const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true) + const showFileTree = createMemo( + () => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length), + ) + const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false) + const fileTreeWidth = createMemo(() => + Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))), + ) + const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4) const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH) - const defaultView = createMemo(() => { - if (config.data.diffs?.view === "unified") return "unified" - if (config.data.diffs?.view === "split") return "split" - return splitAvailable() ? "split" : "unified" - }) - const [viewOverride, setViewOverride] = createSignal(storedView(config.data.diffs?.view)) - const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified")) + const [viewOverride, setViewOverride] = createSignal(storedView(props.preferences?.view)) + const view = createMemo(() => + splitAvailable() ? (viewOverride() ?? storedView(props.preferences?.view) ?? "split") : "unified", + ) const fileTree = createMemo(() => buildFileTree(files())) const [expandedFileNodes, setExpandedFileNodes] = createSignal>(new Set()) - const [highlightedFileNode, setHighlightedFileNode] = createSignal() - const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal() - const [activePatchFileIndex, setActivePatchFileIndex] = createSignal() const [selectedFileIndex, setSelectedFileIndex] = createSignal() const [reviewedFileNames, setReviewedFileNames] = createSignal>(new Set()) + const [fileMenu, setFileMenu] = createSignal() const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data)) - const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree()))) - const focusRunner = (input: Record void>) => () => input[focus()]() - const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0] - const switchFocusShortcut = shortcut("diff.switch_focus") - const nextHunkShortcut = shortcut("diff.next_hunk") - const previousHunkShortcut = shortcut("diff.previous_hunk") - const nextFileShortcut = shortcut("diff.next_file") - const previousFileShortcut = shortcut("diff.previous_file") - const switchSourceShortcut = shortcut("diff.switch_source") - const markReviewedShortcut = shortcut("diff.mark_reviewed") - const helpShortcut = shortcut("diff.help") + const helpShortcut = () => props.context.keymap.shortcuts("diff.help")[0] let scroll: ScrollBoxRenderable | undefined const patchNodeByFileIndex = new Map() const patchDiffByFileIndex = new Map() const [selectedHunk, setSelectedHunk] = createSignal() const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal() - const [patchFillerHeight, setPatchFillerHeight] = createSignal(0) onCleanup(() => dialog.clear()) createEffect(() => { setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) - setHighlightedFileNode(undefined) - setLastHighlightedFileNode(undefined) - setActivePatchFileIndex(undefined) setSelectedFileIndex(undefined) setSelectedHunk(undefined) setReviewedFileNames(new Set()) + setFileMenu(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().find((row) => row.fileIndex !== undefined)?.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 clearPatchSelection = () => { + setPendingPatchScrollFileIndex(undefined) setSelectedHunk(undefined) + if (!singlePatch()) setSelectedFileIndex(undefined) } - const scrollPatchNodeToTop = (patchNode: BoxRenderable) => { - requestAnimationFrame(() => { - if (!scroll) return - const scrollDelta = patchNode.y - scroll.viewport.y - const contentY = scroll.scrollTop + scrollDelta - const offset = contentY === 0 ? 0 : 1 - scroll.scrollBy(scrollDelta + offset) - }) + const scrollPage = (direction: -1 | 1, divisor: 1 | 2) => { + clearPatchSelection() + if (scroll) scroll.scrollBy(direction * Math.max(1, Math.floor(scroll.viewport.height / divisor))) + } + + const scrollPatchNodeToTop = (patchNode: BoxRenderable, offset?: number) => { + if (!scroll || patchNode.isDestroyed) return + const contentY = patchNode.y - scroll.content.y + // The fixed pane edge replaces the leading separator when jumping to a later file. + scroll.scrollTo(contentY + (offset ?? (contentY > 0 ? 1 : 0))) } const revealFileTreeFile = (fileIndex: number) => { const selection = fileTreeFileSelection(fileTree(), fileIndex) if (!selection) return setExpandedFileNodes((expanded) => { + if ([...selection.expandedNodes].every((node) => expanded.has(node))) return expanded const next = new Set(expanded) selection.expandedNodes.forEach((node) => next.add(node)) return next }) - setHighlighted(selection.highlightedNode) } const selectPatchFile = (fileIndex: number) => { + setPendingPatchScrollFileIndex(undefined) revealFileTreeFile(fileIndex) - setActivePatchFileIndex(fileIndex) setSelectedFileIndex(fileIndex) } @@ -218,6 +245,7 @@ function DiffViewer(props: { context: Plugin.Context }) { if (fileIndex === undefined) return setSelectedHunk(undefined) scrollToFileIndex(fileIndex) + if (singlePatch()) scrollSinglePatchToTop() } const currentPatchFileIndex = () => { @@ -239,7 +267,7 @@ function DiffViewer(props: { context: Plugin.Context }) { const jumpRelativePatchFile = (offset: number) => { setSelectedHunk(undefined) - const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset) + const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? currentPatchFileIndex(), offset) if (singlePatch()) { if (next === undefined) return selectPatchFile(next) @@ -271,19 +299,20 @@ function DiffViewer(props: { context: Plugin.Context }) { selected?.scrollTop === patchScroll.scrollTop ? hunks.findIndex((hunk) => hunk.fileIndex === selected.fileIndex && hunk.hunkIndex === selected.hunkIndex) : -1 + const contentTop = patchScroll.scrollTop + FILE_HEADER_HEIGHT const next = selectedIndex !== -1 ? hunks[selectedIndex + offset] : offset === 1 - ? hunks.find((hunk) => hunk.contentY > patchScroll.scrollTop) - : hunks.findLast((hunk) => hunk.contentY < patchScroll.scrollTop) + ? hunks.find((hunk) => hunk.contentY > contentTop) + : hunks.findLast((hunk) => hunk.contentY < contentTop) if (!next) return selectPatchFile(next.fileIndex) - patchScroll.scrollTo(next.contentY) + patchScroll.scrollTo(Math.max(0, next.contentY - FILE_HEADER_HEIGHT)) setSelectedHunk({ fileIndex: next.fileIndex, hunkIndex: next.hunkIndex, scrollTop: patchScroll.scrollTop }) } - const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex + const firstPatchFileIndex = () => patchFileIndexes()[0] const visiblePatchFiles = createMemo(() => { if (!singlePatch()) { return patchFileIndexes().flatMap((fileIndex) => { @@ -291,30 +320,27 @@ function DiffViewer(props: { context: Plugin.Context }) { return file ? [{ file, fileIndex }] : [] }) } - const fileIndex = singlePatchFileIndex( - selectedFileIndex(), - activePatchFileIndex(), - currentPatchFileIndex(), - firstPatchFileIndex(), - ) + const fileIndex = singlePatchFileIndex(selectedFileIndex(), currentPatchFileIndex(), firstPatchFileIndex()) const file = fileIndex === undefined ? undefined : files()[fileIndex] return file && fileIndex !== undefined ? [{ file, fileIndex }] : [] }) const ensureHighlightedPatchFile = () => { - const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex() + const fileIndex = currentPatchFileIndex() ?? selectedFileIndex() ?? firstPatchFileIndex() if (fileIndex === undefined) return selectPatchFile(fileIndex) } - const scrollToPatchFileIndexAfterRender = (fileIndex: number) => { + const scrollToPatchFileIndexAfterRender = (fileIndex: number, offset?: number) => { setPendingPatchScrollFileIndex(fileIndex) requestAnimationFrame(() => { + if (pendingPatchScrollFileIndex() !== fileIndex) return const patchNode = patchNodeByFileIndex.get(fileIndex) - if (patchNode) scrollPatchNodeToTop(patchNode) + if (patchNode) scrollPatchNodeToTop(patchNode, offset) requestAnimationFrame(() => { + if (pendingPatchScrollFileIndex() !== fileIndex) return const patchNode = patchNodeByFileIndex.get(fileIndex) - if (patchNode) scrollPatchNodeToTop(patchNode) + if (patchNode) scrollPatchNodeToTop(patchNode, offset) setPendingPatchScrollFileIndex(undefined) }) }) @@ -327,55 +353,13 @@ function DiffViewer(props: { context: Plugin.Context }) { }) } - const measurePatchFiller = () => { - requestAnimationFrame(() => { - if (!scroll) return - const entries = visiblePatchFiles() - .map((entry) => patchNodeByFileIndex.get(entry.fileIndex)) - .filter((node): node is BoxRenderable => Boolean(node)) - if (entries.length === 0) { - setPatchFillerHeight(0) - return - } - const contentHeight = Math.max( - ...entries.map((node) => scroll!.scrollTop + node.y - scroll!.viewport.y + node.height), - ) - setPatchFillerHeight(Math.max(0, scroll.viewport.height - contentHeight)) - }) - } - const registerPatchNode = (fileIndex: number, element: BoxRenderable) => { patchNodeByFileIndex.set(fileIndex, element) - measurePatchFiller() if (pendingPatchScrollFileIndex() !== fileIndex) return - requestAnimationFrame(() => { - scrollPatchNodeToTop(element) - requestAnimationFrame(() => { - scrollPatchNodeToTop(element) - setPendingPatchScrollFileIndex(undefined) - }) - }) - } - - createEffect(() => { - visiblePatchFiles() - dimensions() - view() - measurePatchFiller() - }) - - const toggleSelectedFileTreeRow = () => { - const highlighted = fileRows().find((row) => row.id === highlightedFileNode()) - if (highlighted?.fileIndex !== undefined) { - jumpToFileIndex(highlighted.fileIndex) - return - } - setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode())) + scrollToPatchFileIndexAfterRender(fileIndex) } const clickFileTreeRow = (row: FileTreeRow) => { - setFocus("files") - setHighlighted(row.id) if (row.fileIndex !== undefined) { jumpToFileIndex(row.fileIndex) return @@ -383,25 +367,43 @@ function DiffViewer(props: { context: Plugin.Context }) { setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, row.id)) } - const toggleSelectedFileReviewed = () => { - const fileIndex = - focus() === "files" - ? fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex - : (selectedFileIndex() ?? activePatchFileIndex() ?? currentPatchFileIndex()) - const file = fileIndex === undefined ? undefined : files()[fileIndex]?.file + const toggleFileReviewed = (fileIndex: number | undefined) => { + if (fileIndex === undefined) return + const file = files()[fileIndex]?.file if (!file) return + const current = selectedFileIndex() ?? currentPatchFileIndex() + const anchor = current === undefined ? undefined : patchNodeByFileIndex.get(current) + const offset = anchor && scroll ? scroll.viewport.y - anchor.y : undefined + const reviewed = reviewedFileNames().has(file) setReviewedFileNames((reviewed) => { const next = new Set(reviewed) if (next.has(file)) next.delete(file) else next.add(file) return next }) + // Completing another file from its menu must not navigate away from the current file. + if (fileIndex !== current) { + if (current !== undefined && offset !== undefined && !singlePatch()) + scrollToPatchFileIndexAfterRender(current, offset) + return + } + const nextFileIndex = + singlePatch() && !reviewed ? (movePatchFileIndex(patchFileIndexes(), fileIndex, 1) ?? fileIndex) : fileIndex + selectPatchFile(nextFileIndex) + setSelectedHunk(undefined) + scrollToPatchFileIndexAfterRender(nextFileIndex) + } + + const openFileMenu = (fileIndex: number, event: MouseEvent) => { + if (event.button !== MouseButton.RIGHT) return + event.preventDefault() + event.stopPropagation() + setFileMenu({ fileIndex, x: event.x, y: event.y }) } const close = () => { - const returnRoute = params()?.returnRoute dialog.clear() - props.context.ui.router.navigate(returnRoute ?? { type: "home" }) + props.onClose() } const commands: KeymapCommand[] = [ @@ -411,126 +413,65 @@ function DiffViewer(props: { context: Plugin.Context }) { group: "VCS", run: close, }, - { - id: "app.exit", - title: "Close diff viewer", - group: "VCS", - run: close, - }, { id: "diff.down", title: "Move diff viewer down", group: "VCS", - run: focusRunner({ - files() { - moveFileSelection(1) - }, - patches() { - clearFileTreePatchState() - scroll?.scrollBy(1) - }, - }), + run() { + clearPatchSelection() + scroll?.scrollBy(1) + }, }, { id: "diff.up", title: "Move diff viewer up", group: "VCS", - run: focusRunner({ - files() { - moveFileSelection(-1) - }, - patches() { - clearFileTreePatchState() - scroll?.scrollBy(-1) - }, - }), + run() { + clearPatchSelection() + scroll?.scrollBy(-1) + }, }, { id: "diff.page.down", title: "Page diff viewer down", group: "VCS", - run: focusRunner({ - files() { - moveFileSelection(8) - }, - patches() { - clearFileTreePatchState() - if (scroll) scroll.scrollBy(scroll.height) - }, - }), + run: () => scrollPage(1, 1), }, { id: "diff.page.up", title: "Page diff viewer up", group: "VCS", - run: focusRunner({ - files() { - moveFileSelection(-8) - }, - patches() { - clearFileTreePatchState() - if (scroll) scroll.scrollBy(-scroll.height) - }, - }), + run: () => scrollPage(-1, 1), }, { - id: "diff.toggle", - title: "Toggle diff viewer item", + id: "diff.half_page.down", + title: "Scroll down half a page", group: "VCS", - run: focusRunner({ - files() { - toggleSelectedFileTreeRow() - }, - patches() {}, - }), + run: () => scrollPage(1, 2), }, { - id: "diff.expand", - title: "Expand diff viewer item", + id: "diff.half_page.up", + title: "Scroll up half a page", group: "VCS", - run: focusRunner({ - files() { - const highlighted = highlightedFileNode() - if (highlighted !== undefined && expandedFileNodes().has(highlighted)) { - setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted)) - return - } - setExpandedFileNodes((expanded) => - setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true), - ) - }, - patches() {}, - }), + run: () => scrollPage(-1, 2), }, { - id: "diff.expand_all", - title: "Expand all diff viewer folders", + id: "diff.first", + title: "Go to the start of the diff", group: "VCS", - run: focusRunner({ - files() { - setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) - }, - patches() {}, - }), + run() { + clearPatchSelection() + scroll?.scrollTo(0) + }, }, { - id: "diff.collapse", - title: "Collapse diff viewer item", + id: "diff.last", + title: "Go to the end of the diff", group: "VCS", - run: focusRunner({ - files() { - const highlighted = highlightedFileNode() - const node = highlighted === undefined ? undefined : fileTree().nodes[highlighted] - if (node?.kind !== "directory" || !expandedFileNodes().has(node.id)) { - setHighlighted(moveFileTreeSelectionToParent(fileRows(), highlighted)) - return - } - setExpandedFileNodes((expanded) => - setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false), - ) - }, - patches() {}, - }), + run() { + clearPatchSelection() + if (scroll) scroll.scrollTo(scroll.scrollHeight) + }, }, { id: "diff.next_hunk", @@ -569,20 +510,7 @@ function DiffViewer(props: { context: Plugin.Context }) { title: "Toggle selected diff file reviewed", group: "VCS", run() { - toggleSelectedFileReviewed() - }, - }, - { - id: "diff.switch_focus", - title: "Switch diff viewer focus", - group: "VCS", - run() { - if (!showFileTree()) return - setFocus((current) => { - if (current === "files") return "patches" - ensureHighlightedFileNode() - return "files" - }) + toggleFileReviewed(selectedFileIndex() ?? currentPatchFileIndex()) }, }, { @@ -591,13 +519,8 @@ function DiffViewer(props: { context: Plugin.Context }) { group: "VCS", run() { const next = !fileTreeEnabled() - if (!next) setFocus("patches") setFileTreeEnabled(next) - void config - .update((draft) => { - draft.diffs = { ...draft.diffs, tree: next } - }) - .catch(() => {}) + props.onPreferencesChange?.({ tree: next }) }, }, { @@ -609,29 +532,16 @@ function DiffViewer(props: { context: Plugin.Context }) { if (!singlePatch()) { ensureHighlightedPatchFile() setSinglePatch(true) - void config - .update((draft) => { - draft.diffs = { ...draft.diffs, single: true } - }) - .catch(() => {}) + props.onPreferencesChange?.({ single: true }) scrollSinglePatchToTop() return } const fileIndex = visiblePatchFiles()[0]?.fileIndex ?? - singlePatchFileIndex( - selectedFileIndex(), - activePatchFileIndex(), - currentPatchFileIndex(), - firstPatchFileIndex(), - ) + singlePatchFileIndex(selectedFileIndex(), currentPatchFileIndex(), firstPatchFileIndex()) if (fileIndex !== undefined) selectPatchFile(fileIndex) setSinglePatch(false) - void config - .update((draft) => { - draft.diffs = { ...draft.diffs, single: false } - }) - .catch(() => {}) + props.onPreferencesChange?.({ single: false }) if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex) }, }, @@ -652,11 +562,7 @@ function DiffViewer(props: { context: Plugin.Context }) { setSelectedHunk(undefined) const next = view() === "split" ? "unified" : "split" setViewOverride(next) - void config - .update((draft) => { - draft.diffs = { ...draft.diffs, view: next } - }) - .catch(() => {}) + props.onPreferencesChange?.({ view: next }) }, }, { @@ -667,6 +573,13 @@ function DiffViewer(props: { context: Plugin.Context }) { openHelpDialog() }, }, + // Specific diff bindings take precedence over app.exit's Ctrl+D binding. + { + id: "app.exit", + title: "Close diff viewer", + group: "VCS", + run: close, + }, ] const openSwitchDiffDialog = () => { @@ -692,15 +605,7 @@ function DiffViewer(props: { context: Plugin.Context }) { ...option, onSelect() { dialog.clear() - props.context.ui.router.navigate({ - type: "plugin", - name: ROUTE, - data: { - mode: option.value, - sessionID: params()?.sessionID, - returnRoute: params()?.returnRoute, - }, - }) + props.onSwitchSource(option.value) }, }))} /> @@ -708,290 +613,386 @@ function DiffViewer(props: { context: Plugin.Context }) { } const openHelpDialog = () => { - dialog.show(() => ) - dialog.set({ size: "large" }) + dialog.show(() => ) + dialog.set({ size: "medium", centered: true }) } + const HelpShortcut = (props: { compact?: boolean }) => ( + + {(shortcut) => ( + { + if (event.button !== MouseButton.LEFT) return + event.stopPropagation() + openHelpDialog() + }} + > + {props.compact ? "?" : shortcut()} + + help + + + )} + + ) + props.context.keymap.layer(() => ({ commands, })) return ( - - - - Diff - {diffSourceLabel(mode())} - - - - {files().length} {files().length === 1 ? "file" : "files"} - - - + + + + + + Loading diff… + + + + + + Could not load diff. Reopen the diff viewer to try again. + + + + + + No changes to show + + + + + + } + /> + - - - - - - Loading diff… - - - - - - - Could not load diff. Reopen the diff viewer to try again. - - - - - - - No changes to show - - - - - - - - - - - (scroll = element)} - flexGrow={1} - minHeight={0} - scrollAcceleration={patchScrollAcceleration()} - verticalScrollbarOptions={{ visible: false }} - horizontalScrollbarOptions={{ visible: false }} - > - - {(entry, index) => { - const reviewed = () => reviewedFileNames().has(entry.file.file) - return ( - registerPatchNode(entry.fileIndex, element)}> - {index() !== 0 ? : null} + + { + // The fixed edge belongs to the visible card, not the tree selection. + edge.onLifecyclePass = () => { + if (!scroll) return + const entry = visiblePatchFiles().findLast( + (entry) => (patchNodeByFileIndex.get(entry.fileIndex)?.y ?? Infinity) <= scroll!.viewport.y, + ) + edge.backgroundColor = + entry && reviewedFileNames().has(entry.file.file) + ? theme.background.surface.overlay + : theme.diff.background.context + } + renderer.registerLifecyclePass(edge) + onCleanup(() => renderer.unregisterLifecyclePass(edge)) + }} + height={1} + flexShrink={0} + backgroundColor={theme.diff.background.context} + /> + (scroll = element)} + flexGrow={1} + minHeight={0} + scrollAcceleration={patchScrollAcceleration()} + onMouseScroll={clearPatchSelection} + verticalScrollbarOptions={{ visible: false }} + horizontalScrollbarOptions={{ visible: false }} + > + + {(entry, index) => { + const reviewed = () => reviewedFileNames().has(entry.file.file) + const background = () => + reviewed() ? theme.background.surface.overlay : theme.diff.background.context + const image = () => isDiffImageFile(entry.file.file) + const countsWidth = () => + (image() ? 6 : String(entry.file.additions).length + String(entry.file.deletions).length + 5) + + (reviewed() ? 2 : 0) + return ( + registerPatchNode(entry.fileIndex, element)}> + 0}> + + + openFileMenu(entry.fileIndex, event)} + ref={(header: BoxRenderable) => { + // Move the original title without changing flow, bounded by its own card. + header.onLifecyclePass = () => { + if (!scroll || !header.parent) return + header.translateY = Math.max( + 0, + Math.min( + scroll.scrollTop - (header.parent.y - scroll.content.y), + header.parent.height - header.height, + ), + ) + } + renderer.registerLifecyclePass(header) + onCleanup(() => renderer.unregisterLifecyclePass(header)) + }} flexDirection="row" gap={1} flexShrink={0} + height={FILE_HEADER_HEIGHT} + zIndex={1} + backgroundColor={background()} paddingLeft={1} paddingRight={1} - border={patchLeftBorder()} - borderColor={theme.border.default} + paddingBottom={1} > - {entry.file.file} - - - +{entry.file.additions} - - - -{entry.file.deletions} - + + + + + + ✓ + + + Image}> + + +{entry.file.additions} + + + -{entry.file.deletions} + + + + + - - No patch available for this file.} - > - {(patch) => ( - - patchDiffByFileIndex.set(entry.fileIndex, component)} - diff={patch()} - hunkFg={reviewed() ? theme.text.subdued : theme.diff.text.hunkHeader} - view={view()} - filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)} - syntaxStyle={currentSyntax()} - showLineNumbers={true} - width="100%" - wrapMode="char" - fg={reviewed() ? theme.text.subdued : theme.text.default} - addedBg={ - reviewed() ? theme.background.surface.overlay : theme.diff.background.added - } - removedBg={ - reviewed() ? theme.background.surface.overlay : theme.diff.background.removed - } - contextBg={ - reviewed() ? theme.background.surface.overlay : theme.diff.background.context - } - addedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.added} - removedSignColor={reviewed() ? theme.text.subdued : theme.diff.highlight.removed} - lineNumberFg={theme.diff.lineNumber.text} - lineNumberBg={ - reviewed() ? theme.background.surface.overlay : theme.diff.background.context - } - addedLineNumberBg={ - reviewed() - ? theme.background.surface.overlay - : theme.diff.lineNumber.background.added - } - removedLineNumberBg={ - reviewed() - ? theme.background.surface.overlay - : theme.diff.lineNumber.background.removed - } - /> - - )} + + + + {entry.file.status === "deleted" && image() + ? "Deleted image. The previous revision is not available for preview." + : "No patch available for this file."} + + + } + > + + {(load) => } + + + {(patch) => ( + { + patchDiffByFileIndex.set(entry.fileIndex, component) + onCleanup(() => patchDiffByFileIndex.delete(entry.fileIndex)) + }} + diff={patch()} + hunkFg={theme.diff.text.hunkHeader} + view={entry.file.status === "modified" ? view() : "unified"} + filetype={filetype(entry.file.file)} + syntaxStyle={currentSyntax()} + showLineNumbers={true} + width="100%" + wrapMode="char" + fg={theme.text.default} + addedBg={theme.diff.background.added} + removedBg={theme.diff.background.removed} + contextBg={theme.diff.background.context} + addedSignColor={theme.diff.highlight.added} + removedSignColor={theme.diff.highlight.removed} + lineNumberFg={theme.diff.lineNumber.text} + lineNumberBg={theme.diff.background.context} + addedLineNumberBg={theme.diff.lineNumber.background.added} + removedLineNumberBg={theme.diff.lineNumber.background.removed} + /> + )} + + - ) - }} - - 0}> - - - - - - - - + + ) + }} + + + + + + + + + + - - - - {(shortcut) => ( - - {shortcut()} focus file tree - - )} - - - {(shortcut) => ( - - {shortcut()} next file - - )} - - - {(shortcut) => ( - - {shortcut()} next hunk - - )} - - - {(shortcut) => ( - - {shortcut()} previous hunk - - )} - - - {(shortcut) => ( - - {shortcut()} previous file - - )} - - - {(shortcut) => ( - - {shortcut()} switch source - - )} - - - {(shortcut) => ( - - {shortcut()} mark reviewed - - )} - - - {(shortcut) => ( - - {shortcut()} all - - )} - - - + + + {(state) => ( + toggleFileReviewed(state.fileIndex)} + onClose={() => setFileMenu(undefined)} + /> + )} + ) } -function DiffViewerHelpDialog(props: { context: Plugin.Context }) { +function DiffFileMenu(props: { + context: Plugin.Context + state: FileMenuState + reviewed: boolean + onToggle: () => void + onClose: () => void +}) { + const dimensions = useTerminalDimensions() + const theme = props.context.theme.contextual.overlay + const [hovered, setHovered] = createSignal(false) + const label = () => (props.reviewed ? "Mark incomplete" : "Mark complete") + const run = () => { + props.onClose() + props.onToggle() + } + onCleanup(props.context.keymap.mode.push("menu")) + props.context.keymap.layer(() => ({ + mode: "menu", + commands: [ + { bind: "escape,ctrl+c", title: "Close file menu", group: "Diff", run: props.onClose }, + { bind: "return", title: label(), group: "Diff", run }, + ], + })) + + return ( + { + props.onClose() + event.preventDefault() + event.stopPropagation() + }} + > + setHovered(true)} + onMouseOut={() => setHovered(false)} + onMouseDown={(event) => { + if (event.button === MouseButton.RIGHT) props.onClose() + event.preventDefault() + event.stopPropagation() + }} + onMouseUp={(event) => { + event.preventDefault() + event.stopPropagation() + if (event.button === MouseButton.LEFT) run() + }} + > + + {label()} + + + + ) +} + +function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) { + const dimensions = useTerminalDimensions() const theme = props.context.theme.contextual.elevated - const shortcut = (id: string) => () => props.context.keymap.shortcuts(id)[0] - const rows = [ + const shortcut = + (...ids: string[]) => + () => + ids + .map((id) => props.context.keymap.shortcuts(id)[0]) + .filter(Boolean) + .join(" / ") + const groups = [ { - shortcut: () => "q", - action: "Close viewer", - description: "Quit the diff viewer", + title: "Review", + rows: [ + { shortcut: () => props.context.keymap.shortcuts("diff.next_file").join(" / "), label: "Next file" }, + { shortcut: () => props.context.keymap.shortcuts("diff.previous_file").join(" / "), label: "Previous file" }, + { + shortcut: shortcut("diff.mark_reviewed"), + label: props.single ? "Review + next / reopen" : "Review + collapse / reopen", + }, + { shortcut: shortcut("diff.next_hunk", "diff.previous_hunk"), label: "Next / previous change" }, + { shortcut: () => "right-click", label: "File menu (heading or tree)" }, + ], }, { - shortcut: shortcut("diff.switch_focus"), - action: "Focus file tree", - description: "Move keyboard focus between the file tree and patch pane", + title: "Scroll", + rows: [ + { shortcut: shortcut("diff.down", "diff.up"), label: "Down / up" }, + { shortcut: shortcut("diff.half_page.down", "diff.half_page.up"), label: "Half page down / up" }, + { shortcut: shortcut("diff.page.down", "diff.page.up"), label: "Page down / up" }, + { shortcut: shortcut("diff.first", "diff.last"), label: "First / last" }, + ], }, { - shortcut: shortcut("diff.next_hunk"), - action: "Next hunk", - description: "Jump to the next diff hunk", - }, - { - shortcut: shortcut("diff.previous_hunk"), - action: "Previous hunk", - description: "Jump to the previous diff hunk", - }, - { - shortcut: shortcut("diff.next_file"), - action: "Next file", - description: "Select the next changed file in file-tree order", - }, - { - shortcut: shortcut("diff.previous_file"), - action: "Previous file", - description: "Select the previous changed file in file-tree order", - }, - { - shortcut: shortcut("diff.toggle_file_tree"), - action: "Toggle file tree", - description: "Show or hide the file tree sidebar", - }, - { - shortcut: shortcut("diff.single_patch"), - action: "Toggle patches", - description: "Switch between one selected patch and all patches", - }, - { - shortcut: shortcut("diff.switch_source"), - action: "Switch source", - description: "Choose working tree or main branch changes", - }, - { - shortcut: shortcut("diff.toggle_view"), - action: "Toggle view", - description: "Switch between split and unified diff layout", - }, - { - shortcut: shortcut("diff.expand_all"), - action: "Expand all folders", - description: "Open every folder in the file tree", - }, - { - shortcut: shortcut("diff.mark_reviewed"), - action: "Mark reviewed", - description: "Toggle reviewed state for the selected file", + title: "View", + rows: [ + { shortcut: shortcut("diff.toggle_view"), label: "Split / unified" }, + { shortcut: shortcut("diff.single_patch"), label: "All files / single file" }, + { shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" }, + { shortcut: shortcut("diff.switch_source"), label: "Switch diff source" }, + { shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" }, + ], }, ] @@ -1001,30 +1002,47 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context }) { Diff shortcuts - esc - - - - Key + props.context.ui.dialog.clear()}> + esc close - - Action - - Description - - {(row) => ( - - - {row.shortcut() || "-"} - - - {row.action} - - {row.description} - + height + group.rows.length + 2, -1), + ), )} - + horizontalScrollbarOptions={{ visible: false }} + verticalScrollbarOptions={{ visible: false }} + > + + + {(group) => ( + + + {group.title} + + + {(row) => ( + + + {row.shortcut() || "unbound"} + + + {row.label} + + + )} + + + )} + + + ) } diff --git a/packages/tui/src/plugin/api.tsx b/packages/tui/src/plugin/api.tsx index d4b226d2c8a..2faa830b8ea 100644 --- a/packages/tui/src/plugin/api.tsx +++ b/packages/tui/src/plugin/api.tsx @@ -236,7 +236,10 @@ function settle(resolve: (value: T) => void) { } } -function createDialogApi(dialog: ReturnType, provide: (render: () => JSX.Element) => JSX.Element) { +export function createDialogApi( + dialog: ReturnType, + provide: (render: () => JSX.Element) => JSX.Element, +) { const api: Dialog = { show(render, onClose) { dialog.replace(() => provide(render), onClose) diff --git a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx index 29d4f7efc4d..ef0b392418a 100644 --- a/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer-file-tree.test.tsx @@ -2,16 +2,11 @@ import { describe, expect, test } from "bun:test" import { testRender } from "@opentui/solid" import type { JSX } from "solid-js" -import { onMount, type ParentProps } from "solid-js" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { emptyThemeSource } from "../../fixture/fixture" -import { ThemeProvider, useThemes } from "../../../src/context/theme" -import type { Plugin } from "@opencode-ai/plugin/tui" +import { ThemeProvider } from "../../../src/context/theme" import { ConfigProvider } from "../../../src/config" -import { - DiffViewerFileTree, - type DiffViewerFileTreeProps, -} from "../../../src/feature-plugins/system/diff-viewer-file-tree" +import { DiffViewerFileTree } from "../../../src/feature-plugins/system/diff-viewer-file-tree" import { TestTuiContexts } from "../../fixture/tui-environment" import { allExpandedFileTreeDirectories, @@ -19,44 +14,44 @@ import { } from "../../../src/feature-plugins/system/diff-viewer-file-tree-utils" describe("DiffViewerFileTree", () => { - test.skip("renders sorted hierarchical file rows", async () => { - const lines = visibleLines( - await renderFrame(() => ( - - )), - ) + test("defaults to text-line file icons and triangle folders with straight rails", async () => { + const frame = await renderFrame(() => ( + + )) - expect(lines).toEqual([ + expect(visibleLines(frame)).toEqual([ + "Files 0/5 reviewed", "▾ a", - "│ ├─ alpha.ts ?", - "│ └─ zeta.ts ?", - "├─ ▾ b", - "│ ├─ alpha.ts ?", - "│ └─ file.ts ?", + "│ ≡ alpha.ts ?", + "│ ≡ zeta.ts ?", + "▾ b", + "│ ≡ alpha.ts ?", + "│ ≡ file.ts ?", + "≡ z-file.ts ?", ]) + expect(frame).not.toMatch(/[├└─]/) }) test("keeps loading and error quiet while rendering an empty settled state", async () => { const loading = await renderFrame(() => ( - + )) const failed = await renderFrame(() => ( - + )) const empty = await renderFrame(() => ( - + )) expect(loading).not.toContain("Loading diff…") @@ -64,32 +59,64 @@ describe("DiffViewerFileTree", () => { expect(failed).not.toContain("Failed to load diff") expect(failed).not.toContain("No files") expect(empty).toContain("No files") + expect( + empty + .split("\n") + .find((line) => line.includes("No files")) + ?.indexOf("No files"), + ).toBe(2) }) - 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")! + test.each(["tree", "list"] as const)("%s layout uses two-cell horizontal sidebar padding", async (layout) => { + const frame = await renderFrame(() => ( + + )) + const lines = frame + .split("\n") + .slice(1) + .filter((line) => line.trim()) + expect(lines.find((line) => line.includes("Files"))?.indexOf("Files")).toBe(2) + const file = lines.find((line) => line.includes("README.md"))! + expect(file.indexOf("≡")).toBe(2) + expect(file.slice(29, 32)).toBe("M ") + expect(lines.every((line) => line.startsWith(" ") && line.slice(30, 32) === " ")).toBe(true) + }) - const focused = visibleLines( + test.each(["dark", "light"] as const)("full top padding keeps the heading one row down in %s mode", async (mode) => { + const frame = await renderFrame( + () => , + mode, + ) + const lines = frame.split("\n") + expect(lines[0].trim()).toBe("") + expect(lines[1].indexOf("Files")).toBe(2) + expect(lines.find((line) => line.includes("README.md"))?.indexOf("≡")).toBe(2) + }) + + test("does not render text markers for selected files", async () => { + const files = [{ file: "src/config/tui.ts" }, { file: "README.md" }] + const selected = visibleLines( await renderFrame(() => ( - + )), ) - const unfocused = visibleLines( - await renderFrame(() => ), + const unselected = visibleLines( + await renderFrame(() => ), ) - expect(focused).toContain("▾ src/config") - expect(unfocused).toContain("▾ src/config") - expect(focused.some((line) => line.includes("*"))).toBe(false) - expect(unfocused.some((line) => line.includes("*"))).toBe(false) + expect(selected).toContain("▾ src/config") + expect(unselected).toContain("▾ src/config") + expect(selected.some((line) => line.includes("*"))).toBe(false) + expect(unselected.some((line) => line.includes("*"))).toBe(false) }) test("renders collapsed and expanded directory rows", async () => { @@ -102,21 +129,15 @@ describe("DiffViewerFileTree", () => { expect( visibleLines( await renderFrame(() => ( - + )), ), - ).toEqual(["▸ src/config"]) + ).toEqual(["Files 0/2 reviewed", "▸ src/config", "≡ README.md ?"]) expect( visibleLines( await renderFrame(() => ( - { /> )), ), - ).toEqual(["▾ src/config", "│ └─ tui.ts ?"]) + ).toEqual(["Files 0/2 reviewed", "▾ src/config", "│ ≡ tui.ts ?", "≡ README.md ?"]) + }) + + test.each(["dark", "light"] as const)( + "file tabs distinguish duplicate basenames and review state in %s", + async (mode) => { + const frame = await renderFrame( + () => ( + + ), + mode, + ) + expect(visibleLines(frame)).toEqual(["Files 1/2 reviewed", "≡ sidebar.tsx ✓", "src", "≡ sidebar.tsx M", "test"]) + expect(frame).not.toMatch(/[│├└─]/) + }, + ) + + test("keeps rows quiet: straight rails, single status letters, no hooks or dots", async () => { + const frame = await renderFrame(() => ( + + )) + const lines = visibleLines(frame) + expect(lines).toEqual(["Files 0/3 reviewed", "▾ src", "│ ≡ a.ts A", "│ ≡ b.ts M", "▾ test", "│ ≡ a.ts D"]) + expect(frame).not.toMatch(/[├└─·]/) + expect(frame).not.toMatch(/[\uE000-\uF8FF]/) + }) + + test("file tabs align parent paths beneath marked filenames", async () => { + const frame = await renderFrame(() => ( + + )) + expect(visibleLines(frame)).toEqual(["Files 0/1 reviewed", "≡ sidebar.tsx M", "src"]) + const lines = frame.split("\n") + expect(lines.find((line) => line.includes("src"))?.indexOf("src")).toBe( + lines.find((line) => line.includes("sidebar.tsx"))?.indexOf("sidebar.tsx"), + ) + }) + + test("narrow collapsed chains drop whole leading segments", async () => { + const frame = await renderFrame(() => ( + + )) + const lines = visibleLines(frame) + expect(lines).toContain("▾ …/system") + expect(frame).not.toMatch(/\S+…\//) }) }) -function ThemedDiffViewerFileTree(props: Omit) { - return -} - -async function renderFrame(component: () => JSX.Element) { - const mounted = Promise.withResolvers() - const app = await testRender(() => withTheme(component, mounted.resolve), { width: 40, height: 10 }) +async function renderFrame(component: () => JSX.Element, mode: "dark" | "light" = "dark") { + const app = await testRender( + () => ( + + + + {component()} + + + + ), + { width: 40, height: 20 }, + ) try { - await mounted.promise - await app.renderOnce() - await app.renderOnce() - return app.captureCharFrame() + return await app.waitForFrame((frame) => frame.includes("Files")) } finally { app.renderer.destroy() } } -function withTheme(component: () => JSX.Element, onReady = () => {}) { - return ( - - - - {component()} - - - - ) -} - -function Ready(props: ParentProps<{ onReady: () => void }>) { - onMount(props.onReady) - return props.children -} - 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)) + .map((line) => line.trim().replace(/\s+/g, " ")) + .filter(Boolean) } diff --git a/packages/tui/test/cli/tui/diff-viewer-image.test.tsx b/packages/tui/test/cli/tui/diff-viewer-image.test.tsx new file mode 100644 index 00000000000..c9746db6277 --- /dev/null +++ b/packages/tui/test/cli/tui/diff-viewer-image.test.tsx @@ -0,0 +1,172 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { ImageRenderable, type Renderable } from "@opentui/core" +import { testRender } from "@opentui/solid" +import { createSignal, Show, type JSX } from "solid-js" +import { ConfigProvider } from "../../../src/config" +import { ThemeProvider } from "../../../src/context/theme" +import { Keymap } from "../../../src/context/keymap" +import { DialogProvider } from "../../../src/ui/dialog" +import { ToastProvider } from "../../../src/ui/toast" +import { DiffViewerImage, isDiffImageFile } from "../../../src/feature-plugins/system/diff-viewer-image" +import { diffImageFixture } from "../../fixture/diff-image" +import { emptyThemeSource } from "../../fixture/fixture" +import { TestTuiContexts } from "../../fixture/tui-environment" +import { createTuiResolvedConfig } from "../../fixture/tui-runtime" + +test("recognizes only supported image file extensions", () => { + for (const extension of ["png", "jpg", "jpeg", "webp", "gif", "PNG", "JPEG"]) { + expect(isDiffImageFile(`assets/preview.${extension}`)).toBe(true) + } + for (const file of ["assets/preview.svg", "preview.avif", "image.png.ts", "png", "image.png/file"]) { + expect(isDiffImageFile(file)).toBe(false) + } +}) + +test.each([ + { width: 40, height: 16, mode: "dark" as const }, + { width: 120, height: 40, mode: "light" as const }, +])("renders a real image at $width columns in $mode mode", async (options) => { + const pending = Promise.withResolvers() + const requested: string[] = [] + const app = await renderImage( + () => ( + { + requested.push(file) + return pending.promise + }} + /> + ), + options, + ) + try { + await app.waitForFrame((frame) => frame.includes("Loading image...")) + pending.resolve(diffImageFixture) + await app.waitForFrame((frame) => frame.includes("96 x 48")) + expect(requested).toEqual(["assets/landscape.png"]) + expect(app.captureCharFrame()).toContain("Working tree preview") + const image = findImage(app.renderer.root)! + expect(image.image?.width).toBe(96) + expect(image.image?.height).toBe(48) + expect(image.width).toBe(options.width - 2) + expect(image.height).toBe(Math.min(8, options.height / 4)) + expect(image.fit).toBe("fit") + expect(image.protocol).toBe("auto") + } finally { + app.renderer.destroy() + } +}) + +test("clicking a thumbnail opens the shared image modal and escape returns to the preview", async () => { + const app = await renderImage(() => diffImageFixture} />) + try { + await app.waitForFrame((frame) => frame.includes("Click to enlarge")) + const thumbnail = findImage(app.renderer.root)! + await app.mockMouse.click(thumbnail.x + Math.floor(thumbnail.width / 2), thumbnail.y + 1) + await app.waitForFrame((frame) => frame.includes("Image 1 of 1")) + const modal = app.renderer.root.findDescendantById("prompt-image-viewer-image") + expect(modal).toBeInstanceOf(ImageRenderable) + if (!(modal instanceof ImageRenderable)) throw new Error("Missing image modal") + await app.waitFor(() => modal.image?.width === 96) + expect(modal.image?.height).toBe(48) + expect(modal.height).toBeGreaterThan(thumbnail.height) + expect(app.captureCharFrame()).toContain("landscape.png") + + app.mockInput.pressEscape() + await app.waitForFrame((frame) => !frame.includes("Image 1 of 1")) + expect(findImage(app.renderer.root)).toBe(thumbnail) + expect(app.captureCharFrame()).toContain("Click to enlarge") + } finally { + app.renderer.destroy() + } +}) + +test("pending image reads are aborted when the source changes or the preview closes", async () => { + const pending = Promise.withResolvers() + const [file, setFile] = createSignal("a.png") + const [visible, setVisible] = createSignal(true) + const signals: AbortSignal[] = [] + const app = await renderImage(() => ( + + { + signals.push(signal) + return pending.promise + }} + /> + + )) + try { + await app.waitForFrame((frame) => frame.includes("Loading image...")) + expect(signals[0].aborted).toBe(false) + setFile("b.png") + await app.flush() + expect(signals).toHaveLength(2) + expect(signals[0].aborted).toBe(true) + expect(signals[1].aborted).toBe(false) + setVisible(false) + await app.flush() + expect(signals[1].aborted).toBe(true) + } finally { + app.renderer.destroy() + pending.resolve(diffImageFixture) + } +}) + +test.each(["fetch", "decode"])("recovers from a %s error when the file changes", async (failure) => { + const [file, setFile] = createSignal("broken.png") + const app = await renderImage(() => ( + { + if (file !== "broken.png") return diffImageFixture + if (failure === "fetch") throw new Error("Unavailable") + return new Uint8Array([1, 2, 3]) + }} + /> + )) + try { + await app.waitForFrame((frame) => + frame.includes(failure === "fetch" ? "Could not load image" : "Could not decode image"), + ) + expect(findImage(app.renderer.root)).toBeUndefined() + setFile("landscape.png") + await app.waitForFrame((frame) => frame.includes("96 x 48")) + expect(app.captureCharFrame()).toContain("Story fixture preview") + expect(app.captureCharFrame()).not.toContain("Could not") + expect(findImage(app.renderer.root)?.image?.width).toBe(96) + } finally { + app.renderer.destroy() + } +}) + +function renderImage( + component: () => JSX.Element, + options = { width: 80, height: 24, mode: "dark" as "dark" | "light" }, +) { + return testRender( + () => ( + + + + + + {component()} + + + + + + ), + { ...options, kittyKeyboard: true }, + ) +} + +function findImage(root: Renderable): ImageRenderable | undefined { + if (root instanceof ImageRenderable) return root + return root.getChildren().map(findImage).find(Boolean) +} diff --git a/packages/tui/test/cli/tui/diff-viewer.test.tsx b/packages/tui/test/cli/tui/diff-viewer.test.tsx index 7090a663c79..5fb643f3c32 100644 --- a/packages/tui/test/cli/tui/diff-viewer.test.tsx +++ b/packages/tui/test/cli/tui/diff-viewer.test.tsx @@ -1,6 +1,13 @@ /** @jsxImportSource @opentui/solid */ import { expect, test } from "bun:test" -import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core" +import { + BoxRenderable, + DiffRenderable, + ImageRenderable, + MouseButton, + type Renderable, + ScrollBoxRenderable, +} from "@opentui/core" import { testRender } from "@opentui/solid" import type { Context, @@ -20,9 +27,11 @@ import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer" import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { TestTuiContexts } from "../../fixture/tui-environment" import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" -import { DialogProvider } from "../../../src/ui/dialog" +import { DialogProvider, useDialog } from "../../../src/ui/dialog" +import { createDialogApi } from "../../../src/plugin/api" import { ToastProvider } from "../../../src/ui/toast" -import { createSignal } from "solid-js" +import { createSignal, Show } from "solid-js" +import { diffImageFixture } from "../../fixture/diff-image" test("closing the diff viewer returns to the route it opened from", async () => { const viewer = await renderDiffViewer([]) @@ -90,7 +99,7 @@ test("brackets navigate diff hunks", async () => { await viewer.app.waitForFrame((frame) => frame.includes("const first")) await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root))) await viewer.app.flush() - expect(countDiffs(viewer.app.renderer.root)).toBe(3) + expect(findDiffs(viewer.app.renderer.root)).toHaveLength(3) const scroll = findScrollBox(viewer.app.renderer.root)! const initial = scroll.scrollTop @@ -98,11 +107,13 @@ test("brackets navigate diff hunks", async () => { await viewer.app.renderOnce() const first = scroll.scrollTop expect(first).toBeGreaterThan(initial) + expect(findDiffs(viewer.app.renderer.root)[1].y).toBeGreaterThanOrEqual(scroll.viewport.y + 2) viewer.app.mockInput.pressKey("]") await viewer.app.renderOnce() const second = scroll.scrollTop expect(second).toBeGreaterThan(first) + expect(findDiffs(viewer.app.renderer.root)[2].y).toBeGreaterThanOrEqual(scroll.viewport.y + 2) viewer.app.mockInput.pressKey("[") await viewer.app.renderOnce() @@ -144,13 +155,964 @@ test("disabled diff keybinds have no component fallbacks", async () => { } }) +test.each(["added", "deleted"] as const)("%s files use full width even in split view", async (status) => { + const viewer = await renderDiffViewer( + [ + { + file: "src/new.txt", + status, + additions: status === "added" ? 1 : 0, + deletions: status === "deleted" ? 1 : 0, + patch: + status === "added" + ? "--- /dev/null\n+++ b/src/new.txt\n@@ -0,0 +1 @@\n+full width content\n" + : "--- a/src/new.txt\n+++ /dev/null\n@@ -1 +0,0 @@\n-full width content\n", + }, + ], + { width: 160 }, + ) + try { + await viewer.app.waitForFrame((frame) => frame.includes("full width content")) + const diffs = findDiffs(viewer.app.renderer.root) + expect(diffs).toHaveLength(1) + expect(diffs[0].view).toBe("unified") + expect(viewer.app.captureCharFrame()).not.toMatch(/[├└┌┐┬┴─]/) + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each([80, 160])("adapts navigation and modified patches at %i columns", async (width) => { + const viewer = await renderDiffViewer(hunkDiff, { width }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + expect(findDiffs(viewer.app.renderer.root)[0].view).toBe(width === 80 ? "unified" : "split") + expect(viewer.app.captureCharFrame().includes("0/1 reviewed")).toBe(width === 160) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("the first file title sits below full-height top padding with a blank row below", async () => { + const viewer = await renderDiffViewer(hunkDiff) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + const lines = viewer.app.captureCharFrame().split("\n") + const title = lines.findIndex((line) => line.includes("src/file.txt")) + expect(title).toBeGreaterThan(0) + const padding = viewer.app.captureSpans().lines[title - 1].spans.find((span) => span.width > 2)! + expect(lines[title - 1].trim()).toBe("") + expect(padding.bg).toEqual( + viewer.app.captureSpans().lines[title].spans.find((span) => span.text.includes("src/"))!.bg, + ) + expect(lines[title + 1].trim()).toBe("") + expect(lines[title + 2]).toContain("const first") + expect(lines[title]).toMatch(/\+3\s+-3/) + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each([100, 160])("shared pane edges align headings and stay fixed at %i columns", async (width) => { + const viewer = await renderDiffViewer(manyDiffs.slice(0, 6), { width, height: 40 }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const lines = viewer.app.captureCharFrame().split("\n") + const title = lines.findIndex((line) => line.includes("file00.txt")) + expect(lines.findIndex((line) => line.includes("Working tree") && line.includes("reviewed"))).toBe(title) + const frame = viewer.app.captureSpans() + const caps = frame.lines[title - 1].spans.filter((span) => span.width > 2) + expect(caps).toHaveLength(2) + caps.forEach((span) => expect(span.text.trim()).toBe("")) + expect(caps[0].bg).toEqual(frame.lines[title].spans.find((span) => span.text.includes("Working tree"))!.bg) + expect(caps[1].bg).toEqual(frame.lines[title].spans.find((span) => span.text.includes("file00.txt"))!.bg) + expect(title).toBe(1) + expect(viewer.app.captureCharFrame()).not.toContain("Diff working tree") + expect(lines[title + 1].slice(lines[title].indexOf("file00.txt")).trim()).toBe("") + expect(lines[title + 2]).toContain("const first") + + const scroll = findScrollBox(viewer.app.renderer.root)! + scroll.scrollTo(5) + await viewer.app.flush() + expect(viewer.app.captureCharFrame().split("\n")[title - 1]).toBe(lines[title - 1]) + expect(viewer.app.captureCharFrame().split("\n")[title]).toContain("file00.txt") + + viewer.commands.get("diff.next_file")!.run() + await viewer.app.flush() + expect(viewer.app.captureCharFrame().split("\n")[title - 1]).toBe(lines[title - 1]) + expect(viewer.app.captureCharFrame().split("\n")[title]).toContain("file01.txt") + viewer.commands.get("diff.single_patch")!.run() + await viewer.app.flush() + expect(viewer.app.captureCharFrame().split("\n")[title]).toContain("file01.txt") + + viewer.app.resize(80, 30) + await viewer.app.flush() + expect(viewer.app.renderer.root.findDescendantById("diff-tree-top-edge")).toBeUndefined() + expect(viewer.app.renderer.root.findDescendantById("diff-patch-top-edge")).toBeDefined() + expect(viewer.app.captureCharFrame().split("\n")[title]).toContain("file01.txt") + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each(["dark", "light"] as const)("the pane edge matches the visible reviewed card in %s mode", async (mode) => { + const viewer = await renderDiffViewer( + Array.from({ length: 4 }, (_, index) => ({ ...hunkDiff[0], file: `src/file${index}.txt` })), + { width: 160, height: 18, mode }, + ) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const expectEdge = (file: string, collapsed = false) => { + const frame = viewer.app.captureSpans() + const edge = frame.lines[scroll.viewport.y - 1].spans.find((span) => span.width === scroll.viewport.width)! + const page = frame.lines[scroll.viewport.y - 1].spans.at(-1)!.bg + const title = frame.lines[scroll.viewport.y].spans.find((span) => span.text.includes(file))! + expect(title, viewer.app.captureCharFrame()).toBeDefined() + expect(edge.text.trim()).toBe("") + expect(edge.bg).toEqual(title.bg) + expect(edge.bg).not.toEqual(page) + expect(frame.lines[0].spans[0].bg).toEqual( + frame.lines[1].spans.find((span) => span.text.includes("Working tree"))!.bg, + ) + const bottom = frame.lines[scroll.viewport.y + 1].spans.findLast((span) => span.text.includes("▀")) + if (!collapsed) { + expect(bottom).toBeUndefined() + return + } + expect(bottom?.text.trim()).toBe("▀".repeat(scroll.viewport.width)) + expect(bottom?.fg).toEqual(edge.bg) + expect(bottom?.bg).toEqual(page) + } + expectEdge("file0.txt") + viewer.app.mockInput.pressKey("m") + await viewer.app.flush() + expectEdge("file0.txt", true) + viewer.app.mockInput.pressKey("n") + await viewer.app.flush() + expectEdge("file1.txt") + viewer.app.mockInput.pressKey("m") + await viewer.app.flush() + expectEdge("file1.txt", true) + viewer.app.mockInput.pressKey("n") + await viewer.app.flush() + expectEdge("file2.txt") + + // Scrolling back must follow the visible card, not the selected unreviewed file. + scroll.scrollTo(0) + await viewer.app.flush() + expectEdge("file0.txt", true) + const second = viewer.app.renderer.root.findDescendantById("diff-file-header-1")! + const frame = viewer.app.captureSpans() + expect(frame.lines[second.y - 1].spans.findLast((span) => span.text.includes("▄"))!.fg).toEqual( + frame.lines[second.y].spans.find((span) => span.text.includes("file1.txt"))!.bg, + ) + + viewer.commands.get("diff.single_patch")!.run() + await viewer.app.flush() + expectEdge("file0.txt", true) + viewer.app.mockInput.pressKey("m") + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + expectEdge("file0.txt") + } finally { + viewer.app.renderer.destroy() + } +}) + +test("the sidebar holds a compact help hint and the patch pane reaches the screen bottom", async () => { + const viewer = await renderDiffViewer(hunkDiff, { width: 160, height: 40, kittyKeyboard: true }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const hint = viewer.app.renderer.root.findDescendantById("diff-help-shortcut")! + expect(scroll.viewport.y + scroll.viewport.height).toBe(40) + expect(hint.x).toBe(2) + expect(hint.y).toBe(37) + expect( + findScrollBox(viewer.app.renderer.root, false)!.parent!.y + + findScrollBox(viewer.app.renderer.root, false)!.parent!.height, + ).toBe(40) + expect(viewer.app.captureCharFrame()).not.toContain("n/p files") + expect(viewer.app.captureCharFrame()).not.toContain("tab focus") + expect(viewer.app.captureCharFrame()).toContain("? help") + expect(viewer.app.captureCharFrame()).not.toContain("next file") + expect(viewer.app.captureCharFrame()).not.toContain("next hunk") + expect(viewer.app.captureCharFrame()).not.toContain("half page") + viewer.app.mockInput.pressTab() + await viewer.app.renderOnce() + expect(viewer.app.captureCharFrame()).toContain("? help") + + viewer.app.mockInput.pressKey("?") + await viewer.app.waitForFrame((frame) => frame.includes("Diff shortcuts")) + expect(viewer.app.captureCharFrame()).toContain("Next file") + expect(viewer.app.captureCharFrame()).toContain("Half page") + expect(viewer.app.captureCharFrame()).toContain("Split / unified") + expect(viewer.app.captureCharFrame()).toContain("alt+↓") + expect(viewer.app.captureCharFrame()).toContain("Review + collapse / reopen") + expect(viewer.app.captureCharFrame()).toContain("right-click") + expect(viewer.app.captureCharFrame()).not.toContain("Focus files") + expect(viewer.app.captureCharFrame()).not.toContain("Description") + viewer.app.mockInput.pressEscape() + await viewer.app.waitForFrame((frame) => !frame.includes("Diff shortcuts")) + expect(viewer.current()).toEqual(expect.objectContaining({ type: "plugin", name: "diff" })) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("compact gutter help leaves the full patch viewport available when the sidebar is hidden", async () => { + const viewer = await renderDiffViewer(hunkDiff, { width: 160, height: 24, kittyKeyboard: true }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + viewer.app.mockInput.pressKey("b") + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const hint = viewer.app.renderer.root.findDescendantById("diff-help-shortcut")! + expect(hint.y).toBe(1) + expect(hint.x).toBe(159) + expect(scroll.viewport.y).toBe(1) + expect(scroll.viewport.y + scroll.viewport.height).toBe(24) + await viewer.app.mockMouse.click(hint.x, hint.y) + await viewer.app.waitForFrame((frame) => frame.includes("Diff shortcuts")) + expect(viewer.app.renderer.getSelection()).toBeNull() + viewer.app.mockInput.pressEscape() + await viewer.app.waitForFrame((frame) => !frame.includes("Diff shortcuts")) + + viewer.app.mockInput.pressKey("b") + await viewer.app.flush() + expect(viewer.app.renderer.root.findDescendantById("diff-help-shortcut")!.y).toBe(21) + viewer.app.resize(80, 24) + await viewer.app.flush() + expect(viewer.app.renderer.root.findDescendantById("diff-help-shortcut")!.y).toBe(1) + expect(viewer.app.renderer.root.findDescendantById("diff-help-shortcut")!.x).toBe(79) + expect(scroll.viewport.y + scroll.viewport.height).toBe(24) + expect(viewer.app.captureCharFrame()).not.toContain("? help") + expect(viewer.app.captureCharFrame().split("\n")[1].at(-1)).toBe("?") + viewer.app.mockInput.pressKey("d") + await viewer.app.waitForFrame((frame) => frame.includes("Switch source")) + viewer.app.mockInput.pressEscape() + await viewer.app.waitForFrame((frame) => !frame.includes("Switch source")) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("compact help scrolls at a narrow size and reflects customized bindings", async () => { + const viewer = await renderDiffViewer(hunkDiff, { + width: 50, + height: 20, + kittyKeyboard: true, + keybinds: { "diff.next_file": "ctrl+n", "diff.previous_file": "ctrl+p", "diff.close": "x" }, + }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + viewer.app.mockInput.pressKey("?") + await viewer.app.waitForFrame((frame) => frame.includes("Diff shortcuts")) + const scroll = viewer.app.renderer.root.findDescendantById("diff-help-scroll") + if (!(scroll instanceof ScrollBoxRenderable)) throw new Error("Missing help scrollbox") + expect(scroll.width).toBeLessThanOrEqual(46) + expect(viewer.app.captureCharFrame()).toMatch(/ctrl\+n\s+Next file/) + expect(viewer.app.captureCharFrame()).not.toContain("alt+↓") + scroll.scrollTo(scroll.scrollHeight) + await viewer.app.flush() + expect(viewer.app.captureCharFrame()).toContain("Show / hide file tree") + expect(viewer.app.captureCharFrame()).not.toContain("Focus files") + expect(viewer.app.captureCharFrame()).toMatch(/x\s+Close diff viewer/) + viewer.app.mockInput.pressEscape() + await viewer.app.waitForFrame((frame) => !frame.includes("Diff shortcuts")) + expect(viewer.current()).toEqual(expect.objectContaining({ type: "plugin", name: "diff" })) + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each([ + { key: "?", shift: false }, + { key: "?", shift: true }, + { key: "/", shift: true }, +])("the viewer opens help from $key with shift=$shift", async (input) => { + const viewer = await renderDiffViewer(hunkDiff, { width: 160, height: 40, kittyKeyboard: true }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first") && frame.includes("? help")) + viewer.app.mockInput.pressKey(input.key, { shift: input.shift }) + await viewer.app.waitForFrame((frame) => frame.includes("Diff shortcuts")) + expect(viewer.app.captureCharFrame()).toContain("Next file") + viewer.app.mockInput.pressEscape() + await viewer.app.waitForFrame((frame) => !frame.includes("Diff shortcuts")) + expect(viewer.app.captureCharFrame()).toContain("const first") + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each([80, 160])("file titles stick to the viewport and hand off while scrolling at %i columns", async (width) => { + const viewer = await renderDiffViewer( + Array.from({ length: 4 }, (_, index) => ({ ...hunkDiff[0], file: `src/file${index}.txt` })), + { width, height: 18 }, + ) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const first = viewer.app.renderer.root.findDescendantById("diff-file-header-0") + const second = viewer.app.renderer.root.findDescendantById("diff-file-header-1") + if (!(first instanceof BoxRenderable) || !(second instanceof BoxRenderable)) throw new Error("Missing file titles") + const next = second.y - scroll.content.y + + scroll.scrollTo(5) + await viewer.app.flush() + expect(first.y).toBe(scroll.viewport.y) + expect(viewer.app.captureCharFrame().split("src/file0.txt")).toHaveLength(2) + + viewer.app.resize(width === 160 ? 80 : 160, 22) + await viewer.app.flush() + expect(first.y).toBe(scroll.viewport.y) + viewer.app.resize(width, 18) + await viewer.app.flush() + expect(first.y).toBe(scroll.viewport.y) + + if (width === 160) { + viewer.app.mockInput.pressTab() + await viewer.app.renderOnce() + expect(first.y).toBe(scroll.viewport.y) + expect(viewer.app.captureCharFrame()).toContain("src/file0.txt") + viewer.app.mockInput.pressTab() + } + + scroll.scrollTo(next + 3) + await viewer.app.flush() + expect(second.y).toBe(scroll.viewport.y) + expect(first.y + first.height).toBeLessThanOrEqual(scroll.viewport.y) + expect(viewer.app.captureCharFrame().split("src/file1.txt")).toHaveLength(2) + expect(viewer.app.captureCharFrame()).not.toContain("src/file0.txt") + + const background = second.backgroundColor + viewer.commands.get("diff.mark_reviewed")!.run() + await viewer.app.flush() + expect(second.backgroundColor).not.toEqual(background) + expect(second.y).toBe(scroll.viewport.y) + + scroll.scrollTo(0) + await viewer.app.flush() + expect(first.translateY).toBe(0) + expect(first.y).toBe(scroll.viewport.y) + + viewer.commands.get("diff.single_patch")!.run() + await viewer.app.flush() + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-1")).toBeUndefined() + const single = viewer.app.renderer.root.findDescendantById("diff-file-header-0") + if (!(single instanceof BoxRenderable)) throw new Error("Missing single file title") + scroll.scrollTo(5) + await viewer.app.flush() + expect(single.y).toBe(scroll.viewport.y) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("split diff filler keeps the full card background", async () => { + const viewer = await renderDiffViewer( + [ + { + file: ".gitignore", + status: "modified", + additions: 1, + deletions: 0, + patch: "--- a/.gitignore\n+++ b/.gitignore\n@@ -1 +1,2 @@\n node_modules/\n+artifacts/\n", + }, + ], + { width: 160 }, + ) + try { + await viewer.app.waitForFrame((frame) => frame.includes("artifacts/")) + const diff = findDiffs(viewer.app.renderer.root)[0] + const frame = viewer.app.captureSpans() + const backgrounds = (row: number) => + frame.lines[row].spans.flatMap((span) => Array.from({ length: span.width }, () => span.bg)) + expect(backgrounds(diff.y + 1).slice(diff.x, diff.x + Math.floor(diff.width / 2))).toEqual( + Array.from({ length: Math.floor(diff.width / 2) }, () => backgrounds(diff.y - 1)[diff.x]), + ) + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each(["patch", "image", "fallback"] as const)( + "reviewing collapses a %s body and unmarking reopens it", + async (kind) => { + const file = + kind === "patch" + ? hunkDiff[0] + : { + file: kind === "image" ? "assets/preview.png" : "bun.lock", + status: "modified", + additions: 0, + deletions: 0, + } + const content = kind === "patch" ? "const first" : kind === "image" ? "96 x 48" : "No patch available" + const viewer = await renderDiffViewer([file], { width: 80, height: kind === "patch" ? 16 : 24 }) + try { + await viewer.app.waitForFrame((frame) => frame.includes(content)) + const scroll = findScrollBox(viewer.app.renderer.root)! + if (kind === "patch") { + scroll.scrollTo(4) + await viewer.app.flush() + expect(scroll.scrollTop).toBeGreaterThan(0) + } + viewer.app.mockInput.pressKey("m") + await viewer.app.flush() + const header = viewer.app.renderer.root.findDescendantById("diff-file-header-0") + if (!(header instanceof BoxRenderable)) throw new Error("Missing reviewed file title") + expect(header.parent?.height).toBe(header.height) + expect(header.height).toBe(2) + expect(viewer.app.captureCharFrame().split("\n")[header.y + 1].trim()).toBe("▀".repeat(header.width)) + expect(header.y).toBe(scroll.viewport.y) + expect(viewer.app.captureCharFrame()).toContain(file.file) + expect(viewer.app.captureCharFrame()).toContain("✓") + expect(viewer.app.captureCharFrame()).not.toContain(content) + expect(findDiffs(viewer.app.renderer.root)).toHaveLength(0) + + viewer.app.mockInput.pressKey("m") + await viewer.app.waitForFrame((frame) => frame.includes(content)) + await viewer.app.flush() + expect(header.parent!.height).toBeGreaterThan(header.height) + expect(viewer.app.captureCharFrame().split("\n")[header.y + 1].trim()).toBe("") + expect(viewer.app.captureCharFrame()).not.toContain("✓") + if (kind === "patch") { + viewer.app.mockInput.pressKey("]") + await viewer.app.flush() + expect(scroll.scrollTop).toBeGreaterThan(0) + } + } finally { + viewer.app.renderer.destroy() + } + }, +) + +test("reviewing after scrolling targets the visible file rather than the initial file", async () => { + const viewer = await renderDiffViewer( + [ + { ...hunkDiff[0], file: "src/file0.txt" }, + { ...hunkDiff[0], file: "src/file1.txt" }, + ], + { width: 160, height: 16 }, + ) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + scroll.scrollTo(scroll.scrollTop + findDiffs(viewer.app.renderer.root)[3].y - scroll.viewport.y) + await viewer.app.renderOnce() + viewer.commands.get("diff.mark_reviewed")!.run() + await viewer.app.waitForFrame((frame) => /file1\.txt\s+✓/.test(frame)) + expect(viewer.app.captureCharFrame()).not.toMatch(/file0\.txt\s+✓/) + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each([80, 160])( + "single-file review advances, but reopening and the last file stay put at %i columns", + async (width) => { + const viewer = await renderDiffViewer( + Array.from({ length: 3 }, (_, index) => ({ ...hunkDiff[0], file: `src/file${index}.txt` })), + { width, height: 24, kittyKeyboard: true }, + ) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + viewer.app.mockInput.pressKey("s") + await viewer.app.flush() + if (width === 160) viewer.app.mockInput.pressTab() + viewer.app.mockInput.pressKey("m") + await viewer.app.flush() + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-0")).toBeUndefined() + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-1")).toBeDefined() + expect(viewer.app.captureCharFrame()).toContain("const first") + + viewer.app.mockInput.pressKey("p") + await viewer.app.flush() + expect(findDiffs(viewer.app.renderer.root)).toHaveLength(0) + viewer.app.mockInput.pressKey("m") + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-0")).toBeDefined() + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-1")).toBeUndefined() + + viewer.app.mockInput.pressKey("n") + await viewer.app.flush() + viewer.app.mockInput.pressKey("m") + await viewer.app.flush() + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-2")).toBeDefined() + viewer.app.mockInput.pressKey("m") + await viewer.app.flush() + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-2")).toBeDefined() + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-0")).toBeUndefined() + expect(findDiffs(viewer.app.renderer.root)).toHaveLength(0) + + viewer.app.mockInput.pressKey("?") + await viewer.app.waitForFrame((frame) => frame.includes("Review + next / reopen")) + } finally { + viewer.app.renderer.destroy() + } + }, +) + +test.each([false, true])("Alt+arrows navigate files rather than session tabs (after Tab: %s)", async (afterTab) => { + let tabChanges = 0 + const viewer = await renderDiffViewer(manyDiffs, { + width: 160, + height: 24, + kittyKeyboard: true, + onSessionTab: () => tabChanges++, + }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + if (afterTab) viewer.app.mockInput.pressTab() + viewer.app.mockInput.pressKey("down", { meta: true }) + await viewer.app.flush() + expect(viewer.app.captureCharFrame().split("\n")[scroll.viewport.y]).toContain("file01.txt") + viewer.app.mockInput.pressKey("up", { meta: true }) + await viewer.app.flush() + expect(scroll.scrollTop).toBe(0) + expect(tabChanges).toBe(0) + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each([ + { target: "tree", mode: "dark" }, + { target: "heading", mode: "dark" }, + { target: "tree", mode: "light" }, + { target: "heading", mode: "light" }, +] as const)("the $target context menu completes the clicked file without selecting it in $mode mode", async (input) => { + const viewer = await renderDiffViewer(manyDiffs.slice(0, 3), { + width: 160, + height: 40, + kittyKeyboard: true, + mode: input.mode, + }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const node = () => + viewer.app.renderer.root.findDescendantById(input.target === "tree" ? "diff-file-row-1" : "diff-file-header-1")! + await viewer.app.mockMouse.click(node().x + 4, node().y, MouseButton.RIGHT) + await viewer.app.waitForFrame((frame) => frame.includes("Mark complete")) + expect(scroll.scrollTop).toBe(0) + expect(viewer.app.captureCharFrame()).toContain("0/3 reviewed") + viewer.app.mockInput.pressKey("n") + viewer.app.mockInput.pressKey("m") + await viewer.app.flush() + expect(scroll.scrollTop).toBe(0) + expect(viewer.app.captureCharFrame()).toContain("0/3 reviewed") + const menu = viewer.app.renderer.root.findDescendantById("diff-file-menu")! + expect( + viewer.app.captureSpans().lines[menu.y].spans.find((span) => span.text.includes("Mark complete"))!.bg, + ).not.toEqual(viewer.app.captureSpans().lines[1].spans.find((span) => span.text.includes("Working tree"))!.bg) + await viewer.app.mockMouse.click(menu.x + 1, menu.y) + await viewer.app.waitForFrame((frame) => frame.includes("1/3 reviewed")) + expect(viewer.app.captureCharFrame()).toMatch(/file01\.txt\s+✓/) + expect(viewer.app.captureCharFrame()).not.toMatch(/file00\.txt\s+✓/) + expect(viewer.app.renderer.root.findDescendantById("diff-file-menu")).toBeUndefined() + expect(findDiffs(viewer.app.renderer.root)).toHaveLength(6) + expect(scroll.scrollTop).toBe(0) + + await viewer.app.mockMouse.click(node().x + 4, node().y, MouseButton.RIGHT) + await viewer.app.waitForFrame((frame) => frame.includes("Mark incomplete")) + viewer.app.mockInput.pressEnter() + await viewer.app.waitForFrame((frame) => frame.includes("0/3 reviewed")) + expect(findDiffs(viewer.app.renderer.root)).toHaveLength(9) + expect(scroll.scrollTop).toBe(0) + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each(["escape", "outside"] as const)( + "a clamped file menu dismisses with %s without closing the viewer", + async (dismiss) => { + const viewer = await renderDiffViewer(hunkDiff, { width: 80, height: 16, kittyKeyboard: true }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const header = viewer.app.renderer.root.findDescendantById("diff-file-header-0")! + await viewer.app.mockMouse.click(header.x + header.width - 1, header.y, MouseButton.RIGHT) + await viewer.app.waitForFrame((frame) => frame.includes("Mark complete")) + const menu = viewer.app.renderer.root.findDescendantById("diff-file-menu")! + expect(menu.x).toBeGreaterThanOrEqual(0) + expect(menu.x + menu.width).toBeLessThanOrEqual(80) + expect(menu.y + menu.height).toBeLessThanOrEqual(16) + if (dismiss === "escape") viewer.app.mockInput.pressEscape() + if (dismiss === "outside") await viewer.app.mockMouse.click(1, 1) + await viewer.app.waitForFrame((frame) => !frame.includes("Mark complete")) + expect(viewer.current()).toEqual(expect.objectContaining({ type: "plugin", name: "diff" })) + expect(findDiffs(viewer.app.renderer.root)).toHaveLength(3) + viewer.app.mockInput.pressKey("j") + await viewer.app.flush() + expect(findScrollBox(viewer.app.renderer.root)!.scrollTop).toBe(1) + } finally { + viewer.app.renderer.destroy() + } + }, +) + +test("single-file menu actions advance only when completing the current file", async () => { + const viewer = await renderDiffViewer(manyDiffs.slice(0, 3), { width: 160, height: 24, kittyKeyboard: true }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + viewer.app.mockInput.pressKey("s") + await viewer.app.flush() + const other = viewer.app.renderer.root.findDescendantById("diff-file-row-2")! + await viewer.app.mockMouse.click(other.x + 4, other.y, MouseButton.RIGHT) + await viewer.app.waitForFrame((frame) => frame.includes("Mark complete")) + viewer.app.mockInput.pressEnter() + await viewer.app.waitForFrame((frame) => frame.includes("1/3 reviewed")) + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-0")).toBeDefined() + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-2")).toBeUndefined() + expect(viewer.app.captureCharFrame()).toMatch(/file02\.txt\s+✓/) + const current = viewer.app.renderer.root.findDescendantById("diff-file-header-0")! + await viewer.app.mockMouse.click(current.x + 4, current.y, MouseButton.RIGHT) + await viewer.app.waitForFrame((frame) => frame.includes("Mark complete")) + viewer.app.mockInput.pressEnter() + await viewer.app.waitForFrame((frame) => frame.includes("2/3 reviewed") && frame.includes("const first")) + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-0")).toBeUndefined() + expect(viewer.app.renderer.root.findDescendantById("diff-file-header-1")).toBeDefined() + expect(viewer.app.captureCharFrame()).toContain("const first") + } finally { + viewer.app.renderer.destroy() + } +}) + +test("image previews use the diff session's filesystem location", async () => { + const viewer = await renderDiffViewer([ + { file: "assets/mock image.png", status: "modified", additions: 0, deletions: 0 }, + ]) + try { + await viewer.app.waitFor(() => viewer.imageReadInput() !== undefined) + expect(viewer.imageReadInput()).toEqual({ path: "assets/mock image.png", location: { directory: "/repo/session" } }) + await viewer.app.waitForFrame((frame) => frame.includes("96 x 48")) + const lines = viewer.app.captureCharFrame().split("\n") + const row = lines.findIndex((line) => line.includes("Working tree preview")) + const top = lines[lines.findIndex((line) => line.includes("assets/mock image.png")) - 1] + const header = viewer.app.renderer.root.findDescendantById("diff-file-header-0")! + const background = viewer.app.captureSpans().lines[row].spans.find((span) => span.text.includes("Working tree"))!.bg + const edges = viewer.app + .captureSpans() + .lines[row + 2].spans.flatMap((span) => Array.from({ length: span.width }, () => span.bg)) + expect(top.trim()).toBe("") + expect(edges[header.x]).toEqual(background) + expect(edges[header.x + header.width - 1]).toEqual(background) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("single-file images load once per file and cancel reads when their preview is removed", async () => { + const reads: { file: string; signal: AbortSignal }[] = [] + const viewer = await renderDiffViewer( + ["a.png", "b.png"].map((file) => ({ file, status: "modified", additions: 0, deletions: 0 })), + { + single: true, + readImage: async (file, signal) => { + reads.push({ file, signal }) + return diffImageFixture + }, + }, + ) + try { + await viewer.app.waitForFrame((frame) => frame.includes("96 x 48")) + expect(reads.map((read) => read.file)).toEqual(["a.png"]) + viewer.app.mockInput.pressKey("n") + await viewer.app.waitForFrame((frame) => frame.includes("b.png") && frame.includes("96 x 48")) + await viewer.app.flush() + expect(reads.map((read) => read.file)).toEqual(["a.png", "b.png"]) + expect(reads[0].signal.aborted).toBe(true) + viewer.app.mockInput.pressKey("j") + await viewer.app.flush() + expect(reads.map((read) => read.file)).toEqual(["a.png", "b.png"]) + viewer.app.mockInput.pressKey("m") + await viewer.app.flush() + expect(reads[1].signal.aborted).toBe(true) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("reviewing a mouse-scrolled past selection keeps the visible file in place", async () => { + const viewer = await renderDiffViewer(manyDiffs.slice(0, 5), { width: 160, height: 24, kittyKeyboard: true }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + viewer.app.mockInput.pressKey("n") + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const header = viewer.app.renderer.root.findDescendantById("diff-file-header-2")! + scroll.scrollTo(scroll.scrollTop + header.y - scroll.viewport.y + 2) + await viewer.app.flush() + await viewer.app.mockMouse.scroll(scroll.x + 5, scroll.viewport.y + 5, "down") + await viewer.app.flush() + const before = scroll.scrollTop + const row = viewer.app.renderer.root.findDescendantById("diff-file-row-1")! + await viewer.app.mockMouse.click(row.x + 4, row.y, MouseButton.RIGHT) + await viewer.app.waitForFrame((frame) => frame.includes("Mark complete")) + viewer.app.mockInput.pressEnter() + await viewer.app.flush() + expect(viewer.app.captureCharFrame().split("\n")[scroll.viewport.y]).toContain("file02.txt") + expect(scroll.scrollTop).toBeLessThan(before) + viewer.app.mockInput.pressKey("m") + await viewer.app.waitForFrame((frame) => /file02\.txt\s+✓/.test(frame)) + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each(["patch", "image"] as const)( + "completing and reopening an earlier %s preserves the reading position", + async (kind) => { + const pending = Promise.withResolvers() + let reads = 0 + const viewer = await renderDiffViewer( + [ + ...(kind === "image" ? [{ file: "a.png", status: "modified", additions: 0, deletions: 0 }] : []), + ...manyDiffs.slice(0, 4), + ], + { + width: 160, + height: 24, + kittyKeyboard: true, + readImage: () => (++reads === 1 ? Promise.resolve(diffImageFixture) : pending.promise), + }, + ) + try { + await viewer.app.waitForFrame((frame) => frame.includes(kind === "image" ? "96 x 48" : "const first")) + viewer.app.mockInput.pressKey("n") + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + scroll.scrollBy(4) + await viewer.app.flush() + const patchFrame = () => + viewer.app + .captureCharFrame() + .split("\n") + .map((line) => line.slice(scroll.x)) + .join("\n") + const before = patchFrame() + const row = viewer.app.renderer.root.findDescendantById("diff-file-row-0")! + for (const label of ["Mark complete", "Mark incomplete"]) { + await viewer.app.mockMouse.click(row.x + 4, row.y, MouseButton.RIGHT) + await viewer.app.waitForFrame((frame) => frame.includes(label)) + viewer.app.mockInput.pressEnter() + await viewer.app.flush() + expect(patchFrame()).toBe(before) + } + if (kind === "patch") return + pending.resolve(diffImageFixture) + await viewer.app.waitFor(() => { + const image = viewer.app.renderer.root.findDescendantById("diff-image-a.png") + return image instanceof ImageRenderable && image.image?.width === 96 + }) + await viewer.app.flush() + expect(patchFrame()).toBe(before) + } finally { + pending.resolve(diffImageFixture) + viewer.app.renderer.destroy() + } + }, +) + +test.each([ + { file: "bun.lock", status: "modified" as const, message: "No patch available for this file." }, + { file: "assets/old.png", status: "deleted" as const, message: "Deleted image." }, +])("pads $file fallback messages inside the card without reading images", async (input) => { + const viewer = await renderDiffViewer([{ file: input.file, status: input.status, additions: 0, deletions: 0 }]) + try { + await viewer.app.waitForFrame((frame) => frame.includes(input.message)) + expect(viewer.imageReadInput()).toBeUndefined() + const header = viewer.app.renderer.root.findDescendantById("diff-file-header-0") + if (!(header instanceof BoxRenderable)) throw new Error("Missing file title") + const lines = viewer.app.captureCharFrame().split("\n") + const title = lines.findIndex((line) => line.includes(input.file)) + const row = lines.findIndex((line) => line.includes(input.message)) + expect(lines[row].indexOf(input.message)).toBe(lines[title].indexOf(input.file)) + expect(lines[row + 1].trim()).toBe("") + const padding = viewer.app + .captureSpans() + .lines[row + 1].spans.flatMap((span) => Array.from({ length: span.width }, () => span.bg)) + expect(padding[header.x]).toEqual(header.backgroundColor) + expect(padding[header.x + header.width - 1]).toEqual(header.backgroundColor) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("Ctrl+D and Ctrl+U scroll the patch viewport by half a page, without closing", async () => { + const viewer = await renderDiffViewer(manyDiffs, { width: 160, height: 24 }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + viewer.app.mockInput.pressKey("d", { ctrl: true }) + await viewer.app.renderOnce() + expect(viewer.current().type).toBe("plugin") + expect(scroll.scrollTop).toBe(Math.floor(scroll.viewport.height / 2)) + viewer.app.mockInput.pressKey("u", { ctrl: true }) + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(0) + viewer.app.mockInput.pressKey("f", { ctrl: true }) + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(scroll.viewport.height) + viewer.app.mockInput.pressKey("b", { ctrl: true }) + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(0) + } finally { + viewer.app.renderer.destroy() + } +}) + +test.each([20, 32])("Tab and file clicks never redirect scrolling keys into the tree at %i rows", async (height) => { + const viewer = await renderDiffViewer(manyDiffs, { width: 160, height, keybinds: { "diff.switch_focus": "tab" } }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const tree = findScrollBox(viewer.app.renderer.root, false)! + const row = viewer.app.renderer.root.findDescendantById("diff-file-row-0")! + expect(viewer.commands.has("diff.switch_focus")).toBe(false) + await viewer.app.mockMouse.click(row.x + 4, row.y) + viewer.app.mockInput.pressTab() + viewer.app.mockInput.pressKey("j") + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(1) + expect(tree.scrollTop).toBe(0) + viewer.app.mockInput.pressKey("k") + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(0) + viewer.app.mockInput.pressArrow("down") + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(1) + viewer.app.mockInput.pressArrow("up") + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(0) + viewer.app.mockInput.pressKey("d", { ctrl: true }) + await viewer.app.renderOnce() + const half = Math.floor(scroll.viewport.height / 2) + expect(viewer.current().type).toBe("plugin") + expect(scroll.scrollTop).toBe(half) + expect(tree.scrollTop).toBe(0) + viewer.app.mockInput.pressKey("u", { ctrl: true }) + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(0) + viewer.app.mockInput.pressKey("f", { ctrl: true }) + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(scroll.viewport.height) + expect(tree.scrollTop).toBe(0) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("folders are mouse controlled and n/p reveal files inside collapsed folders", async () => { + const viewer = await renderDiffViewer( + [ + { ...hunkDiff[0], file: "src/a/first.txt" }, + { ...hunkDiff[0], file: "src/b/second.txt" }, + { ...hunkDiff[0], file: "test/third.txt" }, + ], + { width: 160, height: 28 }, + ) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const treeFrame = () => + viewer.app + .captureCharFrame() + .split("\n") + .map((line) => line.slice(0, 40)) + .join("\n") + const folder = viewer.app.renderer.root.findDescendantById("diff-folder-row-0")! + await viewer.app.mockMouse.click(folder.x + 1, folder.y, MouseButton.RIGHT) + expect(viewer.app.renderer.root.findDescendantById("diff-file-menu")).toBeUndefined() + expect(treeFrame()).toContain("first.txt") + await viewer.app.mockMouse.click(folder.x + 1, folder.y) + await viewer.app.renderOnce() + expect(treeFrame()).toContain("▸ src") + expect(treeFrame()).not.toContain("first.txt") + viewer.app.mockInput.pressTab() + viewer.app.mockInput.pressKey("l") + viewer.app.mockInput.pressKey("}") + viewer.app.mockInput.pressKey("h") + await viewer.app.renderOnce() + expect(treeFrame()).toContain("▸ src") + viewer.app.mockInput.pressKey("n") + await viewer.app.flush() + expect(treeFrame()).toContain("▾ src") + expect(treeFrame()).toContain("second.txt") + const scroll = findScrollBox(viewer.app.renderer.root)! + expect(viewer.app.captureCharFrame().split("\n")[scroll.viewport.y]).toContain("src/b/second.txt") + viewer.app.mockInput.pressKey("p") + await viewer.app.flush() + expect(scroll.scrollTop).toBe(0) + } finally { + viewer.app.renderer.destroy() + } +}) + +test("n/p jump between files and gg/G always navigate the diff", async () => { + const viewer = await renderDiffViewer(manyDiffs, { width: 160, height: 24 }) + try { + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const scroll = findScrollBox(viewer.app.renderer.root)! + const row = viewer.app.renderer.root.findDescendantById("diff-file-row-0")! + viewer.app.mockInput.pressKey("n") + await viewer.app.flush() + expect(viewer.app.renderer.root.findDescendantById("diff-file-row-0")).toBe(row) + expect(scroll.scrollTop).toBeGreaterThan(0) + expect(viewer.app.captureCharFrame()).toContain("file01.txt") + viewer.app.mockInput.pressKey("p") + await viewer.app.flush() + expect(viewer.app.renderer.root.findDescendantById("diff-file-row-0")).toBe(row) + expect(scroll.scrollTop).toBe(0) + viewer.app.mockInput.pressKey("G") + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(scroll.scrollHeight - scroll.viewport.height) + viewer.app.mockInput.pressKey("g") + viewer.app.mockInput.pressKey("g") + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(0) + viewer.app.mockInput.pressTab() + viewer.app.mockInput.pressKey("G") + await viewer.app.renderOnce() + expect(scroll.scrollTop).toBe(scroll.scrollHeight - scroll.viewport.height) + expect(findScrollBox(viewer.app.renderer.root, false)!.scrollTop).toBe(0) + viewer.app.mockInput.pressKey("g") + viewer.app.mockInput.pressKey("g") + viewer.app.mockInput.pressKey("m") + await viewer.app.waitForFrame((frame) => /file00\.txt\s+✓/.test(frame)) + } finally { + viewer.app.renderer.destroy() + } +}) + async function renderDiffViewer( vcsDiff: unknown[], options: { + width?: number height?: number initialRoute?: Route fail?: boolean + mode?: "dark" | "light" + single?: boolean + readImage?: (file: string, signal: AbortSignal) => Promise + onSessionTab?: () => void keybinds?: TuiKeybind.KeybindOverrides + kittyKeyboard?: boolean } = {}, ) { const commands = new Map() @@ -162,9 +1124,20 @@ async function renderDiffViewer( let renderDiff: Page["render"] | undefined let renderCommands: SlotClaim<"app">["render"] | undefined let vcsDiffInput: unknown + let imageReadInput: unknown let shortcut: (command: string) => string | undefined = () => undefined - const config = createTuiResolvedConfig({ keybinds: options.keybinds }) - const transport = createFetch((url) => { + const config = createTuiResolvedConfig({ keybinds: options.keybinds, diffs: { single: options.single } }) + const transport = createFetch(async (url, request) => { + if (url.pathname.startsWith("/api/fs/read/")) { + const file = decodeURIComponent(url.pathname.slice("/api/fs/read/".length)) + imageReadInput = { + path: file, + location: { directory: url.searchParams.get("location[directory]") }, + } + return new Response( + options.readImage ? (await options.readImage(file, request.signal)).slice() : diffImageFixture, + ) + } if (url.pathname !== "/api/vcs/diff") return vcsDiffInput = { location: { directory: url.searchParams.get("location[directory]") }, @@ -180,8 +1153,19 @@ async function renderDiffViewer( function Harness() { let theme: ReturnType["currentTokens"]> function Content() { + const dialog = useDialog() const keymap = Keymap.use() const shortcuts = Keymap.useShortcuts() + if (options.onSessionTab) { + Keymap.createLayer(() => ({ + mode: "global", + commands: ["session.tab.next", "session.tab.previous"].map((id) => ({ + id, + title: id, + run: () => options.onSessionTab?.(), + })), + })) + } shortcut = shortcuts.get theme = useThemes().currentTokens() const context = { @@ -206,11 +1190,7 @@ async function renderDiffViewer( mode: keymap.mode, }, ui: { - dialog: { - show: () => () => {}, - set() {}, - clear() {}, - }, + dialog: createDialogApi(dialog, (render) => render()), router: { register(page: Page) { if (page.name === "diff") renderDiff = page.render @@ -237,7 +1217,7 @@ async function renderDiffViewer( return ( <> {commandView} - {renderDiff?.({ data: currentData() })} + {renderDiff?.({ data: currentData() })} ) } @@ -247,7 +1227,7 @@ async function renderDiffViewer( - + @@ -259,14 +1239,14 @@ async function renderDiffViewer( ) } - const app = await testRender(() => , { width: 80, height: options.height ?? 20 }) - for (let attempt = 0; attempt < 100; attempt++) { - await app.renderOnce() - if (current().type !== "plugin") commands.get("diff.open")?.run() - if (commands.has("diff.close")) break - await Bun.sleep(25) - } - await app.waitFor(() => commands.has("diff.close"), { maxPasses: 1 }) + const app = await testRender(() => , { + width: options.width ?? 80, + height: options.height ?? 20, + kittyKeyboard: options.kittyKeyboard, + }) + await app.waitFor(() => commands.has("diff.open")) + if (current().type !== "plugin") commands.get("diff.open")!.run() + await app.waitFor(() => commands.has("diff.close")) await app.waitFor(() => vcsDiffInput !== undefined) return { app, @@ -274,6 +1254,7 @@ async function renderDiffViewer( current, shortcut: (command: string) => shortcut(command), vcsDiffInput: () => vcsDiffInput, + imageReadInput: () => imageReadInput, } } @@ -284,6 +1265,8 @@ const disabledDiffKeybinds = { "diff.up": "none", "diff.page.down": "none", "diff.page.up": "none", + "diff.half_page.down": "none", + "diff.half_page.up": "none", "diff.mark_reviewed": "none", } satisfies TuiKeybind.KeybindOverrides @@ -313,20 +1296,18 @@ const hunkDiff = [ }, ] -function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined { - if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root - return root.getChildren().map(findScrollBox).find(Boolean) +const manyDiffs = Array.from({ length: 40 }, (_, index) => ({ + ...hunkDiff[0], + file: `file${String(index).padStart(2, "0")}.txt`, +})) + +function findScrollBox(root: Renderable, patches = true): ScrollBoxRenderable | undefined { + const node = root.findDescendantById(patches ? "diff-patches" : "diff-files") + return node instanceof ScrollBoxRenderable ? node : undefined } -function containsDiff(root: Renderable): boolean { - if (root instanceof DiffRenderable) return true - return root.getChildren().some(containsDiff) -} - -function countDiffs(root: Renderable): number { - return ( - (root instanceof DiffRenderable ? 1 : 0) + root.getChildren().reduce((total, child) => total + countDiffs(child), 0) - ) +function findDiffs(root: Renderable): DiffRenderable[] { + return root instanceof DiffRenderable ? [root] : root.getChildren().flatMap(findDiffs) } const session = { @@ -342,8 +1323,10 @@ const session = { }, } -test("branch diff source requests branch VCS diff", async () => { - const viewer = await renderDiffViewer([], { +test.each([100, 160])("the sidebar source picker switches VCS sources at %i columns", async (width) => { + const viewer = await renderDiffViewer(hunkDiff, { + width, + kittyKeyboard: true, initialRoute: { type: "plugin", id: "opencode.diffs", @@ -363,6 +1346,27 @@ test("branch diff source requests branch VCS diff", async () => { mode: "branch", context: "12", }) + await viewer.app.waitForFrame((frame) => frame.includes("const first")) + await viewer.app.flush() + const source = () => viewer.app.renderer.root.findDescendantById("diff-source-switch")! + expect(source().y).toBe(1) + expect(viewer.app.captureCharFrame().split("\n")[1]).toContain("Main branch") + await viewer.app.mockMouse.click(source().x, source().y, MouseButton.RIGHT) + await viewer.app.flush() + expect(viewer.app.captureCharFrame()).not.toContain("Switch source") + await viewer.app.mockMouse.click(source().x, source().y) + await viewer.app.waitForFrame((frame) => frame.includes("Switch source")) + expect(viewer.app.renderer.getSelection()).toBeNull() + viewer.app.mockInput.pressArrow("up") + viewer.app.mockInput.pressEnter() + await viewer.app.waitForFrame((frame) => frame.includes("Working tree") && frame.includes("const first")) + expect(viewer.vcsDiffInput()).toEqual({ location: { directory: "/repo/session" }, mode: "working", context: "12" }) + await viewer.app.mockMouse.click(source().x, source().y) + await viewer.app.waitForFrame((frame) => frame.includes("Switch source")) + viewer.app.mockInput.pressArrow("down") + viewer.app.mockInput.pressEnter() + await viewer.app.waitForFrame((frame) => frame.includes("Main branch") && frame.includes("const first")) + expect(viewer.vcsDiffInput()).toEqual({ location: { directory: "/repo/session" }, mode: "branch", context: "12" }) } finally { viewer.app.renderer.destroy() } diff --git a/packages/tui/test/component/session-tabs-mouse.test.tsx b/packages/tui/test/component/session-tabs-mouse.test.tsx index 51ba6ccabf0..67a554881b0 100644 --- a/packages/tui/test/component/session-tabs-mouse.test.tsx +++ b/packages/tui/test/component/session-tabs-mouse.test.tsx @@ -65,7 +65,7 @@ test("releasing a transcript selection over tab controls does not activate them" } }) -test("the tab context menu keeps preview tabs open without offering promotion for permanent tabs", async () => { +test("the horizontal tab context menu keeps preview tabs open without selecting them", async () => { const [active, setActive] = createSignal("first") const promoted: string[] = [] const controller = { @@ -104,18 +104,31 @@ test("the tab context menu keeps preview tabs open without offering promotion fo app.renderer.start() await app.waitForFrame((frame) => frame.includes("Second")) - await app.mockMouse.click(5, 0, MouseButton.RIGHT) + const first = app + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("First")) + await app.mockMouse.click(app.captureCharFrame().split("\n")[first]!.indexOf("First"), first, MouseButton.RIGHT) await app.waitForFrame((frame) => frame.includes("Rename")) + expect(app.captureCharFrame()).toContain("Close") expect(app.captureCharFrame()).not.toContain("Keep open") app.mockInput.pressKey("c", { ctrl: true }) await app.waitForFrame((frame) => !frame.includes("Rename")) - await app.mockMouse.click(40, 0, MouseButton.RIGHT) + const second = app + .captureCharFrame() + .split("\n") + .findIndex((line) => line.includes("Second")) + await app.mockMouse.click(app.captureCharFrame().split("\n")[second]!.indexOf("Second"), second, MouseButton.RIGHT) await app.waitForFrame((frame) => frame.includes("Keep open")) + expect(app.captureCharFrame()).toContain("Rename") + expect(app.captureCharFrame()).toContain("Close") + expect(active()).toBe("first") const frame = app.captureCharFrame().split("\n") const row = frame.findIndex((line) => line.includes("Keep open")) await app.mockMouse.click(frame[row]!.indexOf("Keep open"), row) + await app.waitForFrame((frame) => !frame.includes("Rename")) expect(promoted).toEqual(["second"]) expect(active()).toBe("first") diff --git a/packages/tui/test/component/session-tabs-status.test.tsx b/packages/tui/test/component/session-tabs-status.test.tsx index 57eb6516a3b..3016b9f9341 100644 --- a/packages/tui/test/component/session-tabs-status.test.tsx +++ b/packages/tui/test/component/session-tabs-status.test.tsx @@ -1,5 +1,6 @@ /** @jsxImportSource @opentui/solid */ import { testRender } from "@opentui/solid" +import { MouseButton } from "@opentui/core" import { expect, test } from "bun:test" import { batch, createSignal } from "solid-js" import { ConfigProvider, useConfig, type Info } from "../../src/config" @@ -13,23 +14,27 @@ import { SPINNER_FRAMES } from "../../src/component/spinner-frames" import { ClientProvider } from "../../src/context/client" import { DataProvider } from "../../src/context/data" import { LocationProvider } from "../../src/context/location" +import { Keymap } from "../../src/context/keymap" import { RouteProvider } from "../../src/context/route" import { TuiAppProvider } from "../../src/context/runtime" import { SessionTabsProvider } from "../../src/context/session-tabs" import { StorageProvider } from "../../src/context/storage" import { ThemeProvider, useTheme } from "../../src/context/theme" +import { DialogProvider } from "../../src/ui/dialog" +import { ToastProvider } from "../../src/ui/toast" import { emptyThemeSource, tmpdir } from "../fixture/fixture" import { createApi, createEventStream, createFetch } from "../fixture/tui-client" import { TestTuiContexts } from "../fixture/tui-environment" import { createTuiResolvedConfig } from "../fixture/tui-runtime" for (const orientation of ["horizontal", "vertical"] as const) { - test(`${orientation} tabs replace ordinals with status without moving titles`, async () => { + test(`${orientation} tabs replace ordinals with status without moving titles and keep context menu actions`, async () => { await using temporary = await tmpdir() const [status, setStatus] = createSignal(EMPTY_SESSION_TAB_STATUS) const [active, setActive] = createSignal("second") const [animations, setAnimations] = createSignal(false) const [newTab, setNewTab] = createSignal(false) + const [preview, setPreview] = createSignal(false) const settings: Info = { tabs: { enabled: true } } let config!: ReturnType let theme!: ReturnType @@ -53,6 +58,10 @@ for (const orientation of ["horizontal", "vertical"] as const) { }, close() {}, move() {}, + isPreview: (sessionID: string) => sessionID === "first" && preview(), + promote(sessionID: string) { + if (sessionID === "first") setPreview(false) + }, detail: () => "project", status: (sessionID: string) => (sessionID === "first" ? status() : EMPTY_SESSION_TAB_STATUS), } satisfies SessionTabsController @@ -78,7 +87,19 @@ for (const orientation of ["horizontal", "vertical"] as const) { - + + + + + + + + + @@ -227,6 +248,46 @@ for (const orientation of ["horizontal", "vertical"] as const) { }) await app.waitForFrame((frame) => SPINNER_FRAMES.some((glyph) => frame.includes(`${glyph} First`))) + setAnimations(false) + setStatus(EMPTY_SESSION_TAB_STATUS) + setActive("second") + await app.renderOnce() + const rows = app.captureCharFrame().split("\n") + const row = rows.findIndex((line) => line.includes("First")) + const column = rows[row]!.indexOf("First") + await app.mockMouse.click(column, row, MouseButton.RIGHT) + await app.waitForFrame((frame) => frame.includes("Rename")) + expect(app.captureCharFrame().split("\n")[row + 1]!.indexOf("Rename")).toBe(column + 1) + expect(app.captureCharFrame()).toContain("Close") + expect(app.captureCharFrame()).not.toContain("Keep open") + expect(active()).toBe("second") + app.mockInput.pressKey("c", { ctrl: true }) + await app.waitForFrame((frame) => !frame.includes("Rename")) + + setPreview(true) + await app.mockMouse.click(column, row, MouseButton.RIGHT) + await app.waitForFrame((frame) => frame.includes("Keep open")) + expect(app.captureCharFrame()).toContain("Rename") + expect(app.captureCharFrame()).toContain("Close") + expect(active()).toBe("second") + for (const size of [ + { width: 18, height: 4, row: 1, column: 3 }, + { width: 60, height: 10, row: row + 1, column: column + 1 }, + ]) { + app.resize(size.width, size.height) + await app.waitForFrame((frame) => frame.split("\n")[0]?.length === size.width && frame.includes("Keep open")) + const rows = app.captureCharFrame().split("\n") + expect(rows[size.row]?.indexOf("Keep open")).toBe(size.column) + expect(rows[size.row + 1]?.indexOf("Rename")).toBe(size.column) + expect(rows[size.row + 2]?.indexOf("Close")).toBe(size.column) + } + const menu = app.captureCharFrame().split("\n") + const keepOpen = menu.findIndex((line) => line.includes("Keep open")) + await app.mockMouse.click(menu[keepOpen]!.indexOf("Keep open"), keepOpen) + await app.waitForFrame((frame) => !frame.includes("Rename")) + expect(preview()).toBe(false) + expect(active()).toBe("second") + setNewTab(true) await app.waitForFrame((frame) => frame.includes("+ New session")) } finally { diff --git a/packages/tui/test/config-v2.test.tsx b/packages/tui/test/config-v2.test.tsx index 234e993d83a..7f17d8e4230 100644 --- a/packages/tui/test/config-v2.test.tsx +++ b/packages/tui/test/config-v2.test.tsx @@ -179,6 +179,10 @@ test("accepts every v2-only named command ID", () => { "diff.up", "diff.page.down", "diff.page.up", + "diff.half_page.down", + "diff.half_page.up", + "diff.first", + "diff.last", "diff.mark_reviewed", "opencode.settings", "service.restart", @@ -207,7 +211,16 @@ test("centralizes named command defaults and resolves explicit none", () => { "diff.up": "k,up", "diff.page.down": "pagedown,ctrl+f", "diff.page.up": "pageup,ctrl+b", + "diff.half_page.down": "ctrl+d", + "diff.half_page.up": "ctrl+u", + "diff.first": "gg,home", + "diff.last": "shift+g,end", + "diff.next_file": "n,alt+down", + "diff.previous_file": "p,alt+up", + "diff.next_hunk": "]", + "diff.previous_hunk": "[", "diff.mark_reviewed": "m", + "diff.help": "?,shift+?,shift+/", } const config = resolve({}, { terminalSuspend: true }) Object.entries(defaults).forEach(([command, key]) => expect(config.keybinds.get(command)).toMatchObject([{ key }])) @@ -219,6 +232,14 @@ test("centralizes named command defaults and resolves explicit none", () => { Object.keys(defaults).forEach((command) => expect(disabled.keybinds.get(command)).toEqual([])) }) +test("retired diff tree keybinds remain accepted but have no default bindings", () => { + const ids = ["diff.toggle", "diff.expand", "diff.expand_all", "diff.collapse", "diff.switch_focus"] + const defaults = resolve({}, { terminalSuspend: true }) + const overrides = Object.fromEntries(ids.map((id) => [id, "ctrl+alt+x"])) + expect(decodeInfo({ keybinds: overrides }).keybinds).toEqual(overrides) + ids.forEach((id) => expect(defaults.keybinds.get(id)).toEqual([])) +}) + test("rejects orphaned keybind definitions", () => { expect(decodeInfo({ keybinds: { "app.heap_snapshot": "ctrl+h" } })).toEqual({ keybinds: {} }) }) diff --git a/packages/tui/test/feature-plugins/diff-viewer-file-tree-utils.test.ts b/packages/tui/test/feature-plugins/diff-viewer-file-tree-utils.test.ts index e53e35709d7..0baf2604cb8 100644 --- a/packages/tui/test/feature-plugins/diff-viewer-file-tree-utils.test.ts +++ b/packages/tui/test/feature-plugins/diff-viewer-file-tree-utils.test.ts @@ -4,12 +4,8 @@ import { buildFileTree, fileTreeFileSelection, flattenFileTree, - moveFileTreeSelection, - moveFileTreeSelectionToFirstChild, - moveFileTreeSelectionToParent, movePatchFileIndex, orderedPatchFileIndexes, - setFileTreeDirectoryExpanded, showDiffViewerFileTree, singlePatchFileIndex, toggleFileTreeDirectory, @@ -173,67 +169,18 @@ describe("diff viewer file tree utilities", () => { ]) }) - 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 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("selects a file tree node and expands its parents for a patch file", () => { + test("finds the parent directories to expand for a patch file", () => { const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "src/session/index.ts" }, { file: "README.md" }]) const selection = fileTreeFileSelection(tree, 1) - expect(selection?.highlightedNode).toBe( - tree.nodes.find((node) => node.kind === "file" && node.name === "index.ts")?.id, - ) expect([...selection!.expandedNodes].map((id) => tree.nodes[id]!.name)).toEqual(["session", "src"]) expect(fileTreeFileSelection(tree, 99)).toBeUndefined() }) test("prefers the selected file when choosing the single patch file", () => { - expect(singlePatchFileIndex(2, 1, 0, 3)).toBe(2) - expect(singlePatchFileIndex(undefined, 1, 0, 3)).toBe(1) - expect(singlePatchFileIndex(undefined, undefined, 0, 3)).toBe(0) - expect(singlePatchFileIndex(undefined, undefined, undefined, 3)).toBe(3) + expect(singlePatchFileIndex(2, 0, 3)).toBe(2) + expect(singlePatchFileIndex(undefined, 0, 3)).toBe(0) + expect(singlePatchFileIndex(undefined, undefined, 3)).toBe(3) }) test("orders patches by the flattened file tree order", () => { @@ -284,20 +231,4 @@ describe("diff viewer file tree utilities", () => { expect(toggleFileTreeDirectory(tree, reopened, readme.id)).toBe(reopened) expect(toggleFileTreeDirectory(tree, reopened, undefined)).toBe(reopened) }) - - test("sets only selected directory expansion", () => { - const tree = buildFileTree([{ file: "src/config/tui.ts" }, { file: "README.md" }]) - const src = tree.nodes.find((node) => node.kind === "directory" && node.name === "src")! - const readme = tree.nodes.find((node) => node.kind === "file" && node.name === "README.md")! - const expanded = allExpandedFileTreeDirectories(tree) - - const collapsed = setFileTreeDirectoryExpanded(tree, expanded, src.id, false) - expect(collapsed.has(src.id)).toBe(false) - - const reopened = setFileTreeDirectoryExpanded(tree, collapsed, src.id, true) - expect(reopened.has(src.id)).toBe(true) - - expect(setFileTreeDirectoryExpanded(tree, reopened, readme.id, false)).toBe(reopened) - expect(setFileTreeDirectoryExpanded(tree, reopened, undefined, false)).toBe(reopened) - }) }) diff --git a/packages/tui/test/fixture/diff-image.ts b/packages/tui/test/fixture/diff-image.ts new file mode 100644 index 00000000000..2f64aa479da --- /dev/null +++ b/packages/tui/test/fixture/diff-image.ts @@ -0,0 +1,7 @@ +// A 96 x 48 PNG fixture: a golden sun over blue mountains and a lake. +export const diffImageFixture = Uint8Array.from( + atob( + "iVBORw0KGgoAAAANSUhEUgAAAGAAAAAwCAIAAABhdOiYAAABX0lEQVR42u3XwQkCMRCF4eljwVoET3r1YAGCbCcebMMCrMEW7MWrLiysi+4ms5PJJDEP/lsu4WMGEtpf78gRgQBAAAIQgCSttse5o9ej/a0uoE6nj6kjNqKidX6NHDoyIypdZ2zk1REY6QPtDhdjncGoAKBOp89Ypy9roIFmnKUO3ygB0KSOohFTh2lkCuSg0WJapMMxsgNi6oQYCXS8RkZAi3RkRmIdh5HFO0hAI2AK1Jk0snhJB+owjVR0voyi/8VUaLxMijRz35EoQOo6k0aRdEKMKKHOl1FUHbERJaQZZ6AjM6KqdARGlJbGXmepEVWos8iI6tThG1ESmhx0mEZUsw7HiCrX8RoRdPrmLkxNe0aOAAQgAE32vJ3GAQgTBKBIyxW4aNSsN58wMh4gSHGBipVy7Jdsy3xA1Y8VgBSB8hb0Lpds0VSB/nESUwAVhZgrUDaOhQPFR6wAKMzxDTghptXEGbWAAAAAAElFTkSuQmCC", + ), + (character) => character.charCodeAt(0), +) diff --git a/packages/www/src/docs/content/cli/keybinds.mdx b/packages/www/src/docs/content/cli/keybinds.mdx index e8934ce59f3..9fc40f5d8e3 100644 --- a/packages/www/src/docs/content/cli/keybinds.mdx +++ b/packages/www/src/docs/content/cli/keybinds.mdx @@ -101,29 +101,40 @@ Unknown command IDs are rejected. ## Diff Viewer -| ID | Default | Description | -| ----------------------- | ----------------- | ---------------------------------------- | -| `diff.open` | `none` | Open diff viewer | -| `diff.close` | `escape,q` | Close diff viewer | -| `diff.down` | `j,down` | Move diff viewer down | -| `diff.up` | `k,up` | Move diff viewer up | -| `diff.page.down` | `pagedown,ctrl+f` | Page diff viewer down | -| `diff.page.up` | `pageup,ctrl+b` | Page diff viewer up | -| `diff.toggle` | `enter,space` | Toggle diff viewer item | -| `diff.expand` | `right` | Expand diff viewer item | -| `diff.expand_all` | `E` | Expand all diff viewer folders | -| `diff.collapse` | `left` | Collapse diff viewer item | -| `diff.switch_focus` | `tab` | Switch diff viewer focus | -| `diff.next_hunk` | `]` | Jump to next diff hunk | -| `diff.previous_hunk` | `[` | Jump to previous diff hunk | -| `diff.next_file` | `n` | Jump to next diff file | -| `diff.previous_file` | `p` | Jump to previous diff file | -| `diff.toggle_file_tree` | `b` | Toggle diff viewer file tree | -| `diff.single_patch` | `s` | Toggle single patch view | -| `diff.switch_source` | `d` | Switch diff viewer source | -| `diff.toggle_view` | `v` | Toggle diff viewer split or unified view | -| `diff.mark_reviewed` | `m` | Toggle selected diff file reviewed | -| `diff.help` | `?` | Show more diff viewer shortcuts | +Scrolling, paging, and start/end shortcuts always control the diff. There is no keyboard focus switch: click files to open them, click folders to expand or collapse them, and use the mouse wheel to scroll the file tree. + +Use `n` / `p` or `alt+down` / `alt+up` (Option on macOS) to move between files. Inside the diff viewer, the Alt shortcuts navigate files rather than session tabs. + +Press `m` to mark a file reviewed and collapse its patch body. In all-files view, press `m` again to unmark it and reopen the patch. Single-file view automatically advances to the next file when marking reviewed, but stays on the last file rather than wrapping. Unmarking a file never advances. + +Right-click a file in the sidebar or its diff heading for **Mark complete** (or **Mark incomplete**). This changes the same reviewed state as `m`. Completing a different file from its menu does not select it or advance the file currently being read. + +The shifted `diff.help` alternatives support terminals that report the Shift modifier when you press `?`. + +| ID | Default | Description | +| ----------------------- | ------------------- | ---------------------------------------- | +| `diff.open` | `none` | Open diff viewer | +| `diff.close` | `escape,q` | Close diff viewer | +| `diff.down` | `j,down` | Move diff viewer down | +| `diff.up` | `k,up` | Move diff viewer up | +| `diff.page.down` | `pagedown,ctrl+f` | Page diff viewer down | +| `diff.page.up` | `pageup,ctrl+b` | Page diff viewer up | +| `diff.half_page.down` | `ctrl+d` | Scroll diff viewer down half a page | +| `diff.half_page.up` | `ctrl+u` | Scroll diff viewer up half a page | +| `diff.first` | `gg,home` | Go to the start of the diff | +| `diff.last` | `shift+g,end` | Go to the end of the diff | +| `diff.next_hunk` | `]` | Jump to next diff hunk | +| `diff.previous_hunk` | `[` | Jump to previous diff hunk | +| `diff.next_file` | `n,alt+down` | Jump to next diff file | +| `diff.previous_file` | `p,alt+up` | Jump to previous diff file | +| `diff.toggle_file_tree` | `b` | Toggle diff viewer file tree | +| `diff.single_patch` | `s` | Toggle single patch view | +| `diff.switch_source` | `d` | Switch diff viewer source | +| `diff.toggle_view` | `v` | Toggle diff viewer split or unified view | +| `diff.mark_reviewed` | `m` | Toggle selected diff file reviewed | +| `diff.help` | `?,shift+?,shift+/` | Show more diff viewer shortcuts | + +The retired `diff.toggle`, `diff.expand`, `diff.expand_all`, `diff.collapse`, and `diff.switch_focus` configuration entries are still accepted, but no longer register shortcuts. ## Appearance And Navigation