|
|
@@ -0,0 +1,686 @@
|
|
|
+/** @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<DiffViewerFocus>("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<ReadonlySet<number>>(new Set())
|
|
|
+ const [highlightedFileNode, setHighlightedFileNode] = createSignal<number | undefined>()
|
|
|
+ const [lastHighlightedFileNode, setLastHighlightedFileNode] = createSignal<number | undefined>()
|
|
|
+ const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
|
|
|
+ const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
|
|
|
+ const focusRunner = (input: Record<DiffViewerFocus, () => 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<number, BoxRenderable>()
|
|
|
+ const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
|
|
+
|
|
|
+ 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(() => (
|
|
|
+ <DialogSelect
|
|
|
+ title="Switch diff"
|
|
|
+ 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,
|
|
|
+ })
|
|
|
+ },
|
|
|
+ }))}
|
|
|
+ />
|
|
|
+ ))
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 (
|
|
|
+ <box
|
|
|
+ position="absolute"
|
|
|
+ zIndex={2500}
|
|
|
+ left={0}
|
|
|
+ top={0}
|
|
|
+ width={dimensions().width}
|
|
|
+ height={dimensions().height}
|
|
|
+ backgroundColor={theme().background}
|
|
|
+ paddingLeft={1}
|
|
|
+ paddingRight={1}
|
|
|
+ paddingTop={1}
|
|
|
+ paddingBottom={1}
|
|
|
+ gap={1}
|
|
|
+ >
|
|
|
+ <box flexDirection="row" justifyContent="space-between" flexShrink={0}>
|
|
|
+ <box flexDirection="row" gap={1}>
|
|
|
+ <text fg={theme().text}>Diff</text>
|
|
|
+ <text fg={theme().textMuted}>{mode() === "last-turn" ? "last turn" : "working tree"}</text>
|
|
|
+ </box>
|
|
|
+ </box>
|
|
|
+
|
|
|
+ <Switch>
|
|
|
+ <Match when={diff.loading}>
|
|
|
+ <box flexGrow={1} alignItems="center" justifyContent="center">
|
|
|
+ <text fg={theme().textMuted}>Loading diff...</text>
|
|
|
+ </box>
|
|
|
+ </Match>
|
|
|
+ <Match when={!diff.loading}>
|
|
|
+ <box flexDirection="row" flexGrow={1} minHeight={0} gap={1}>
|
|
|
+ <Show when={showFileTree()}>
|
|
|
+ <DiffViewerFileTree
|
|
|
+ files={files()}
|
|
|
+ loading={diff.loading}
|
|
|
+ error={diff.error}
|
|
|
+ theme={theme()}
|
|
|
+ focused={focus() === "files"}
|
|
|
+ highlightedNode={highlightedFileNode()}
|
|
|
+ expandedNodes={expandedFileNodes()}
|
|
|
+ />
|
|
|
+ </Show>
|
|
|
+
|
|
|
+ <box
|
|
|
+ flexGrow={1}
|
|
|
+ minWidth={0}
|
|
|
+ backgroundColor={theme().background}
|
|
|
+ paddingLeft={0}
|
|
|
+ paddingRight={2}
|
|
|
+ gap={1}
|
|
|
+ >
|
|
|
+ <Switch>
|
|
|
+ <Match when={diff.error}>
|
|
|
+ <box paddingTop={1}>
|
|
|
+ <text fg={theme().error}>Failed to load diff</text>
|
|
|
+ </box>
|
|
|
+ </Match>
|
|
|
+ <Match when={files().length === 0}>
|
|
|
+ <box paddingTop={1}>
|
|
|
+ <text fg={theme().textMuted}>No diff to show</text>
|
|
|
+ </box>
|
|
|
+ </Match>
|
|
|
+ <Match when={files().length > 0}>
|
|
|
+ <scrollbox
|
|
|
+ ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
|
|
+ flexGrow={1}
|
|
|
+ minHeight={0}
|
|
|
+ verticalScrollbarOptions={{ visible: false }}
|
|
|
+ horizontalScrollbarOptions={{ visible: false }}
|
|
|
+ >
|
|
|
+ <For each={visiblePatchFiles()}>
|
|
|
+ {(entry) => (
|
|
|
+ <box
|
|
|
+ ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}
|
|
|
+ marginBottom={1}
|
|
|
+ backgroundColor={theme().backgroundPanel}
|
|
|
+ >
|
|
|
+ <box
|
|
|
+ flexDirection="row"
|
|
|
+ gap={2}
|
|
|
+ flexShrink={0}
|
|
|
+ paddingTop={1}
|
|
|
+ paddingBottom={1}
|
|
|
+ paddingLeft={2}
|
|
|
+ paddingRight={1}
|
|
|
+ backgroundColor={theme().backgroundPanel}
|
|
|
+ >
|
|
|
+ <text fg={theme().text}>{entry.file.file}</text>
|
|
|
+ <text fg={theme().diffAdded}>+{entry.file.additions}</text>
|
|
|
+ <text fg={theme().diffRemoved}>-{entry.file.deletions}</text>
|
|
|
+ </box>
|
|
|
+ <Show
|
|
|
+ when={entry.file.patch}
|
|
|
+ fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
|
|
+ >
|
|
|
+ {(patch) => (
|
|
|
+ <diff
|
|
|
+ diff={patch()}
|
|
|
+ view={view()}
|
|
|
+ filetype={filetype(entry.file.file)}
|
|
|
+ syntaxStyle={themeState.syntax()}
|
|
|
+ showLineNumbers={true}
|
|
|
+ width="100%"
|
|
|
+ wrapMode="word"
|
|
|
+ fg={theme().text}
|
|
|
+ addedBg={theme().diffAddedBg}
|
|
|
+ removedBg={theme().diffRemovedBg}
|
|
|
+ contextBg={theme().diffContextBg}
|
|
|
+ addedSignColor={theme().diffHighlightAdded}
|
|
|
+ removedSignColor={theme().diffHighlightRemoved}
|
|
|
+ lineNumberFg={theme().diffLineNumber}
|
|
|
+ lineNumberBg={theme().diffContextBg}
|
|
|
+ addedLineNumberBg={theme().diffAddedLineNumberBg}
|
|
|
+ removedLineNumberBg={theme().diffRemovedLineNumberBg}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ </box>
|
|
|
+ )}
|
|
|
+ </For>
|
|
|
+ </scrollbox>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
+ </box>
|
|
|
+ </box>
|
|
|
+ </Match>
|
|
|
+ </Switch>
|
|
|
+
|
|
|
+ <box flexDirection="row" gap={2} flexShrink={0}>
|
|
|
+ <Show when={switchFocusShortcut()}>
|
|
|
+ {(shortcut) => (
|
|
|
+ <text fg={theme().text}>
|
|
|
+ {shortcut()} <span style={{ fg: theme().textMuted }}>focus file tree</span>
|
|
|
+ </text>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ <Show when={nextFileShortcut()}>
|
|
|
+ {(shortcut) => (
|
|
|
+ <text fg={theme().text}>
|
|
|
+ {shortcut()} <span style={{ fg: theme().textMuted }}>next file</span>
|
|
|
+ </text>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ <Show when={previousFileShortcut()}>
|
|
|
+ {(shortcut) => (
|
|
|
+ <text fg={theme().text}>
|
|
|
+ {shortcut()} <span style={{ fg: theme().textMuted }}>previous file</span>
|
|
|
+ </text>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ <Show when={toggleFileTreeShortcut()}>
|
|
|
+ {(shortcut) => (
|
|
|
+ <text fg={theme().text}>
|
|
|
+ {shortcut()}{" "}
|
|
|
+ <span style={{ fg: theme().textMuted }}>{showFileTree() ? "hide file tree" : "show file tree"}</span>
|
|
|
+ </text>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ <Show when={singlePatchShortcut()}>
|
|
|
+ {(shortcut) => (
|
|
|
+ <text fg={theme().text}>
|
|
|
+ {shortcut()}{" "}
|
|
|
+ <span style={{ fg: theme().textMuted }}>{singlePatch() ? "all patches" : "single patch"}</span>
|
|
|
+ </text>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ <Show when={switchDiffShortcut()}>
|
|
|
+ {(shortcut) => (
|
|
|
+ <text fg={theme().text}>
|
|
|
+ {shortcut()} <span style={{ fg: theme().textMuted }}>switch diff</span>
|
|
|
+ </text>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ <Show when={toggleViewShortcut()}>
|
|
|
+ {(shortcut) => (
|
|
|
+ <text fg={theme().text}>
|
|
|
+ {shortcut()}{" "}
|
|
|
+ <span style={{ fg: theme().textMuted }}>{view() === "split" ? "unified view" : "split view"}</span>
|
|
|
+ </text>
|
|
|
+ )}
|
|
|
+ </Show>
|
|
|
+ </box>
|
|
|
+ </box>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+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,
|
|
|
+ })
|
|
|
+ api.ui.dialog.clear()
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+export default {
|
|
|
+ id: "diff-viewer",
|
|
|
+ tui,
|
|
|
+}
|