فهرست منبع

feat(tui): add diff hunk navigation (#30935)

Shoubhit Dash 2 ماه پیش
والد
کامیت
7a4d18390a

+ 4 - 0
packages/opencode/src/cli/cmd/tui/config/keybind.ts

@@ -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",

+ 93 - 1
packages/opencode/src/cli/cmd/tui/feature-plugins/system/diff-viewer.tsx

@@ -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",

+ 108 - 15
packages/opencode/test/cli/tui/diff-viewer.test.tsx

@@ -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",