mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-21 18:26:09 +00:00
feat(tui): add diff hunk navigation (#30935)
This commit is contained in:
@@ -65,6 +65,8 @@ export const Definitions = {
|
||||
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_hunk: keybind("]", "Jump to next diff hunk"),
|
||||
diff_previous_hunk: keybind("[", "Jump to previous diff hunk"),
|
||||
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"),
|
||||
@@ -267,6 +269,8 @@ export const CommandMap = {
|
||||
diff_expand_all: "diff.expand_all",
|
||||
diff_collapse: "diff.collapse",
|
||||
diff_switch_focus: "diff.switch_focus",
|
||||
diff_next_hunk: "diff.next_hunk",
|
||||
diff_previous_hunk: "diff.previous_hunk",
|
||||
diff_next_file: "diff.next_file",
|
||||
diff_previous_file: "diff.previous_file",
|
||||
diff_toggle_file_tree: "diff.toggle_file_tree",
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
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 ScrollBoxRenderable } from "@opentui/core"
|
||||
import {
|
||||
TextAttributes,
|
||||
type BorderSides,
|
||||
type BoxRenderable,
|
||||
type DiffRenderable,
|
||||
type ScrollBoxRenderable,
|
||||
} from "@opentui/core"
|
||||
import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
|
||||
import { useBindings, useCommandShortcut } from "@tui/keymap"
|
||||
import { useTheme } from "@tui/context/theme"
|
||||
@@ -40,6 +46,7 @@ const KV_VIEW = "diff_viewer_view"
|
||||
type DiffMode = "git" | "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
|
||||
@@ -143,6 +150,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
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")
|
||||
@@ -153,6 +162,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
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)
|
||||
|
||||
@@ -164,6 +175,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
setLastHighlightedFileNode(undefined)
|
||||
setActivePatchFileIndex(undefined)
|
||||
setSelectedFileIndex(undefined)
|
||||
setSelectedHunk(undefined)
|
||||
setReviewedFileNames(new Set<string>())
|
||||
})
|
||||
|
||||
@@ -189,6 +201,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
const clearFileTreePatchState = () => {
|
||||
setHighlightedFileNode(undefined)
|
||||
setActivePatchFileIndex(undefined)
|
||||
setSelectedHunk(undefined)
|
||||
}
|
||||
|
||||
const scrollPatchNodeToTop = (patchNode: BoxRenderable) => {
|
||||
@@ -227,6 +240,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
|
||||
const jumpToFileIndex = (fileIndex: number | undefined) => {
|
||||
if (fileIndex === undefined) return
|
||||
setSelectedHunk(undefined)
|
||||
scrollToFileIndex(fileIndex)
|
||||
}
|
||||
|
||||
@@ -248,6 +262,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
}
|
||||
|
||||
const jumpRelativePatchFile = (offset: number) => {
|
||||
setSelectedHunk(undefined)
|
||||
const next = movePatchFileIndex(patchFileIndexes(), selectedFileIndex() ?? activePatchFileIndex(), offset)
|
||||
if (singlePatch()) {
|
||||
if (next === undefined) return
|
||||
@@ -258,6 +273,38 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
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.getHunkRowOffsets().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(() => {
|
||||
@@ -504,6 +551,22 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
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",
|
||||
@@ -557,6 +620,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
title: "Toggle single patch view",
|
||||
category: "VCS",
|
||||
run() {
|
||||
setSelectedHunk(undefined)
|
||||
if (!singlePatch()) {
|
||||
ensureHighlightedPatchFile()
|
||||
setSinglePatch(true)
|
||||
@@ -592,6 +656,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
category: "VCS",
|
||||
run() {
|
||||
if (!splitAvailable()) return
|
||||
setSelectedHunk(undefined)
|
||||
const next = view() === "split" ? "unified" : "split"
|
||||
setViewOverride(next)
|
||||
props.api.kv.set(KV_VIEW, next)
|
||||
@@ -716,6 +781,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
<Panel flexGrow={1} minHeight={0} border="none">
|
||||
<Separator axis="x" start={showFileTree() ? "edge-out" : undefined} />
|
||||
<scrollbox
|
||||
id="diff-viewer-patches"
|
||||
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||
flexGrow={1}
|
||||
minHeight={0}
|
||||
@@ -755,6 +821,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
{(patch) => (
|
||||
<box border={patchLeftBorder()} borderColor={theme().border}>
|
||||
<diff
|
||||
id={`diff-viewer-patch-${entry.fileIndex}`}
|
||||
ref={(element: DiffRenderable) => diffNodeByFileIndex.set(entry.fileIndex, element)}
|
||||
diff={patch()}
|
||||
view={view()}
|
||||
filetype={reviewed() ? PLAIN_TEXT_FILETYPE : filetype(entry.file.file)}
|
||||
@@ -808,6 +876,20 @@ function DiffViewer(props: { api: TuiPluginApi }) {
|
||||
</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}>
|
||||
@@ -855,6 +937,16 @@ function DiffViewerHelpDialog() {
|
||||
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",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { mkdir } from "fs/promises"
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import type { DiffRenderable, Renderable, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { testRender, useRenderer } from "@opentui/solid"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
|
||||
@@ -10,13 +11,98 @@ import type { Session } from "@opencode-ai/sdk/v2"
|
||||
import { KVProvider } from "../../../src/cli/cmd/tui/context/kv"
|
||||
import { ThemeProvider } from "../../../src/cli/cmd/tui/context/theme"
|
||||
import { TuiConfigProvider } from "../../../src/cli/cmd/tui/context/tui-config"
|
||||
import { TuiKeybind } from "../../../src/cli/cmd/tui/config/keybind"
|
||||
import { OpencodeKeymapProvider } from "../../../src/cli/cmd/tui/keymap"
|
||||
import diffViewerPlugin from "../../../src/cli/cmd/tui/feature-plugins/system/diff-viewer"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
test("closing the diff viewer returns to the route it opened from", async () => {
|
||||
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
|
||||
const viewer = await renderDiffViewer([])
|
||||
try {
|
||||
expect(viewer.current()).toEqual({
|
||||
name: "diff",
|
||||
params: { mode: "git", sessionID: "session-1", returnRoute: startRoute },
|
||||
})
|
||||
expect(viewer.vcsDiffInput()).toEqual({ directory: "/repo/session", mode: "git", context: 12 })
|
||||
|
||||
expect(viewer.commands.has("diff.close")).toBe(true)
|
||||
viewer.commands.get("diff.close")!.run?.({} as never)
|
||||
expect(viewer.current()).toEqual(startRoute)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("brackets navigate diff hunks", async () => {
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "src/file.ts",
|
||||
additions: 3,
|
||||
deletions: 3,
|
||||
status: "modified",
|
||||
patch: `--- a/src/file.ts
|
||||
+++ b/src/file.ts
|
||||
@@ -1,3 +1,3 @@
|
||||
const first = true
|
||||
-const oldFirst = true
|
||||
+const newFirst = true
|
||||
const afterFirst = true
|
||||
@@ -20,3 +20,3 @@
|
||||
const second = true
|
||||
-const oldSecond = true
|
||||
+const newSecond = true
|
||||
const afterSecond = true
|
||||
@@ -40,3 +40,3 @@
|
||||
const third = true
|
||||
-const oldThird = true
|
||||
+const newThird = true
|
||||
const afterThird = true`,
|
||||
},
|
||||
],
|
||||
12,
|
||||
)
|
||||
try {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
|
||||
await viewer.app.waitFor(() => Boolean(findRenderable(viewer.app.renderer.root, "diff-viewer-patches")))
|
||||
await viewer.app.flush()
|
||||
const scroll = findRenderable(viewer.app.renderer.root, "diff-viewer-patches") as ScrollBoxRenderable
|
||||
const diff = findRenderable(viewer.app.renderer.root, "diff-viewer-patch-0") as DiffRenderable
|
||||
expect(diff.getHunkRowOffsets()).toEqual([0, 4, 8])
|
||||
const initial = scroll.scrollTop
|
||||
|
||||
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
|
||||
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
const first = scroll.scrollTop
|
||||
expect(first).toBeGreaterThan(initial)
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
const second = scroll.scrollTop
|
||||
expect(second).toBeGreaterThan(first)
|
||||
|
||||
viewer.commands.get("diff.previous_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(first)
|
||||
|
||||
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(second)
|
||||
|
||||
scroll.scrollTo(initial)
|
||||
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
|
||||
await viewer.app.renderOnce()
|
||||
expect(scroll.scrollTop).toBe(first)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
async function renderDiffViewer(vcsDiff: unknown[], height = 20) {
|
||||
const commands = new Map<
|
||||
string,
|
||||
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
|
||||
@@ -24,6 +110,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
let current = startRoute
|
||||
let renderDiff: TuiRouteDefinition["render"] | undefined
|
||||
let vcsDiffInput: unknown
|
||||
const config = createTuiResolvedConfig()
|
||||
await mkdir(Global.Path.state, { recursive: true })
|
||||
await Bun.write(path.join(Global.Path.state, "kv.json"), "{}")
|
||||
|
||||
@@ -41,7 +128,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
vcs: {
|
||||
diff: async (input: unknown) => {
|
||||
vcsDiffInput = input
|
||||
return { data: [] }
|
||||
return { data: vcsDiff }
|
||||
},
|
||||
},
|
||||
session: { diff: async () => ({ data: [] }) },
|
||||
@@ -73,7 +160,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
|
||||
return (
|
||||
<OpencodeKeymapProvider keymap={keymap}>
|
||||
<TuiConfigProvider config={createTuiResolvedConfig()}>
|
||||
<TuiConfigProvider config={config}>
|
||||
<KVProvider>
|
||||
<ThemeProvider mode="dark">
|
||||
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
|
||||
@@ -84,19 +171,25 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
)
|
||||
}
|
||||
|
||||
const app = await testRender(() => <Harness />, { width: 80, height: 20 })
|
||||
try {
|
||||
await waitForCommand(app, commands, "diff.close")
|
||||
expect(current).toEqual({ name: "diff", params: { mode: "git", sessionID: "session-1", returnRoute: startRoute } })
|
||||
expect(vcsDiffInput).toEqual({ directory: "/repo/session", mode: "git", context: 12 })
|
||||
|
||||
expect(commands.has("diff.close")).toBe(true)
|
||||
commands.get("diff.close")!.run?.({} as never)
|
||||
expect(current).toEqual(startRoute)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
const app = await testRender(() => <Harness />, { width: 80, height })
|
||||
await waitForCommand(app, commands, "diff.close")
|
||||
return {
|
||||
app,
|
||||
commands,
|
||||
current: () => current,
|
||||
vcsDiffInput: () => vcsDiffInput,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } }
|
||||
|
||||
function findRenderable(root: Renderable, id: string): Renderable | undefined {
|
||||
if (root.id === id) return root
|
||||
return root
|
||||
.getChildren()
|
||||
.map((child) => findRenderable(child, id))
|
||||
.find(Boolean)
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: "session-1",
|
||||
|
||||
Reference in New Issue
Block a user