feat(tui): port diff viewer to v2 plugins

This commit is contained in:
Dax Raad 2026-07-14 15:10:40 -04:00
commit b67bed061a
7 changed files with 332 additions and 243 deletions

View file

@ -1,6 +1,5 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { PluginRuntime } from "../plugin/runtime"
import DiffViewer from "./system/diff-viewer"
import Notifications from "./system/notifications"
import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key"
@ -12,7 +11,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
}
export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [Notifications, PluginManager, WhichKey, DiffViewer]
return [Notifications, PluginManager, WhichKey]
}
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {

View file

@ -1,6 +1,7 @@
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
import type { FileDiffInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/v2/tui"
import type { KeymapCommand, Route } from "@opencode-ai/plugin/v2/tui/context"
import {
TextAttributes,
type BorderSides,
@ -9,14 +10,13 @@ import {
type ScrollBoxRenderable,
} from "@opentui/core"
import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
import { useBindings, useCommandShortcut } from "../../keymap"
import { useTheme } from "../../context/theme"
import { useClient } from "../../context/client"
import { useTerminalDimensions } from "@opentui/solid"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select"
import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config"
@ -80,32 +80,36 @@ function diffSourceLabel(mode: DiffMode) {
return "working tree"
}
function DiffViewer(props: { api: TuiPluginApi }) {
function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
const client = useClient()
const config = useConfig()
const dialog = useDialog()
const themeState = useTheme()
const theme = () => props.api.theme.current
const params = () =>
("params" in props.api.route.current ? props.api.route.current.params : undefined) as
const theme = () => themeState.theme
const params = () => {
const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
| {
mode?: DiffMode
sessionID?: string
returnRoute?: TuiRouteCurrent
returnRoute?: Route
}
| undefined
}
const mode = () => params()?.mode ?? "working"
const diffInput = createMemo(() => {
const sessionID = params()?.sessionID
return {
mode: mode(),
sessionID,
directory: sessionID ? props.api.state.session.get(sessionID)?.directory : undefined,
location: sessionID
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
: props.context.data.location.default(),
}
})
const [diff] = createResource(diffInput, async (input) => {
const result = await client.api.vcs.diff({
location: input.directory ? { directory: input.directory } : undefined,
const result = await props.context.client.vcs.diff({
location: input.location,
mode: input.mode,
context: VCS_DIFF_CONTEXT_LINES,
})
@ -120,8 +124,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const defaultView = createMemo(() => {
if (props.api.tuiConfig.diffs?.view === "unified") return "unified"
if (props.api.tuiConfig.diffs?.view === "split") return "split"
if (config.data.diffs?.view === "unified") return "unified"
if (config.data.diffs?.view === "split") return "split"
return splitAvailable() ? "split" : "unified"
})
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(config.data.diffs?.view))
@ -133,21 +137,22 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set())
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(props.api.tuiConfig))
const patchScrollAcceleration = createMemo(() => getScrollAcceleration(config.data))
const fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
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")
const singlePatchShortcut = useCommandShortcut("diff.single_patch")
const switchSourceShortcut = useCommandShortcut("diff.switch_source")
const toggleViewShortcut = useCommandShortcut("diff.toggle_view")
const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed")
const helpShortcut = useCommandShortcut("diff.help")
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
const switchFocusShortcut = shortcut("diff.switch_focus")
const nextHunkShortcut = shortcut("diff.next_hunk")
const previousHunkShortcut = shortcut("diff.previous_hunk")
const nextFileShortcut = shortcut("diff.next_file")
const previousFileShortcut = shortcut("diff.previous_file")
const toggleFileTreeShortcut = shortcut("diff.toggle_file_tree")
const singlePatchShortcut = shortcut("diff.single_patch")
const switchSourceShortcut = shortcut("diff.switch_source")
const toggleViewShortcut = shortcut("diff.toggle_view")
const markReviewedShortcut = shortcut("diff.mark_reviewed")
const helpShortcut = shortcut("diff.help")
let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const diffNodeByFileIndex = new Map<number, DiffRenderable>()
@ -155,7 +160,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
onCleanup(() => props.api.ui.dialog.clear())
onCleanup(() => dialog.clear())
createEffect(() => {
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
@ -412,25 +417,24 @@ function DiffViewer(props: { api: TuiPluginApi }) {
})
}
const commands = [
{
name: "diff.close",
title: "Close diff viewer",
category: "VCS",
run() {
const returnRoute = params()?.returnRoute
props.api.ui.dialog.clear()
const close = () => {
const returnRoute = params()?.returnRoute
dialog.clear()
props.context.ui.router.navigate(returnRoute ?? { type: "home" })
}
props.api.route.navigate(
returnRoute?.name ?? "home",
returnRoute && "params" in returnRoute ? returnRoute.params : undefined,
)
},
const commands: KeymapCommand[] = [
{
id: "diff.close",
title: "Close diff viewer",
group: "VCS",
run: close,
},
{
name: "diff.down",
id: "diff.down",
title: "Move diff viewer down",
category: "VCS",
group: "VCS",
bind: "j,down",
run: focusRunner({
files() {
moveFileSelection(1)
@ -442,9 +446,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}),
},
{
name: "diff.up",
id: "diff.up",
title: "Move diff viewer up",
category: "VCS",
group: "VCS",
bind: "k,up",
run: focusRunner({
files() {
moveFileSelection(-1)
@ -456,9 +461,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}),
},
{
name: "diff.page.down",
id: "diff.page.down",
title: "Page diff viewer down",
category: "VCS",
group: "VCS",
bind: "pagedown,ctrl+f",
run: focusRunner({
files() {
moveFileSelection(8)
@ -470,9 +476,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}),
},
{
name: "diff.page.up",
id: "diff.page.up",
title: "Page diff viewer up",
category: "VCS",
group: "VCS",
bind: "pageup,ctrl+b",
run: focusRunner({
files() {
moveFileSelection(-8)
@ -484,9 +491,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}),
},
{
name: "diff.toggle",
id: "diff.toggle",
title: "Toggle diff viewer item",
category: "VCS",
group: "VCS",
run: focusRunner({
files() {
toggleSelectedFileTreeRow()
@ -495,9 +502,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}),
},
{
name: "diff.expand",
id: "diff.expand",
title: "Expand diff viewer item",
category: "VCS",
group: "VCS",
run: focusRunner({
files() {
const highlighted = highlightedFileNode()
@ -513,9 +520,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}),
},
{
name: "diff.expand_all",
id: "diff.expand_all",
title: "Expand all diff viewer folders",
category: "VCS",
group: "VCS",
run: focusRunner({
files() {
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
@ -524,9 +531,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}),
},
{
name: "diff.collapse",
id: "diff.collapse",
title: "Collapse diff viewer item",
category: "VCS",
group: "VCS",
run: focusRunner({
files() {
const highlighted = highlightedFileNode()
@ -543,49 +550,50 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}),
},
{
name: "diff.next_hunk",
id: "diff.next_hunk",
title: "Jump to next diff hunk",
category: "VCS",
group: "VCS",
run() {
jumpRelativeHunk(1)
},
},
{
name: "diff.previous_hunk",
id: "diff.previous_hunk",
title: "Jump to previous diff hunk",
category: "VCS",
group: "VCS",
run() {
jumpRelativeHunk(-1)
},
},
{
name: "diff.next_file",
id: "diff.next_file",
title: "Jump to next diff file",
category: "VCS",
group: "VCS",
run() {
jumpRelativePatchFile(1)
},
},
{
name: "diff.previous_file",
id: "diff.previous_file",
title: "Jump to previous diff file",
category: "VCS",
group: "VCS",
run() {
jumpRelativePatchFile(-1)
},
},
{
name: "diff.mark_reviewed",
id: "diff.mark_reviewed",
title: "Toggle selected diff file reviewed",
category: "VCS",
group: "VCS",
bind: "m",
run() {
toggleSelectedFileReviewed()
},
},
{
name: "diff.switch_focus",
id: "diff.switch_focus",
title: "Switch diff viewer focus",
category: "VCS",
group: "VCS",
run() {
if (!showFileTree()) return
setFocus((current) => {
@ -596,10 +604,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
},
},
{
name: "diff.toggle_file_tree",
id: "diff.toggle_file_tree",
title: "Toggle diff viewer file tree",
category: "VCS",
hidden: true,
group: "VCS",
run() {
const next = !fileTreeEnabled()
if (!next) setFocus("patches")
@ -612,10 +619,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
},
},
{
name: "diff.single_patch",
id: "diff.single_patch",
title: "Toggle single patch view",
category: "VCS",
hidden: true,
group: "VCS",
run() {
setSelectedHunk(undefined)
if (!singlePatch()) {
@ -648,18 +654,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
},
},
{
name: "diff.switch_source",
id: "diff.switch_source",
title: "Switch diff viewer source",
category: "VCS",
group: "VCS",
run() {
openSwitchDiffDialog()
},
},
{
name: "diff.toggle_view",
id: "diff.toggle_view",
title: "Toggle diff viewer split or unified view",
category: "VCS",
hidden: true,
group: "VCS",
run() {
if (!splitAvailable()) return
setSelectedHunk(undefined)
@ -673,9 +678,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
},
},
{
name: "diff.help",
id: "diff.help",
title: "Show more diff viewer shortcuts",
category: "VCS",
group: "VCS",
run() {
openHelpDialog()
},
@ -698,7 +703,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
})
const openSwitchDiffDialog = () => {
props.api.ui.dialog.replace(() => (
dialog.replace(() => (
<DialogSelect
title="Switch source"
skipFilter={true}
@ -708,10 +713,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
...option,
onSelect(dialog) {
dialog.clear()
props.api.route.navigate(ROUTE, {
mode: option.value,
sessionID: params()?.sessionID,
returnRoute: params()?.returnRoute,
props.context.ui.router.navigate({
type: "plugin",
name: ROUTE,
data: {
mode: option.value,
sessionID: params()?.sessionID,
returnRoute: params()?.returnRoute,
},
})
},
}))}
@ -720,20 +729,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}
const openHelpDialog = () => {
props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />)
props.api.ui.dialog.setSize("large")
dialog.replace(() => <DiffViewerHelpDialog context={props.context} />)
dialog.setSize("large")
}
useBindings(() => ({
props.context.keymap.layer(() => ({
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" },
{ key: "m", cmd: "diff.mark_reviewed", desc: "Mark selected file reviewed" },
...commands.flatMap((command) => props.api.tuiConfig.keybinds.get(command.name)),
],
}))
return (
@ -932,8 +933,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
)
}
function DiffViewerHelpDialog() {
function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
const { theme } = useTheme()
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
const rows = [
{
shortcut: () => "q",
@ -941,57 +943,57 @@ function DiffViewerHelpDialog() {
description: "Quit the diff viewer",
},
{
shortcut: useCommandShortcut("diff.switch_focus"),
shortcut: shortcut("diff.switch_focus"),
action: "Focus file tree",
description: "Move keyboard focus between the file tree and patch pane",
},
{
shortcut: useCommandShortcut("diff.next_hunk"),
shortcut: shortcut("diff.next_hunk"),
action: "Next hunk",
description: "Jump to the next diff hunk",
},
{
shortcut: useCommandShortcut("diff.previous_hunk"),
shortcut: shortcut("diff.previous_hunk"),
action: "Previous hunk",
description: "Jump to the previous diff hunk",
},
{
shortcut: useCommandShortcut("diff.next_file"),
shortcut: shortcut("diff.next_file"),
action: "Next file",
description: "Select the next changed file in file-tree order",
},
{
shortcut: useCommandShortcut("diff.previous_file"),
shortcut: shortcut("diff.previous_file"),
action: "Previous file",
description: "Select the previous changed file in file-tree order",
},
{
shortcut: useCommandShortcut("diff.toggle_file_tree"),
shortcut: shortcut("diff.toggle_file_tree"),
action: "Toggle file tree",
description: "Show or hide the file tree sidebar",
},
{
shortcut: useCommandShortcut("diff.single_patch"),
shortcut: shortcut("diff.single_patch"),
action: "Toggle patches",
description: "Switch between one selected patch and all patches",
},
{
shortcut: useCommandShortcut("diff.switch_source"),
shortcut: shortcut("diff.switch_source"),
action: "Switch source",
description: "Choose working tree or main branch changes",
},
{
shortcut: useCommandShortcut("diff.toggle_view"),
shortcut: shortcut("diff.toggle_view"),
action: "Toggle view",
description: "Switch between split and unified diff layout",
},
{
shortcut: useCommandShortcut("diff.expand_all"),
shortcut: shortcut("diff.expand_all"),
action: "Expand all folders",
description: "Open every folder in the file tree",
},
{
shortcut: useCommandShortcut("diff.mark_reviewed"),
shortcut: shortcut("diff.mark_reviewed"),
action: "Mark reviewed",
description: "Toggle reviewed state for the selected file",
},
@ -1031,36 +1033,54 @@ function DiffViewerHelpDialog() {
)
}
const tui: TuiPlugin = async (api) => {
api.route.register([
{
name: ROUTE,
render: () => <DiffViewer api={api} />,
},
])
api.keymap.registerLayer({
function Commands(props: { context: Plugin.Context }) {
const dialog = useDialog()
props.context.keymap.layer(() => ({
mode: "global",
commands: [
{
name: "diff.open",
id: "diff.open",
title: "Open diff viewer",
slash: { name: "diff" },
category: "VCS",
namespace: "palette",
group: "VCS",
palette: true,
run() {
api.route.navigate(ROUTE, {
mode: "working",
sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined,
returnRoute: api.route.current,
const route = props.context.ui.router.current()
const returnRoute: Route =
route.type === "home"
? { type: "home" }
: route.type === "session"
? { type: "session", sessionID: route.sessionID }
: {
type: "plugin",
id: route.id,
name: route.name,
...(route.data ? { data: { ...route.data } } : {}),
}
props.context.ui.router.navigate({
type: "plugin",
name: ROUTE,
data: {
mode: "working",
sessionID: route.type === "session" ? route.sessionID : undefined,
returnRoute,
},
})
api.ui.dialog.clear()
dialog.clear()
},
},
],
})
}))
return null
}
export default {
export default Plugin.define({
id: "diff-viewer",
tui,
}
setup(context) {
context.ui.router.register({
name: ROUTE,
render: () => <DiffViewer context={context} />,
})
context.ui.slot("app", () => <Commands context={context} />)
},
})