/** @jsxImportSource @opentui/solid */ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" import type { BoxRenderable, ScrollBoxRenderable } from "@opentui/core" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { useBindings, useCommandShortcut } from "@tui/keymap" import { useTheme } from "@tui/context/theme" import { useTerminalDimensions } from "@opentui/solid" import path from "path" import { createEffect, createMemo, createResource, createSignal, For, Match, Show, Switch } from "solid-js" import { DiffViewerFileTree } from "./diff-viewer-file-tree" import { DialogSelect } from "@tui/ui/dialog-select" import { allExpandedFileTreeDirectories, buildFileTree, flattenFileTree, moveFileTreeSelection, moveFileTreeSelectionToFile, setFileTreeDirectoryExpanded, toggleFileTreeDirectory, } from "./diff-viewer-file-tree-utils" const ROUTE = "diff" const MIN_SPLIT_WIDTH = 100 type DiffMode = "git" | "last-turn" type DiffViewerFocus = "patches" | "files" type DiffFile = { readonly file: string readonly patch?: string readonly additions: number readonly deletions: number readonly status: "added" | "deleted" | "modified" } const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] => diffs.flatMap((item) => item.file ? [ { file: item.file, patch: item.patch, additions: item.additions, deletions: item.deletions, status: item.status ?? "modified", } satisfies DiffFile, ] : [], ) function filetype(input?: string) { if (!input) return "none" const language = LANGUAGE_EXTENSIONS[path.extname(input)] if (["typescriptreact", "javascriptreact", "javascript"].includes(language)) return "typescript" return language } function DiffViewer(props: { api: TuiPluginApi }) { const dimensions = useTerminalDimensions() const themeState = useTheme() const theme = () => props.api.theme.current const params = () => ("params" in props.api.route.current ? props.api.route.current.params : undefined) as | { mode?: DiffMode; sessionID?: string; messageID?: string } | undefined const mode = () => params()?.mode ?? "git" const diffInput = createMemo(() => ({ mode: mode(), sessionID: params()?.sessionID, messageID: params()?.messageID, })) const [diff] = createResource(diffInput, async (input) => { if (input.mode === "last-turn") { const sessionID = input.sessionID if (!sessionID) return [] const result = await props.api.client.session.diff({ sessionID, messageID: input.messageID }, { throwOnError: true }) return normalizeDiffs(result.data ?? []) } const result = await props.api.client.vcs.diff({ mode: "git" }, { throwOnError: true }) return normalizeDiffs(result.data ?? []) }) const files = createMemo(() => diff() ?? []) const [focus, setFocus] = createSignal("patches") const [showFileTree, setShowFileTree] = createSignal(true) const [singlePatch, setSinglePatch] = createSignal(false) const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4) const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH) const defaultView = createMemo(() => { if (props.api.tuiConfig.diff_style === "stacked") return "unified" return splitAvailable() ? "split" : "unified" }) const [viewOverride, setViewOverride] = createSignal<"split" | "unified">() const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified")) const fileTree = createMemo(() => buildFileTree(files())) const [expandedFileNodes, setExpandedFileNodes] = createSignal>(new Set()) const [highlightedFileNode, setHighlightedFileNode] = createSignal() const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal() const [activePatchFileIndex, setActivePatchFileIndex] = createSignal() const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) const focusRunner = (input: Record void>) => () => input[focus()]() const switchFocusShortcut = useCommandShortcut("diff.switch_focus") const nextFileShortcut = useCommandShortcut("diff.next_file") const previousFileShortcut = useCommandShortcut("diff.previous_file") const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree") const singlePatchShortcut = useCommandShortcut("diff.single_patch") const switchDiffShortcut = useCommandShortcut("diff.switch_diff") const toggleViewShortcut = useCommandShortcut("diff.toggle_view") let scroll: ScrollBoxRenderable | undefined const patchNodeByFileIndex = new Map() const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal() createEffect(() => { setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) setHighlightedFileNode(undefined) setLastHighlightedFileNode(undefined) setActivePatchFileIndex(undefined) }) const ensureHighlightedFileNode = () => { const highlighted = highlightedFileNode() if (highlighted !== undefined && fileRows().some((row) => row.id === highlighted)) return const lastHighlighted = lastHighlightedFileNode() const next = lastHighlighted !== undefined && fileRows().some((row) => row.id === lastHighlighted) ? lastHighlighted : fileRows()[0]?.id setHighlightedFileNode(next) } const setHighlighted = (node: number | undefined) => { setHighlightedFileNode(node) if (node !== undefined) setLastHighlightedFileNode(node) } const moveFileSelection = (offset: number) => setHighlighted(moveFileTreeSelection(fileRows(), highlightedFileNode(), offset)) const clearFileTreePatchState = () => { setHighlightedFileNode(undefined) setActivePatchFileIndex(undefined) } const scrollPatchNodeToTop = (patchNode: BoxRenderable) => { if (!scroll) return scroll.scrollBy(patchNode.y - scroll.viewport.y) requestAnimationFrame(() => { if (scroll) scroll.scrollBy(patchNode.y - scroll.viewport.y) }) } const revealFileTreeFile = (fileIndex: number) => { const node = fileTree().nodes.find((item) => item.kind === "file" && item.fileIndex === fileIndex) if (!node) return setExpandedFileNodes((expanded) => { const next = new Set(expanded) for (let parent = node.parent; parent !== undefined; parent = fileTree().nodes[parent]?.parent) { next.add(parent) } return next }) setHighlighted(node.id) } const scrollToFileIndex = (fileIndex: number | undefined) => { if (fileIndex === undefined) return setActivePatchFileIndex(fileIndex) const patchNode = patchNodeByFileIndex.get(fileIndex) if (patchNode) scrollPatchNodeToTop(patchNode) } const jumpToFileIndex = (fileIndex: number | undefined) => { if (fileIndex === undefined) return revealFileTreeFile(fileIndex) scrollToFileIndex(fileIndex) } const currentPatchFileIndex = () => { if (!scroll) return undefined const entries = files() .map((_, fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) })) .filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node)) .sort((left, right) => left.node.y - right.node.y) return entries.findLast((entry) => entry.node.y <= scroll!.viewport.y + 1)?.fileIndex ?? entries[0]?.fileIndex } const jumpRelativePatchFile = (offset: number) => { const current = focus() === "files" ? highlightedFileNode() : undefined const nextFromSelection = current === undefined ? undefined : moveFileTreeSelectionToFile(fileRows(), current, offset) if (nextFromSelection !== undefined) { jumpToFileIndex(fileRows().find((row) => row.id === nextFromSelection)?.fileIndex) return } const currentFileIndex = activePatchFileIndex() ?? currentPatchFileIndex() const currentRow = fileRows().find((row) => row.fileIndex === currentFileIndex) scrollToFileIndex(fileRows().find((row) => row.id === moveFileTreeSelectionToFile(fileRows(), currentRow?.id, offset))?.fileIndex) } const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex const visiblePatchFiles = createMemo(() => { if (!singlePatch()) return files().map((file, fileIndex) => ({ file, fileIndex })) const fileIndex = activePatchFileIndex() ?? currentPatchFileIndex() ?? firstPatchFileIndex() const file = fileIndex === undefined ? undefined : files()[fileIndex] return file && fileIndex !== undefined ? [{ file, fileIndex }] : [] }) const ensureHighlightedPatchFile = () => { if (activePatchFileIndex() !== undefined) return const fileIndex = currentPatchFileIndex() ?? firstPatchFileIndex() if (fileIndex !== undefined) setActivePatchFileIndex(fileIndex) } const scrollToHighlightedPatchFile = () => { const fileIndex = activePatchFileIndex() if (fileIndex === undefined) return setPendingPatchScrollFileIndex(fileIndex) } const registerPatchNode = (fileIndex: number, element: BoxRenderable) => { patchNodeByFileIndex.set(fileIndex, element) if (pendingPatchScrollFileIndex() !== fileIndex) return requestAnimationFrame(() => { scrollPatchNodeToTop(element) requestAnimationFrame(() => { scrollPatchNodeToTop(element) setPendingPatchScrollFileIndex(undefined) }) }) } const toggleSelectedFileTreeRow = () => { const highlighted = fileRows().find((row) => row.id === highlightedFileNode()) if (highlighted?.fileIndex !== undefined) { jumpToFileIndex(highlighted.fileIndex) return } setExpandedFileNodes((expanded) => toggleFileTreeDirectory(fileTree(), expanded, highlightedFileNode())) } const commands = [ { name: "diff.close", title: "Close diff viewer", category: "VCS", run() { props.api.route.navigate("home") }, }, { name: "diff.down", title: "Move diff viewer down", category: "VCS", run: focusRunner({ files() { moveFileSelection(1) }, patches() { clearFileTreePatchState() scroll?.scrollBy(1) }, }), }, { name: "diff.up", title: "Move diff viewer up", category: "VCS", run: focusRunner({ files() { moveFileSelection(-1) }, patches() { clearFileTreePatchState() scroll?.scrollBy(-1) }, }), }, { name: "diff.page.down", title: "Page diff viewer down", category: "VCS", run: focusRunner({ files() { moveFileSelection(8) }, patches() { clearFileTreePatchState() if (scroll) scroll.scrollBy(scroll.height) }, }), }, { name: "diff.page.up", title: "Page diff viewer up", category: "VCS", run: focusRunner({ files() { moveFileSelection(-8) }, patches() { clearFileTreePatchState() if (scroll) scroll.scrollBy(-scroll.height) }, }), }, { name: "diff.toggle", title: "Toggle diff viewer item", category: "VCS", run: focusRunner({ files() { toggleSelectedFileTreeRow() }, patches() {}, }), }, { name: "diff.expand", title: "Expand diff viewer item", category: "VCS", run: focusRunner({ files() { setExpandedFileNodes((expanded) => setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true)) }, patches() {}, }), }, { name: "diff.collapse", title: "Collapse diff viewer item", category: "VCS", run: focusRunner({ files() { setExpandedFileNodes((expanded) => setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), false)) }, patches() {}, }), }, { name: "diff.next_file", title: "Jump to next diff file", category: "VCS", run() { jumpRelativePatchFile(1) }, }, { name: "diff.previous_file", title: "Jump to previous diff file", category: "VCS", run() { jumpRelativePatchFile(-1) }, }, { name: "diff.switch_focus", title: "Switch diff viewer focus", category: "VCS", run() { if (!showFileTree()) return setFocus((current) => { if (current === "files") return "patches" ensureHighlightedFileNode() return "files" }) }, }, { name: "diff.toggle_file_tree", title: "Toggle diff viewer file tree", category: "VCS", run() { setShowFileTree((value) => { if (value) setFocus("patches") return !value }) }, }, { name: "diff.single_patch", title: "Toggle single patch view", category: "VCS", run() { setSinglePatch((value) => { const next = !value if (next) ensureHighlightedPatchFile() else scrollToHighlightedPatchFile() return next }) }, }, { name: "diff.switch_diff", title: "Switch diff viewer source", category: "VCS", run() { openSwitchDiffDialog() }, }, { name: "diff.toggle_view", title: "Toggle diff viewer split or unified view", category: "VCS", run() { if (!splitAvailable()) return setViewOverride(view() === "split" ? "unified" : "split") }, }, ] const switchDiffOptions = createMemo(() => [ { title: "Working tree", value: "git" as const, description: "Show current git changes", }, { title: "Last turn", value: "last-turn" as const, description: "Show changes from the last assistant turn", }, ]) const openSwitchDiffDialog = () => { props.api.ui.dialog.replace(() => ( ({ ...option, onSelect(dialog) { dialog.clear() props.api.route.navigate(ROUTE, { mode: option.value, sessionID: params()?.sessionID, messageID: params()?.messageID, }) }, }))} /> )) } useBindings(() => ({ commands, bindings: [ { key: "j,down", cmd: "diff.down", desc: "Move diff viewer down" }, { key: "k,up", cmd: "diff.up", desc: "Move diff viewer up" }, { key: "pagedown,ctrl+f", cmd: "diff.page.down", desc: "Page diff viewer down" }, { key: "pageup,ctrl+b", cmd: "diff.page.up", desc: "Page diff viewer up" }, ...props.api.tuiConfig.keybinds.gather( "diff", commands.map((command) => command.name), ), ], })) return ( Diff {mode() === "last-turn" ? "last turn" : "working tree"} Loading diff... Failed to load diff No diff to show 0}> (scroll = element)} flexGrow={1} minHeight={0} verticalScrollbarOptions={{ visible: false }} horizontalScrollbarOptions={{ visible: false }} > {(entry) => ( registerPatchNode(entry.fileIndex, element)} marginBottom={1} backgroundColor={theme().backgroundPanel} > {entry.file.file} +{entry.file.additions} -{entry.file.deletions} No patch available for this file.}> {(patch) => ( )} )} {(shortcut) => ( {shortcut()} focus file tree )} {(shortcut) => ( {shortcut()} next file )} {(shortcut) => ( {shortcut()} previous file )} {(shortcut) => ( {shortcut()} {showFileTree() ? "hide file tree" : "show file tree"} )} {(shortcut) => ( {shortcut()} {singlePatch() ? "all patches" : "single patch"} )} {(shortcut) => ( {shortcut()} switch diff )} {(shortcut) => ( {shortcut()} {view() === "split" ? "unified view" : "split view"} )} ) } const tui: TuiPlugin = async (api) => { api.route.register([ { name: ROUTE, render: () => , }, ]) api.keymap.registerLayer({ commands: [ { name: "diff.open", title: "Open diff viewer", slashName: "diff", category: "VCS", namespace: "palette", run() { api.route.navigate(ROUTE, { mode: "git", sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined, }) api.ui.dialog.clear() }, }, ], }) } export default { id: "diff-viewer", tui, }