| |
| import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui" |
| import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" |
| import { |
| TextAttributes, |
| type BorderSides, |
| type BoxRenderable, |
| type DiffRenderable, |
| type ScrollBoxRenderable, |
| } from "@opentui/core" |
| import { LANGUAGE_EXTENSIONS } from "../../util/filetype" |
| import { useBindings, useCommandShortcut } from "../../keymap" |
| import { useTheme } from "../../context/theme" |
| import { useTerminalDimensions } from "@opentui/solid" |
| import path from "path" |
| 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 { DialogSelect } from "../../ui/dialog-select" |
| import { getScrollAcceleration } from "../../util/scroll" |
| import { |
| allExpandedFileTreeDirectories, |
| buildFileTree, |
| fileTreeFileSelection, |
| type FileTreeRow, |
| flattenFileTree, |
| moveFileTreeSelection, |
| moveFileTreeSelectionToFirstChild, |
| moveFileTreeSelectionToParent, |
| movePatchFileIndex, |
| orderedPatchFileIndexes, |
| setFileTreeDirectoryExpanded, |
| showDiffViewerFileTree, |
| singlePatchFileIndex, |
| toggleFileTreeDirectory, |
| } from "./diff-viewer-file-tree-utils" |
|
|
| const ROUTE = "diff" |
| const MIN_SPLIT_WIDTH = 100 |
| const FILE_TREE_WIDTH = 32 |
| const PLAIN_TEXT_FILETYPE = "opencode-plain-text" |
| const VCS_DIFF_CONTEXT_LINES = 12 |
| const KV_SHOW_FILE_TREE = "diff_viewer_show_file_tree" |
| const KV_SINGLE_PATCH = "diff_viewer_single_patch" |
| const KV_VIEW = "diff_viewer_view" |
| type DiffMode = "git" | "branch" | "last-turn" |
| type DiffViewerFocus = "patches" | "files" |
| type DiffView = "split" | "unified" |
| type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number } |
|
|
| 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 storedView(value: unknown): DiffView | undefined { |
| if (value === "split" || value === "unified") return value |
| } |
|
|
| function diffSourceLabel(mode: DiffMode) { |
| if (mode === "last-turn") return "last turn" |
| if (mode === "branch") return "main branch" |
| return "working tree" |
| } |
|
|
| 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 |
| returnRoute?: TuiRouteCurrent |
| } |
| | undefined |
| const mode = () => params()?.mode ?? "git" |
| const diffInput = createMemo(() => { |
| const sessionID = params()?.sessionID |
| return { |
| mode: mode(), |
| sessionID, |
| messageID: params()?.messageID, |
| directory: sessionID ? props.api.state.session.get(sessionID)?.directory : undefined, |
| } |
| }) |
| 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( |
| { directory: input.directory, mode: input.mode, context: VCS_DIFF_CONTEXT_LINES }, |
| { throwOnError: true }, |
| ) |
| return normalizeDiffs(result.data ?? []) |
| }) |
| const files = createMemo(() => diff() ?? []) |
| const [focus, setFocus] = createSignal<DiffViewerFocus>("patches") |
| const [fileTreeEnabled, setFileTreeEnabled] = createSignal( |
| props.api.kv.get<boolean>(KV_SHOW_FILE_TREE, true) !== false, |
| ) |
| const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length)) |
| const [singlePatch, setSinglePatch] = createSignal(props.api.kv.get<boolean>(KV_SINGLE_PATCH, false) === true) |
| const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? 33 : 0) - 4) |
| const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : [])) |
| 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<DiffView | undefined>(storedView(props.api.kv.get(KV_VIEW))) |
| const view = createMemo(() => (splitAvailable() ? (viewOverride() ?? defaultView()) : "unified")) |
| const fileTree = createMemo(() => buildFileTree(files())) |
| const [expandedFileNodes, setExpandedFileNodes] = createSignal<ReadonlySet<number>>(new Set()) |
| const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>() |
| const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>() |
| const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>() |
| const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>() |
| const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set()) |
| const patchScrollAcceleration = createMemo(() => getScrollAcceleration(props.api.tuiConfig)) |
| const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes())) |
| const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree()))) |
| const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]() |
| const switchFocusShortcut = useCommandShortcut("diff.switch_focus") |
| const nextHunkShortcut = useCommandShortcut("diff.next_hunk") |
| const previousHunkShortcut = useCommandShortcut("diff.previous_hunk") |
| 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 switchSourceShortcut = useCommandShortcut("diff.switch_source") |
| const toggleViewShortcut = useCommandShortcut("diff.toggle_view") |
| const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed") |
| const helpShortcut = useCommandShortcut("diff.help") |
| let scroll: ScrollBoxRenderable | undefined |
| const patchNodeByFileIndex = new Map<number, BoxRenderable>() |
| const diffNodeByFileIndex = new Map<number, DiffRenderable>() |
| const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>() |
| const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>() |
| const [patchFillerHeight, setPatchFillerHeight] = createSignal(0) |
|
|
| onCleanup(() => props.api.ui.dialog.clear()) |
|
|
| createEffect(() => { |
| setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) |
| setHighlightedFileNode(undefined) |
| setLastHighlightedFileNode(undefined) |
| setActivePatchFileIndex(undefined) |
| setSelectedFileIndex(undefined) |
| setSelectedHunk(undefined) |
| setReviewedFileNames(new Set<string>()) |
| }) |
|
|
| 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) |
| setSelectedHunk(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 revealFileTreeFile = (fileIndex: number) => { |
| const selection = fileTreeFileSelection(fileTree(), fileIndex) |
| if (!selection) return |
| setExpandedFileNodes((expanded) => { |
| const next = new Set(expanded) |
| selection.expandedNodes.forEach((node) => next.add(node)) |
| return next |
| }) |
| setHighlighted(selection.highlightedNode) |
| } |
|
|
| const selectPatchFile = (fileIndex: number) => { |
| revealFileTreeFile(fileIndex) |
| setActivePatchFileIndex(fileIndex) |
| setSelectedFileIndex(fileIndex) |
| } |
|
|
| const scrollToFileIndex = (fileIndex: number | undefined) => { |
| if (fileIndex === undefined) return |
| selectPatchFile(fileIndex) |
| const patchNode = patchNodeByFileIndex.get(fileIndex) |
| if (patchNode) scrollPatchNodeToTop(patchNode) |
| } |
|
|
| const jumpToFileIndex = (fileIndex: number | undefined) => { |
| if (fileIndex === undefined) return |
| setSelectedHunk(undefined) |
| scrollToFileIndex(fileIndex) |
| } |
|
|
| const currentPatchFileIndex = () => { |
| if (!scroll) return undefined |
| const viewportContentY = scroll.scrollTop + 1 |
| const entries = patchFileIndexes() |
| .map((fileIndex) => ({ |
| fileIndex, |
| node: patchNodeByFileIndex.get(fileIndex), |
| })) |
| .filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node)) |
| .map((entry) => ({ |
| ...entry, |
| contentY: scroll!.scrollTop + entry.node.y - scroll!.viewport.y, |
| })) |
| .sort((left, right) => left.contentY - right.contentY) |
| return entries.findLast((entry) => entry.contentY <= viewportContentY)?.fileIndex ?? entries[0]?.fileIndex |
| } |
|
|
| const jumpRelativePatchFile = (offset: number) => { |
| setSelectedHunk(undefined) |
| const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset) |
| if (singlePatch()) { |
| if (next === undefined) return |
| selectPatchFile(next) |
| scrollSinglePatchToTop() |
| return |
| } |
| scrollToFileIndex(next) |
| } |
|
|
| const jumpRelativeHunk = (offset: -1 | 1) => { |
| const patchScroll = scroll |
| if (!patchScroll) return |
| const hunks = visiblePatchFiles() |
| .flatMap((entry) => { |
| const node = diffNodeByFileIndex.get(entry.fileIndex) |
| if (!node || node.isDestroyed) return [] |
| const contentY = patchScroll.scrollTop + node.y - patchScroll.viewport.y |
| return node.diff |
| .split("\n") |
| .flatMap((line, row) => (line.startsWith("@@") ? [row] : [])) |
| .map((row, hunkIndex) => ({ |
| fileIndex: entry.fileIndex, |
| hunkIndex, |
| contentY: contentY + row, |
| })) |
| }) |
| .sort((left, right) => left.contentY - right.contentY) |
| const selected = selectedHunk() |
| const selectedIndex = |
| selected?.scrollTop === patchScroll.scrollTop |
| ? hunks.findIndex((hunk) => hunk.fileIndex === selected.fileIndex && hunk.hunkIndex === selected.hunkIndex) |
| : -1 |
| const next = |
| selectedIndex !== -1 |
| ? hunks[selectedIndex + offset] |
| : offset === 1 |
| ? hunks.find((hunk) => hunk.contentY > patchScroll.scrollTop) |
| : hunks.findLast((hunk) => hunk.contentY < patchScroll.scrollTop) |
| if (!next) return |
| selectPatchFile(next.fileIndex) |
| patchScroll.scrollTo(next.contentY) |
| setSelectedHunk({ fileIndex: next.fileIndex, hunkIndex: next.hunkIndex, scrollTop: patchScroll.scrollTop }) |
| } |
|
|
| const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex |
| const firstPatchFileIndex = () => fileRows().find((row) => row.fileIndex !== undefined)?.fileIndex |
| const visiblePatchFiles = createMemo(() => { |
| if (!singlePatch()) { |
| return patchFileIndexes().flatMap((fileIndex) => { |
| const file = files()[fileIndex] |
| return file ? [{ file, fileIndex }] : [] |
| }) |
| } |
| const fileIndex = singlePatchFileIndex( |
| selectedFileIndex(), |
| activePatchFileIndex(), |
| currentPatchFileIndex(), |
| firstPatchFileIndex(), |
| ) |
| const file = fileIndex === undefined ? undefined : files()[fileIndex] |
| return file && fileIndex !== undefined ? [{ file, fileIndex }] : [] |
| }) |
|
|
| const ensureHighlightedPatchFile = () => { |
| const fileIndex = currentPatchFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex() |
| if (fileIndex === undefined) return |
| selectPatchFile(fileIndex) |
| } |
|
|
| const scrollToPatchFileIndexAfterRender = (fileIndex: number) => { |
| setPendingPatchScrollFileIndex(fileIndex) |
| requestAnimationFrame(() => { |
| const patchNode = patchNodeByFileIndex.get(fileIndex) |
| if (patchNode) scrollPatchNodeToTop(patchNode) |
| requestAnimationFrame(() => { |
| const patchNode = patchNodeByFileIndex.get(fileIndex) |
| if (patchNode) scrollPatchNodeToTop(patchNode) |
| setPendingPatchScrollFileIndex(undefined) |
| }) |
| }) |
| } |
|
|
| const scrollSinglePatchToTop = () => { |
| requestAnimationFrame(() => { |
| scroll?.scrollTo(0) |
| requestAnimationFrame(() => scroll?.scrollTo(0)) |
| }) |
| } |
|
|
| 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())) |
| } |
|
|
| const clickFileTreeRow = (row: FileTreeRow) => { |
| setFocus("files") |
| setHighlighted(row.id) |
| if (row.fileIndex !== undefined) { |
| jumpToFileIndex(row.fileIndex) |
| return |
| } |
| 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 |
| if (!file) return |
| setReviewedFileNames((reviewed) => { |
| const next = new Set(reviewed) |
| if (next.has(file)) next.delete(file) |
| else next.add(file) |
| return next |
| }) |
| } |
|
|
| const commands = [ |
| { |
| name: "diff.close", |
| title: "Close diff viewer", |
| category: "VCS", |
| run() { |
| const returnRoute = params()?.returnRoute |
| props.api.ui.dialog.clear() |
|
|
| props.api.route.navigate( |
| returnRoute?.name ?? "home", |
| returnRoute && "params" in returnRoute ? returnRoute.params : undefined, |
| ) |
| }, |
| }, |
| { |
| 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() { |
| const highlighted = highlightedFileNode() |
| if (highlighted !== undefined && expandedFileNodes().has(highlighted)) { |
| setHighlighted(moveFileTreeSelectionToFirstChild(fileRows(), highlighted)) |
| return |
| } |
| setExpandedFileNodes((expanded) => |
| setFileTreeDirectoryExpanded(fileTree(), expanded, highlightedFileNode(), true), |
| ) |
| }, |
| patches() {}, |
| }), |
| }, |
| { |
| name: "diff.expand_all", |
| title: "Expand all diff viewer folders", |
| category: "VCS", |
| run: focusRunner({ |
| files() { |
| setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) |
| }, |
| patches() {}, |
| }), |
| }, |
| { |
| name: "diff.collapse", |
| title: "Collapse diff viewer item", |
| category: "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() {}, |
| }), |
| }, |
| { |
| name: "diff.next_hunk", |
| title: "Jump to next diff hunk", |
| category: "VCS", |
| run() { |
| jumpRelativeHunk(1) |
| }, |
| }, |
| { |
| name: "diff.previous_hunk", |
| title: "Jump to previous diff hunk", |
| category: "VCS", |
| run() { |
| jumpRelativeHunk(-1) |
| }, |
| }, |
| { |
| 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.mark_reviewed", |
| title: "Toggle selected diff file reviewed", |
| category: "VCS", |
| run() { |
| toggleSelectedFileReviewed() |
| }, |
| }, |
| { |
| 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() { |
| const next = !fileTreeEnabled() |
| if (!next) setFocus("patches") |
| setFileTreeEnabled(next) |
| props.api.kv.set(KV_SHOW_FILE_TREE, next) |
| }, |
| }, |
| { |
| name: "diff.single_patch", |
| title: "Toggle single patch view", |
| category: "VCS", |
| run() { |
| setSelectedHunk(undefined) |
| if (!singlePatch()) { |
| ensureHighlightedPatchFile() |
| setSinglePatch(true) |
| props.api.kv.set(KV_SINGLE_PATCH, true) |
| scrollSinglePatchToTop() |
| return |
| } |
| const fileIndex = |
| visiblePatchFiles()[0]?.fileIndex ?? |
| singlePatchFileIndex( |
| selectedFileIndex(), |
| activePatchFileIndex(), |
| currentPatchFileIndex(), |
| firstPatchFileIndex(), |
| ) |
| if (fileIndex !== undefined) selectPatchFile(fileIndex) |
| setSinglePatch(false) |
| props.api.kv.set(KV_SINGLE_PATCH, false) |
| if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex) |
| }, |
| }, |
| { |
| name: "diff.switch_source", |
| 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 |
| setSelectedHunk(undefined) |
| const next = view() === "split" ? "unified" : "split" |
| setViewOverride(next) |
| props.api.kv.set(KV_VIEW, next) |
| }, |
| }, |
| { |
| name: "diff.help", |
| title: "Show more diff viewer shortcuts", |
| category: "VCS", |
| run() { |
| openHelpDialog() |
| }, |
| }, |
| ] |
|
|
| const switchDiffOptions = createMemo(() => { |
| const vcs = props.api.state.vcs |
| return [ |
| { |
| title: "Working tree", |
| value: "git" as const, |
| description: "Show current git changes", |
| }, |
| ...(vcs?.branch && vcs.default_branch && vcs.branch !== vcs.default_branch |
| ? [ |
| { |
| title: "Main branch", |
| value: "branch" as const, |
| description: "Show changes compared to main branch", |
| }, |
| ] |
| : []), |
| { |
| title: "Last turn", |
| value: "last-turn" as const, |
| description: "Show changes from the last assistant turn", |
| }, |
| ] |
| }) |
|
|
| const openSwitchDiffDialog = () => { |
| props.api.ui.dialog.replace(() => ( |
| <DialogSelect |
| title="Switch source" |
| skipFilter={true} |
| renderFilter={false} |
| current={mode()} |
| options={switchDiffOptions().map((option) => ({ |
| ...option, |
| onSelect(dialog) { |
| dialog.clear() |
| props.api.route.navigate(ROUTE, { |
| mode: option.value, |
| sessionID: params()?.sessionID, |
| messageID: params()?.messageID, |
| returnRoute: params()?.returnRoute, |
| }) |
| }, |
| }))} |
| /> |
| )) |
| } |
|
|
| const openHelpDialog = () => { |
| props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />) |
| props.api.ui.dialog.setSize("large") |
| } |
|
|
| 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" }, |
| { key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" }, |
| ...props.api.tuiConfig.keybinds.gather( |
| "diff", |
| commands.map((command) => command.name), |
| ), |
| ], |
| })) |
|
|
| return ( |
| <box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}> |
| <PanelGroup axis="y" width="100%" height="100%"> |
| <Panel border="none" flexShrink={0} padding={0} paddingLeft={1}> |
| <text fg={theme().text}>Diff </text> |
| <text fg={theme().textMuted}>{diffSourceLabel(mode())}</text> |
| <box flexGrow={1} /> |
| <text fg={theme().textMuted}> |
| {files().length} {files().length === 1 ? "file" : "files"} |
| </text> |
| </Panel> |
| |
| <box flexGrow={1} minHeight={0}> |
| <Switch> |
| <Match when={diff.loading}> |
| <Separator axis="x" /> |
| <box flexGrow={1} paddingLeft={1}> |
| <text fg={theme().textMuted}>Loading diff…</text> |
| </box> |
| </Match> |
| <Match when={!diff.loading && files().length === 0}> |
| <Separator axis="x" /> |
| <box flexGrow={1} paddingLeft={1}> |
| <text fg={theme().textMuted}>No diff!</text> |
| </box> |
| </Match> |
| <Match when={!diff.loading && diff.error}> |
| <Separator axis="x" /> |
| <box flexGrow={1} paddingLeft={1}> |
| <text fg={theme().error}>Failed to load diff</text> |
| </box> |
| </Match> |
| <Match when={!diff.loading}> |
| <PanelGroup axis="x"> |
| <Show when={showFileTree()}> |
| <DiffViewerFileTree |
| files={files()} |
| loading={diff.loading} |
| error={diff.error} |
| theme={theme()} |
| focused={focus() === "files"} |
| width={FILE_TREE_WIDTH} |
| highlightedNode={highlightedFileNode()} |
| selectedFileIndex={selectedFileIndex()} |
| reviewedFileNames={reviewedFileNames()} |
| expandedNodes={expandedFileNodes()} |
| onRowClick={clickFileTreeRow} |
| /> |
| </Show> |
| |
| <Panel flexGrow={1} minHeight={0} border="none"> |
| <Separator axis="x" start={showFileTree() ? "edge-out" : undefined} /> |
| <scrollbox |
| ref={(element: ScrollBoxRenderable) => (scroll = element)} |
| flexGrow={1} |
| minHeight={0} |
| scrollAcceleration={patchScrollAcceleration()} |
| verticalScrollbarOptions={{ visible: false }} |
| horizontalScrollbarOptions={{ visible: false }} |
| > |
| <For each={visiblePatchFiles()}> |
| {(entry, index) => { |
| const reviewed = () => reviewedFileNames().has(entry.file.file) |
| return ( |
| <box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}> |
| {index() !== 0 ? <Separator axis="x" start={showFileTree() ? "edge" : undefined} /> : null} |
| <box |
| flexDirection="row" |
| gap={1} |
| flexShrink={0} |
| paddingLeft={1} |
| paddingRight={1} |
| border={patchLeftBorder()} |
| borderColor={theme().border} |
| > |
| <text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text> |
| <box flexGrow={1} /> |
| <text fg={reviewed() ? theme().textMuted : theme().diffAdded}> |
| +{entry.file.additions} |
| </text> |
| <text fg={reviewed() ? theme().textMuted : theme().diffRemoved}> |
| -{entry.file.deletions} |
| </text> |
| </box> |
| <Separator axis="x" start={showFileTree() ? "edge" : undefined} /> |
| <Show |
| when={entry.file.patch} |
| fallback={<text fg={theme().textMuted}>No patch available for this file.</text>} |
| > |
| {(patch) => ( |
| <box border={patchLeftBorder()} borderColor={theme().border}> |
| <diff |
| ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)} |
| diff={patch()} |
| view={view()} |
| filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)} |
| syntaxStyle={themeState.syntax()} |
| showLineNumbers={true} |
| width="100%" |
| wrapMode="char" |
| fg={reviewed() ? theme().textMuted : theme().text} |
| addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg} |
| removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg} |
| addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded} |
| removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved} |
| lineNumberFg={theme().diffLineNumber} |
| addedLineNumberBg={ |
| reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg |
| } |
| removedLineNumberBg={ |
| reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg |
| } |
| /> |
| </box> |
| )} |
| </Show> |
| </box> |
| ) |
| }} |
| </For> |
| <Show when={patchFillerHeight() > 0}> |
| <box height={patchFillerHeight()} border={patchLeftBorder()} borderColor={theme().border} /> |
| </Show> |
| </scrollbox> |
| <Separator axis="x" start={showFileTree() ? "edge-in" : undefined} /> |
| </Panel> |
| </PanelGroup> |
| </Match> |
| </Switch> |
| </box> |
| |
| <Panel flexShrink={0} gap={2} paddingLeft={1} border="none"> |
| <Show when={switchFocusShortcut()}> |
| {(shortcut) => ( |
| <text fg={theme().text}> |
| {shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span> |
| </text> |
| )} |
| </Show> |
| <Show when={nextFileShortcut()}> |
| {(shortcut) => ( |
| <text fg={theme().text}> |
| {shortcut()} <span style={{ fg: theme().textMuted }}>next file</span> |
| </text> |
| )} |
| </Show> |
| <Show when={nextHunkShortcut()}> |
| {(shortcut) => ( |
| <text fg={theme().text}> |
| {shortcut()} <span style={{ fg: theme().textMuted }}>next hunk</span> |
| </text> |
| )} |
| </Show> |
| <Show when={previousHunkShortcut()}> |
| {(shortcut) => ( |
| <text fg={theme().text}> |
| {shortcut()} <span style={{ fg: theme().textMuted }}>previous hunk</span> |
| </text> |
| )} |
| </Show> |
| <Show when={previousFileShortcut()}> |
| {(shortcut) => ( |
| <text fg={theme().text}> |
| {shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span> |
| </text> |
| )} |
| </Show> |
| <Show when={switchSourceShortcut()}> |
| {(shortcut) => ( |
| <text fg={theme().text}> |
| {shortcut()} <span style={{ fg: theme().textMuted }}>switch source</span> |
| </text> |
| )} |
| </Show> |
| <Show when={markReviewedShortcut()}> |
| {(shortcut) => ( |
| <text fg={theme().text}> |
| {shortcut()} <span style={{ fg: theme().textMuted }}>mark reviewed</span> |
| </text> |
| )} |
| </Show> |
| <Show when={helpShortcut()}> |
| {(shortcut) => ( |
| <text fg={theme().text}> |
| {shortcut()} <span style={{ fg: theme().textMuted }}>all</span> |
| </text> |
| )} |
| </Show> |
| </Panel> |
| </PanelGroup> |
| </box> |
| ) |
| } |
|
|
| function DiffViewerHelpDialog() { |
| const { theme } = useTheme() |
| const rows = [ |
| { |
| shortcut: () => "q", |
| action: "Close viewer", |
| description: "Quit the diff viewer", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.switch_focus"), |
| action: "Focus file tree", |
| description: "Move keyboard focus between the file tree and patch pane", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.next_hunk"), |
| action: "Next hunk", |
| description: "Jump to the next diff hunk", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.previous_hunk"), |
| action: "Previous hunk", |
| description: "Jump to the previous diff hunk", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.next_file"), |
| action: "Next file", |
| description: "Select the next changed file in file-tree order", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.previous_file"), |
| action: "Previous file", |
| description: "Select the previous changed file in file-tree order", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.toggle_file_tree"), |
| action: "Toggle file tree", |
| description: "Show or hide the file tree sidebar", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.single_patch"), |
| action: "Toggle patches", |
| description: "Switch between one selected patch and all patches", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.switch_source"), |
| action: "Switch source", |
| description: "Choose working tree, main branch, or last-turn changes", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.toggle_view"), |
| action: "Toggle view", |
| description: "Switch between split and unified diff layout", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.expand_all"), |
| action: "Expand all folders", |
| description: "Open every folder in the file tree", |
| }, |
| { |
| shortcut: useCommandShortcut("diff.mark_reviewed"), |
| action: "Mark reviewed", |
| description: "Toggle reviewed state for the selected file", |
| }, |
| ] |
|
|
| return ( |
| <box paddingLeft={2} paddingRight={2} paddingBottom={1} gap={1}> |
| <box flexDirection="row" justifyContent="space-between"> |
| <text attributes={TextAttributes.BOLD} fg={theme.text}> |
| Diff shortcuts |
| </text> |
| <text fg={theme.textMuted}>esc</text> |
| </box> |
| <box flexDirection="row"> |
| <text fg={theme.textMuted} width={5} wrapMode="none"> |
| Key |
| </text> |
| <text fg={theme.textMuted} width={22} wrapMode="none"> |
| Action |
| </text> |
| <text fg={theme.textMuted}>Description</text> |
| </box> |
| <For each={rows}> |
| {(row) => ( |
| <box flexDirection="row"> |
| <text fg={theme.text} width={5} wrapMode="none"> |
| {row.shortcut() || "-"} |
| </text> |
| <text fg={theme.text} width={22} wrapMode="none"> |
| {row.action} |
| </text> |
| <text fg={theme.textMuted}>{row.description}</text> |
| </box> |
| )} |
| </For> |
| </box> |
| ) |
| } |
|
|
| const tui: TuiPlugin = async (api) => { |
| api.route.register([ |
| { |
| name: ROUTE, |
| render: () => <DiffViewer api={api} />, |
| }, |
| ]) |
|
|
| api.keymap.registerLayer({ |
| commands: [ |
| { |
| name: "diff.open", |
| title: "Open diff viewer", |
| slashName: "diff", |
| category: "VCS", |
| namespace: "palette", |
| run() { |
| api.route.navigate(ROUTE, { |
| mode: "git", |
| sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined, |
| returnRoute: api.route.current, |
| }) |
| api.ui.dialog.clear() |
| }, |
| }, |
| ], |
| }) |
| } |
|
|
| export default { |
| id: "diff-viewer", |
| tui, |
| } |
|
|