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

This commit is contained in:
Shoubhit Dash
2026-06-05 16:56:53 +05:30
committed by GitHub
parent edbe2280a5
commit 7a4d18390a
3 changed files with 205 additions and 16 deletions
@@ -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",