fix(tui): empty states, context, and minor improvements to diff viewer (#28878)
This commit is contained in:
@@ -62,14 +62,16 @@ export const Definitions = {
|
||||
diff_close: keybind("escape,q", "Close diff viewer"),
|
||||
diff_toggle: keybind("enter,space", "Toggle diff viewer item"),
|
||||
diff_expand: keybind("right", "Expand diff viewer item"),
|
||||
diff_expand_all: keybind("E", "Expand all diff viewer folders"),
|
||||
diff_collapse: keybind("left", "Collapse diff viewer item"),
|
||||
diff_switch_focus: keybind("tab", "Switch diff viewer focus"),
|
||||
diff_next_file: keybind("n", "Jump to next diff file"),
|
||||
diff_previous_file: keybind("p", "Jump to previous diff file"),
|
||||
diff_toggle_file_tree: keybind("b", "Toggle diff viewer file tree"),
|
||||
diff_single_patch: keybind("s", "Toggle single patch view"),
|
||||
diff_switch_diff: keybind("d", "Switch diff viewer source"),
|
||||
diff_switch_source: keybind("d", "Switch diff viewer source"),
|
||||
diff_toggle_view: keybind("v", "Toggle diff viewer split or unified view"),
|
||||
diff_help: keybind("?", "Show more diff viewer shortcuts"),
|
||||
|
||||
editor_open: keybind("<leader>e", "Open external editor"),
|
||||
theme_list: keybind("<leader>t", "List available themes"),
|
||||
@@ -259,14 +261,16 @@ export const CommandMap = {
|
||||
diff_close: "diff.close",
|
||||
diff_toggle: "diff.toggle",
|
||||
diff_expand: "diff.expand",
|
||||
diff_expand_all: "diff.expand_all",
|
||||
diff_collapse: "diff.collapse",
|
||||
diff_switch_focus: "diff.switch_focus",
|
||||
diff_next_file: "diff.next_file",
|
||||
diff_previous_file: "diff.previous_file",
|
||||
diff_toggle_file_tree: "diff.toggle_file_tree",
|
||||
diff_single_patch: "diff.single_patch",
|
||||
diff_switch_diff: "diff.switch_diff",
|
||||
diff_switch_source: "diff.switch_source",
|
||||
diff_toggle_view: "diff.toggle_view",
|
||||
diff_help: "diff.help",
|
||||
editor_open: "prompt.editor",
|
||||
theme_list: "theme.switch",
|
||||
theme_switch_mode: "theme.switch_mode",
|
||||
|
||||
@@ -179,23 +179,17 @@ export function orderedPatchFileIndexes(rows: readonly FileTreeRow[]) {
|
||||
return rows.flatMap((row) => (row.fileIndex === undefined ? [] : [row.fileIndex]))
|
||||
}
|
||||
|
||||
export function showDiffViewerFileTree(showFileTree: boolean, fileCount: number) {
|
||||
return showFileTree && fileCount > 0
|
||||
}
|
||||
|
||||
export function movePatchFileIndex(fileIndexes: readonly number[], current: number | undefined, offset: number) {
|
||||
if (fileIndexes.length === 0) return undefined
|
||||
const index = current === undefined ? -1 : fileIndexes.indexOf(current)
|
||||
if (index === -1) return offset < 0 ? fileIndexes[fileIndexes.length - 1] : fileIndexes[0]
|
||||
if (index === -1) return fileIndexes[0]
|
||||
return fileIndexes[Math.max(0, Math.min(fileIndexes.length - 1, index + offset))]
|
||||
}
|
||||
|
||||
export function relativePatchFileIndexFromViewport(
|
||||
entries: readonly { readonly fileIndex: number; readonly titleContentY: number }[],
|
||||
scrollTop: number,
|
||||
offset: number,
|
||||
) {
|
||||
const ordered = [...entries].sort((left, right) => left.titleContentY - right.titleContentY)
|
||||
if (offset > 0) return ordered.find((entry) => entry.titleContentY > scrollTop)?.fileIndex
|
||||
return ordered.findLast((entry) => entry.titleContentY < scrollTop)?.fileIndex
|
||||
}
|
||||
|
||||
export function allExpandedFileTreeDirectories(tree: FileTree) {
|
||||
return new Set(tree.nodes.filter((node) => node.kind === "directory").map((node) => node.id))
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createEffect, createMemo, For, Match, Switch } 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_HORIZONTAL_PADDING = 2
|
||||
const FILE_TREE_STATUS_WIDTH = 2
|
||||
|
||||
export type DiffViewerFileTreeTheme = {
|
||||
@@ -32,6 +31,7 @@ export type DiffViewerFileTreeProps = {
|
||||
readonly selectedFileIndex?: number
|
||||
readonly reviewedFileNames?: ReadonlySet<string>
|
||||
readonly expandedNodes?: ReadonlySet<number>
|
||||
readonly onRowClick?: (row: FileTreeRow) => void
|
||||
}
|
||||
|
||||
export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
@@ -72,20 +72,18 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
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)
|
||||
return file !== undefined && (props.reviewedFileNames?.has(file) ?? false)
|
||||
}
|
||||
const prefix = () => fileTreeRowPrefix(rows(), index(), row, props.expandedNodes)
|
||||
const status = () => fileTreeRowStatus(row, props.files)
|
||||
const status = () => fileTreeRowStatus(row, props.files, reviewed())
|
||||
const name = () =>
|
||||
Locale.truncate(
|
||||
row.name,
|
||||
Math.max(1, props.width - FILE_TREE_HORIZONTAL_PADDING - prefix().length - status().length),
|
||||
)
|
||||
Locale.truncate(row.name, Math.max(1, props.width - FILE_TREE_STATUS_WIDTH - prefix().length))
|
||||
return (
|
||||
<box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
backgroundColor={highlighted() ? props.theme.primary : undefined}
|
||||
onMouseUp={() => props.onRowClick?.(row)}
|
||||
>
|
||||
<text fg={highlighted() ? props.theme.background : fadedColor()} wrapMode="none" flexShrink={0}>
|
||||
{prefix()}
|
||||
@@ -97,11 +95,9 @@ export function DiffViewerFileTree(props: DiffViewerFileTreeProps) {
|
||||
? props.theme.background
|
||||
: selected()
|
||||
? props.theme.primary
|
||||
: reviewed()
|
||||
: reviewed() || row.kind === "directory"
|
||||
? props.theme.textMuted
|
||||
: row.kind === "directory"
|
||||
? tint(props.theme.text, props.theme.background, 0.35)
|
||||
: props.theme.text
|
||||
: props.theme.text
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
@@ -158,11 +154,9 @@ function hasLaterSibling(rows: readonly FileTreeRow[], index: number, depth: num
|
||||
return rows.slice(index + 1).find((row) => row.depth <= depth)?.depth === depth
|
||||
}
|
||||
|
||||
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[]) {
|
||||
function fileTreeRowStatus(row: FileTreeRow, files: readonly FileTreeItem[], reviewed: boolean) {
|
||||
if (row.fileIndex === undefined) return ""
|
||||
const status = files[row.fileIndex]?.status
|
||||
if (status === "modified") return "M".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
if (status === "added") return "A".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
if (status === "deleted") return "D".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
return "?".padStart(FILE_TREE_STATUS_WIDTH)
|
||||
const marker = status === "modified" ? "M" : status === "added" ? "A" : status === "deleted" ? "D" : "?"
|
||||
return `${reviewed ? "✓" : " "}${marker}`.padStart(FILE_TREE_STATUS_WIDTH)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { BorderSides, ColorInput } from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
import { createContext, splitProps, useContext } from "solid-js"
|
||||
import { createContext, Show, splitProps, useContext } from "solid-js"
|
||||
|
||||
export type Axis = "x" | "y"
|
||||
export type SeparatorEdge = "edge" | "edge-in" | "edge-out"
|
||||
@@ -63,22 +63,30 @@ export function Separator(props: { axis?: Axis; color?: ColorInput; start?: Sepa
|
||||
const color = () => props.color ?? theme.border
|
||||
const axis = () => props.axis ?? crossAxis(group?.axis ?? "y")
|
||||
if (axis() === "y") {
|
||||
if (!props.start && !props.end) return <box width={1} flexShrink={0} border={["left"]} borderColor={color()} />
|
||||
return (
|
||||
<box width={1} flexShrink={0} flexDirection="column">
|
||||
{props.start && <text fg={color()}>{verticalEdge(props.start, "start")}</text>}
|
||||
<box flexGrow={1} border={["left"]} borderColor={color()} />
|
||||
{props.end && <text fg={color()}>{verticalEdge(props.end, "end")}</text>}
|
||||
</box>
|
||||
<Show
|
||||
when={props.start || props.end}
|
||||
fallback={<box width={1} flexShrink={0} border={["left"]} borderColor={color()} />}
|
||||
>
|
||||
<box width={1} flexShrink={0} flexDirection="column">
|
||||
<Show when={props.start}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "start")}</text>}</Show>
|
||||
<box flexGrow={1} border={["left"]} borderColor={color()} />
|
||||
<Show when={props.end}>{(edge) => <text fg={color()}>{verticalEdge(edge(), "end")}</text>}</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
if (!props.start && !props.end) return <box height={1} flexShrink={0} border={["top"]} borderColor={color()} />
|
||||
return (
|
||||
<box height={1} flexShrink={0} flexDirection="row">
|
||||
{props.start && <text fg={color()}>{horizontalEdge(props.start, "start")}</text>}
|
||||
<box flexGrow={1} border={["top"]} borderColor={color()} />
|
||||
{props.end && <text fg={color()}>{horizontalEdge(props.end, "end")}</text>}
|
||||
</box>
|
||||
<Show
|
||||
when={props.start || props.end}
|
||||
fallback={<box height={1} flexShrink={0} border={["top"]} borderColor={color()} />}
|
||||
>
|
||||
<box height={1} flexShrink={0} flexDirection="row">
|
||||
<Show when={props.start}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "start")}</text>}</Show>
|
||||
<box flexGrow={1} border={["top"]} borderColor={color()} />
|
||||
<Show when={props.end}>{(edge) => <text fg={color()}>{horizontalEdge(edge(), "end")}</text>}</Show>
|
||||
</box>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @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 { TextAttributes, type BorderSides, type BoxRenderable, type ScrollBoxRenderable } from "@opentui/core"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import { useBindings, useCommandShortcut } from "@tui/keymap"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
@@ -15,15 +15,15 @@ import {
|
||||
allExpandedFileTreeDirectories,
|
||||
buildFileTree,
|
||||
fileTreeFileSelection,
|
||||
type FileTreeRow,
|
||||
flattenFileTree,
|
||||
moveFileTreeSelection,
|
||||
moveFileTreeSelectionToFirstChild,
|
||||
moveFileTreeSelectionToFile,
|
||||
moveFileTreeSelectionToParent,
|
||||
movePatchFileIndex,
|
||||
orderedPatchFileIndexes,
|
||||
relativePatchFileIndexFromViewport,
|
||||
setFileTreeDirectoryExpanded,
|
||||
showDiffViewerFileTree,
|
||||
singlePatchFileIndex,
|
||||
toggleFileTreeDirectory,
|
||||
} from "./diff-viewer-file-tree-utils"
|
||||
@@ -32,8 +32,13 @@ const ROUTE = "diff"
|
||||
const MIN_SPLIT_WIDTH = 100
|
||||
const FILE_TREE_WIDTH = 32
|
||||
const PLAIN_TEXT_FILETYPE = "opencode-plain-text"
|
||||
const WORKING_TREE_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" | "last-turn"
|
||||
type DiffViewerFocus = "patches" | "files"
|
||||
type DiffView = "split" | "unified"
|
||||
|
||||
type DiffFile = {
|
||||
readonly file: string
|
||||
@@ -65,6 +70,10 @@ function filetype(input?: string) {
|
||||
return language
|
||||
}
|
||||
|
||||
function storedView(value: unknown): DiffView | undefined {
|
||||
if (value === "split" || value === "unified") return value
|
||||
}
|
||||
|
||||
function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const themeState = useTheme()
|
||||
@@ -90,20 +99,25 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
return normalizeDiffs(result.data ?? [])
|
||||
}
|
||||
|
||||
const result = await props.api.client.vcs.diff({ mode: "git" }, { throwOnError: true })
|
||||
const result = await props.api.client.vcs.diff(
|
||||
{ mode: "git", context: WORKING_TREE_DIFF_CONTEXT_LINES },
|
||||
{ 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 [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<"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())
|
||||
@@ -120,12 +134,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
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 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 [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
||||
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
|
||||
@@ -216,48 +232,15 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
return entries.findLast((entry) => entry.contentY <= viewportContentY)?.fileIndex ?? entries[0]?.fileIndex
|
||||
}
|
||||
|
||||
const nextPatchFileIndexFromViewport = (offset: number) => {
|
||||
if (!scroll) return undefined
|
||||
return relativePatchFileIndexFromViewport(
|
||||
patchFileIndexes()
|
||||
.map((fileIndex) => ({ fileIndex, node: patchNodeByFileIndex.get(fileIndex) }))
|
||||
.filter((entry): entry is { fileIndex: number; node: BoxRenderable } => Boolean(entry.node))
|
||||
.map((entry) => {
|
||||
const contentY = scroll!.scrollTop + entry.node.y - scroll!.viewport.y
|
||||
return {
|
||||
fileIndex: entry.fileIndex,
|
||||
titleContentY: contentY + (contentY === 0 ? 0 : 1),
|
||||
}
|
||||
}),
|
||||
scroll.scrollTop,
|
||||
offset,
|
||||
)
|
||||
}
|
||||
|
||||
const jumpRelativePatchFile = (offset: number) => {
|
||||
const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset)
|
||||
if (singlePatch()) {
|
||||
const next = movePatchFileIndex(
|
||||
patchFileIndexes(),
|
||||
visiblePatchFiles()[0]?.fileIndex ?? selectedFileIndex() ?? activePatchFileIndex() ?? firstPatchFileIndex(),
|
||||
offset,
|
||||
)
|
||||
if (next === undefined) return
|
||||
selectPatchFile(next)
|
||||
scrollSinglePatchToTop()
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
scrollToFileIndex(
|
||||
nextPatchFileIndexFromViewport(offset) ??
|
||||
movePatchFileIndex(patchFileIndexes(), currentPatchFileIndex() ?? activePatchFileIndex(), offset),
|
||||
)
|
||||
scrollToFileIndex(next)
|
||||
}
|
||||
|
||||
const highlightedPatchFileIndex = () => fileRows().find((row) => row.id === highlightedFileNode())?.fileIndex
|
||||
@@ -305,8 +288,26 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -317,6 +318,13 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
visiblePatchFiles()
|
||||
dimensions()
|
||||
view()
|
||||
measurePatchFiller()
|
||||
})
|
||||
|
||||
const toggleSelectedFileTreeRow = () => {
|
||||
const highlighted = fileRows().find((row) => row.id === highlightedFileNode())
|
||||
if (highlighted?.fileIndex !== undefined) {
|
||||
@@ -326,6 +334,16 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
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"
|
||||
@@ -435,6 +453,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
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",
|
||||
@@ -496,10 +525,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
title: "Toggle diff viewer file tree",
|
||||
category: "VCS",
|
||||
run() {
|
||||
setShowFileTree((value) => {
|
||||
if (value) setFocus("patches")
|
||||
return !value
|
||||
})
|
||||
const next = !fileTreeEnabled()
|
||||
if (!next) setFocus("patches")
|
||||
setFileTreeEnabled(next)
|
||||
props.api.kv.set(KV_SHOW_FILE_TREE, next)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -510,6 +539,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
if (!singlePatch()) {
|
||||
ensureHighlightedPatchFile()
|
||||
setSinglePatch(true)
|
||||
props.api.kv.set(KV_SINGLE_PATCH, true)
|
||||
scrollSinglePatchToTop()
|
||||
return
|
||||
}
|
||||
@@ -523,11 +553,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
)
|
||||
if (fileIndex !== undefined) selectPatchFile(fileIndex)
|
||||
setSinglePatch(false)
|
||||
props.api.kv.set(KV_SINGLE_PATCH, false)
|
||||
if (fileIndex !== undefined) scrollToPatchFileIndexAfterRender(fileIndex)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "diff.switch_diff",
|
||||
name: "diff.switch_source",
|
||||
title: "Switch diff viewer source",
|
||||
category: "VCS",
|
||||
run() {
|
||||
@@ -540,7 +571,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
category: "VCS",
|
||||
run() {
|
||||
if (!splitAvailable()) return
|
||||
setViewOverride(view() === "split" ? "unified" : "split")
|
||||
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()
|
||||
},
|
||||
},
|
||||
]
|
||||
@@ -580,6 +621,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
))
|
||||
}
|
||||
|
||||
const openHelpDialog = () => {
|
||||
props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />)
|
||||
props.api.ui.dialog.setSize("large")
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
commands,
|
||||
bindings: [
|
||||
@@ -610,10 +656,23 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
<box flexGrow={1} minHeight={0}>
|
||||
<Switch>
|
||||
<Match when={diff.loading}>
|
||||
<box flexGrow={1} alignItems="center" justifyContent="center">
|
||||
<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()}>
|
||||
@@ -628,93 +687,83 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
selectedFileIndex={selectedFileIndex()}
|
||||
reviewedFileNames={reviewedFileNames()}
|
||||
expandedNodes={expandedFileNodes()}
|
||||
onRowClick={clickFileTreeRow}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Panel flexGrow={1} minHeight={0} border="none">
|
||||
<Separator axis="x" start="edge-out" />
|
||||
<Switch>
|
||||
<Match when={diff.error}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().error}>Failed to load diff</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length === 0}>
|
||||
<box paddingTop={1}>
|
||||
<text fg={theme().textMuted}>No diff to show</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={files().length > 0}>
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={visiblePatchFiles()}>
|
||||
{(entry, index) => {
|
||||
const reviewed = () => reviewedFileNames().has(entry.file.file)
|
||||
return (
|
||||
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
|
||||
{index() !== 0 ? <Separator axis="x" start="edge" /> : null}
|
||||
<box
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
border={["left"]}
|
||||
borderColor={theme().border}
|
||||
>
|
||||
<text fg={reviewed() ? theme().textMuted : theme().text}>{entry.file.file}</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={reviewed() ? theme().textMuted : theme().diffAdded}>
|
||||
+{entry.file.additions}
|
||||
</text>
|
||||
<text fg={reviewed() ? theme().textMuted : theme().diffRemoved}>
|
||||
-{entry.file.deletions}
|
||||
</text>
|
||||
<Separator axis="x" start={showFileTree() ? "edge-out" : undefined} />
|
||||
<scrollbox
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
verticalScrollbarOptions={{ visible: false }}
|
||||
horizontalScrollbarOptions={{ visible: false }}
|
||||
>
|
||||
<For each={visiblePatchFiles()}>
|
||||
{(entry, index) => {
|
||||
const reviewed = () => reviewedFileNames().has(entry.file.file)
|
||||
return (
|
||||
<box ref={(element: BoxRenderable) => registerPatchNode(entry.fileIndex, element)}>
|
||||
{index() !== 0 ? <Separator axis="x" start={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
|
||||
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>
|
||||
<Separator axis="x" start="edge" />
|
||||
<Show
|
||||
when={entry.file.patch}
|
||||
fallback={<text fg={theme().textMuted}>No patch available for this file.</text>}
|
||||
>
|
||||
{(patch) => (
|
||||
<box border={["left"]} borderColor={theme().border}>
|
||||
<diff
|
||||
diff={patch()}
|
||||
view={view()}
|
||||
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||
syntaxStyle={themeState.syntax()}
|
||||
showLineNumbers={true}
|
||||
width="100%"
|
||||
wrapMode="char"
|
||||
fg={reviewed() ? theme().textMuted : theme().text}
|
||||
addedBg={reviewed() ? theme().backgroundElement : theme().diffAddedBg}
|
||||
removedBg={reviewed() ? theme().backgroundElement : theme().diffRemovedBg}
|
||||
addedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightAdded}
|
||||
removedSignColor={reviewed() ? theme().textMuted : theme().diffHighlightRemoved}
|
||||
lineNumberFg={theme().diffLineNumber}
|
||||
addedLineNumberBg={
|
||||
reviewed() ? theme().backgroundElement : theme().diffAddedLineNumberBg
|
||||
}
|
||||
removedLineNumberBg={
|
||||
reviewed() ? theme().backgroundElement : theme().diffRemovedLineNumberBg
|
||||
}
|
||||
/>
|
||||
</box>
|
||||
)}
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</scrollbox>
|
||||
</Match>
|
||||
</Switch>
|
||||
<Separator axis="x" start="edge-in" />
|
||||
)}
|
||||
</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>
|
||||
@@ -743,34 +792,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={toggleFileTreeShortcut()}>
|
||||
<Show when={switchSourceShortcut()}>
|
||||
{(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>
|
||||
{shortcut()} <span style={{ fg: theme().textMuted }}>switch source</span>
|
||||
</text>
|
||||
)}
|
||||
</Show>
|
||||
@@ -781,12 +806,103 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
</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: useCommandShortcut("diff.switch_focus"),
|
||||
action: "Focus file tree",
|
||||
description: "Move keyboard focus between the file tree and patch pane.",
|
||||
},
|
||||
{
|
||||
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 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([
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user