feat(tui): port diff viewer to v2 plugins

This commit is contained in:
Dax Raad
2026-07-14 15:10:40 -04:00
parent b2741d2f97
commit b67bed061a
7 changed files with 335 additions and 246 deletions
+57
View File
@@ -19,6 +19,7 @@ import type {
ShellInfo, ShellInfo,
SkillInfo, SkillInfo,
} from "@opencode-ai/client" } from "@opencode-ai/client"
import type { Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid" import type { JSX } from "@opentui/solid"
interface LocationCollection<Value> { interface LocationCollection<Value> {
@@ -105,6 +106,61 @@ export interface Page {
export type Slot = (props: Record<string, any>) => JSX.Element export type Slot = (props: Record<string, any>) => JSX.Element
export interface KeymapCommand {
/** Stable command and config keybind identifier. Omit for an inline command. */
readonly id?: string
/** Optional label used by command discovery and keyboard-help UI. */
readonly title?: string
/** Optional longer description. */
readonly description?: string
/** Groups the command in discovery and keyboard-help UI. */
readonly group?: string
/** Enables or disables the command. */
readonly enabled?: boolean | (() => boolean)
/** Configures automatic binding, or disables it for a named command. */
readonly bind?: false | string
/** Adds a named command to the command palette. */
readonly palette?: true
/** Adds a named command to prompt slash completion. */
readonly slash?: {
readonly name: string
readonly aliases?: string[]
}
/** Executes the command. Return false to let keymap dispatch continue. */
readonly run: () => void | false | Promise<void>
}
export interface KeymapLayer {
/** Limits the layer to one OpenCode input mode. Use global to opt out; defaults to base. */
readonly mode?: string
/** Enables or disables the complete layer. */
readonly enabled?: boolean | (() => boolean)
/** Limits the layer to a focused renderable. */
readonly target?: () => Renderable | null | undefined
/** Resolves conflicts with other active layers. */
readonly priority?: number
/** Commands owned by this layer. */
readonly commands?: readonly KeymapCommand[]
/** IDs of commands whose configured bindings should be active in this layer. */
readonly bindings?: readonly string[]
}
export interface Keymap {
/** Creates a reactive keymap layer owned by the calling component. */
layer(input: () => KeymapLayer): void
/** Dispatches a reachable command by ID. */
dispatch(id: string): void
/** Returns the formatted shortcut for a registered command. */
shortcut(id: string): string | undefined
/** Controls mutually exclusive OpenCode input modes. */
readonly mode: {
/** Returns the active mode. */
current(): string
/** Pushes a mode until the returned cleanup is called. */
push(mode: string): () => void
}
}
export interface UI { export interface UI {
readonly router: { readonly router: {
register(page: Page): () => void register(page: Page): () => void
@@ -118,5 +174,6 @@ export interface Context {
readonly options: Readonly<Record<string, any>> readonly options: Readonly<Record<string, any>>
readonly client: OpenCodeClient readonly client: OpenCodeClient
readonly data: Data readonly data: Data
readonly keymap: Keymap
readonly ui: UI readonly ui: UI
} }
+3 -39
View File
@@ -1,4 +1,5 @@
import { InputRenderable, TextareaRenderable, type Renderable } from "@opentui/core" import type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context"
import { InputRenderable, TextareaRenderable } from "@opentui/core"
import { stringifyKeyStroke } from "@opentui/keymap" import { stringifyKeyStroke } from "@opentui/keymap"
import { import {
registerBackspacePopsPendingSequence, registerBackspacePopsPendingSequence,
@@ -117,44 +118,7 @@ function Provider(props: ParentProps) {
) )
} }
export interface KeymapCommand { export type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context"
/** Stable command and config keybind identifier. Omit for an inline command. */
readonly id?: string
/** Optional label used by command discovery and keyboard-help UI. */
readonly title?: string
/** Optional longer description. */
readonly description?: string
/** Groups the command in discovery and keyboard-help UI. */
readonly group?: string
/** Enables or disables the command. */
readonly enabled?: boolean | (() => boolean)
/** Configures automatic binding, or disables it for a named command. */
readonly bind?: false | string
/** Adds a named command to the command palette. */
readonly palette?: true
/** Adds a named command to prompt slash completion. */
readonly slash?: {
readonly name: string
readonly aliases?: string[]
}
/** Executes the command. Return false to let keymap dispatch continue. */
readonly run: () => void | false | Promise<void>
}
export interface KeymapLayer {
/** Limits the layer to one OpenCode input mode. Use global to opt out; defaults to base. */
readonly mode?: string
/** Enables or disables the complete layer. */
readonly enabled?: boolean | (() => boolean)
/** Limits the layer to a focused renderable. */
readonly target?: () => Renderable | null | undefined
/** Resolves conflicts with other active layers. */
readonly priority?: number
/** Commands owned by this layer. */
readonly commands?: readonly KeymapCommand[]
/** IDs of commands whose configured bindings should be active in this layer. */
readonly bindings?: readonly string[]
}
export interface Keymap { export interface Keymap {
/** Dispatches a reachable command by ID. */ /** Dispatches a reachable command by ID. */
+1 -2
View File
@@ -1,6 +1,5 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import type { PluginRuntime } from "../plugin/runtime" import type { PluginRuntime } from "../plugin/runtime"
import DiffViewer from "./system/diff-viewer"
import Notifications from "./system/notifications" import Notifications from "./system/notifications"
import PluginManager from "./system/plugins" import PluginManager from "./system/plugins"
import WhichKey from "./system/which-key" import WhichKey from "./system/which-key"
@@ -12,7 +11,7 @@ export type BuiltinTuiPlugin = Omit<TuiPluginModule, "id"> & {
} }
export function createBuiltinPlugins(): BuiltinTuiPlugin[] { export function createBuiltinPlugins(): BuiltinTuiPlugin[] {
return [Notifications, PluginManager, WhichKey, DiffViewer] return [Notifications, PluginManager, WhichKey]
} }
export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) { export async function loadBuiltinPlugins(api: TuiPluginApi, runtime: PluginRuntime) {
@@ -1,6 +1,7 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
import type { FileDiffInfo } from "@opencode-ai/client" 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 { import {
TextAttributes, TextAttributes,
type BorderSides, type BorderSides,
@@ -9,14 +10,13 @@ import {
type ScrollBoxRenderable, type ScrollBoxRenderable,
} from "@opentui/core" } from "@opentui/core"
import { LANGUAGE_EXTENSIONS } from "../../util/filetype" import { LANGUAGE_EXTENSIONS } from "../../util/filetype"
import { useBindings, useCommandShortcut } from "../../keymap"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { useClient } from "../../context/client"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import path from "path" import path from "path"
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js" import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
import { DiffViewerFileTree } from "./diff-viewer-file-tree" import { DiffViewerFileTree } from "./diff-viewer-file-tree"
import { Panel, PanelGroup, Separator } from "./diff-viewer-ui" import { Panel, PanelGroup, Separator } from "./diff-viewer-ui"
import { useDialog } from "../../ui/dialog"
import { DialogSelect } from "../../ui/dialog-select" import { DialogSelect } from "../../ui/dialog-select"
import { getScrollAcceleration } from "../../util/scroll" import { getScrollAcceleration } from "../../util/scroll"
import { useConfig } from "../../config" import { useConfig } from "../../config"
@@ -80,32 +80,36 @@ function diffSourceLabel(mode: DiffMode) {
return "working tree" return "working tree"
} }
function DiffViewer(props: { api: TuiPluginApi }) { function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const client = useClient()
const config = useConfig() const config = useConfig()
const dialog = useDialog()
const themeState = useTheme() const themeState = useTheme()
const theme = () => props.api.theme.current const theme = () => themeState.theme
const params = () => const params = () => {
("params" in props.api.route.current ? props.api.route.current.params : undefined) as const route = props.context.ui.router.current()
return (route.type === "plugin" ? route.data : undefined) as
| { | {
mode?: DiffMode mode?: DiffMode
sessionID?: string sessionID?: string
returnRoute?: TuiRouteCurrent returnRoute?: Route
} }
| undefined | undefined
}
const mode = () => params()?.mode ?? "working" const mode = () => params()?.mode ?? "working"
const diffInput = createMemo(() => { const diffInput = createMemo(() => {
const sessionID = params()?.sessionID const sessionID = params()?.sessionID
return { return {
mode: mode(), mode: mode(),
sessionID, 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 [diff] = createResource(diffInput, async (input) => {
const result = await client.api.vcs.diff({ const result = await props.context.client.vcs.diff({
location: input.directory ? { directory: input.directory } : undefined, location: input.location,
mode: input.mode, mode: input.mode,
context: VCS_DIFF_CONTEXT_LINES, context: VCS_DIFF_CONTEXT_LINES,
}) })
@@ -120,8 +124,8 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : [])) const patchLeftBorder = createMemo<BorderSides[]>(() => (showFileTree() ? ["left"] : []))
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH) const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
const defaultView = createMemo(() => { const defaultView = createMemo(() => {
if (props.api.tuiConfig.diffs?.view === "unified") return "unified" if (config.data.diffs?.view === "unified") return "unified"
if (props.api.tuiConfig.diffs?.view === "split") return "split" if (config.data.diffs?.view === "split") return "split"
return splitAvailable() ? "split" : "unified" return splitAvailable() ? "split" : "unified"
}) })
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(config.data.diffs?.view)) 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 [activePatchFileIndex, setActivePatchFileIndex] = createSignal<number | undefined>()
const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>() const [selectedFileIndex, setSelectedFileIndex] = createSignal<number | undefined>()
const [reviewedFileNames, setReviewedFileNames] = createSignal<ReadonlySet<string>>(new Set()) 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 fileRows = createMemo(() => flattenFileTree(fileTree(), expandedFileNodes()))
const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree()))) const patchFileIndexes = createMemo(() => orderedPatchFileIndexes(flattenFileTree(fileTree())))
const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]() const focusRunner = (input: Record<DiffViewerFocus, () => void>) => () => input[focus()]()
const switchFocusShortcut = useCommandShortcut("diff.switch_focus") const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
const nextHunkShortcut = useCommandShortcut("diff.next_hunk") const switchFocusShortcut = shortcut("diff.switch_focus")
const previousHunkShortcut = useCommandShortcut("diff.previous_hunk") const nextHunkShortcut = shortcut("diff.next_hunk")
const nextFileShortcut = useCommandShortcut("diff.next_file") const previousHunkShortcut = shortcut("diff.previous_hunk")
const previousFileShortcut = useCommandShortcut("diff.previous_file") const nextFileShortcut = shortcut("diff.next_file")
const toggleFileTreeShortcut = useCommandShortcut("diff.toggle_file_tree") const previousFileShortcut = shortcut("diff.previous_file")
const singlePatchShortcut = useCommandShortcut("diff.single_patch") const toggleFileTreeShortcut = shortcut("diff.toggle_file_tree")
const switchSourceShortcut = useCommandShortcut("diff.switch_source") const singlePatchShortcut = shortcut("diff.single_patch")
const toggleViewShortcut = useCommandShortcut("diff.toggle_view") const switchSourceShortcut = shortcut("diff.switch_source")
const markReviewedShortcut = useCommandShortcut("diff.mark_reviewed") const toggleViewShortcut = shortcut("diff.toggle_view")
const helpShortcut = useCommandShortcut("diff.help") const markReviewedShortcut = shortcut("diff.mark_reviewed")
const helpShortcut = shortcut("diff.help")
let scroll: ScrollBoxRenderable | undefined let scroll: ScrollBoxRenderable | undefined
const patchNodeByFileIndex = new Map<number, BoxRenderable>() const patchNodeByFileIndex = new Map<number, BoxRenderable>()
const diffNodeByFileIndex = new Map<number, DiffRenderable>() const diffNodeByFileIndex = new Map<number, DiffRenderable>()
@@ -155,7 +160,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>() const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
const [patchFillerHeight, setPatchFillerHeight] = createSignal(0) const [patchFillerHeight, setPatchFillerHeight] = createSignal(0)
onCleanup(() => props.api.ui.dialog.clear()) onCleanup(() => dialog.clear())
createEffect(() => { createEffect(() => {
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
@@ -412,25 +417,24 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}) })
} }
const commands = [ const close = () => {
{
name: "diff.close",
title: "Close diff viewer",
category: "VCS",
run() {
const returnRoute = params()?.returnRoute const returnRoute = params()?.returnRoute
props.api.ui.dialog.clear() dialog.clear()
props.context.ui.router.navigate(returnRoute ?? { type: "home" })
}
props.api.route.navigate( const commands: KeymapCommand[] = [
returnRoute?.name ?? "home", {
returnRoute && "params" in returnRoute ? returnRoute.params : undefined, id: "diff.close",
) title: "Close diff viewer",
}, group: "VCS",
run: close,
}, },
{ {
name: "diff.down", id: "diff.down",
title: "Move diff viewer down", title: "Move diff viewer down",
category: "VCS", group: "VCS",
bind: "j,down",
run: focusRunner({ run: focusRunner({
files() { files() {
moveFileSelection(1) moveFileSelection(1)
@@ -442,9 +446,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}), }),
}, },
{ {
name: "diff.up", id: "diff.up",
title: "Move diff viewer up", title: "Move diff viewer up",
category: "VCS", group: "VCS",
bind: "k,up",
run: focusRunner({ run: focusRunner({
files() { files() {
moveFileSelection(-1) moveFileSelection(-1)
@@ -456,9 +461,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}), }),
}, },
{ {
name: "diff.page.down", id: "diff.page.down",
title: "Page diff viewer down", title: "Page diff viewer down",
category: "VCS", group: "VCS",
bind: "pagedown,ctrl+f",
run: focusRunner({ run: focusRunner({
files() { files() {
moveFileSelection(8) moveFileSelection(8)
@@ -470,9 +476,10 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}), }),
}, },
{ {
name: "diff.page.up", id: "diff.page.up",
title: "Page diff viewer up", title: "Page diff viewer up",
category: "VCS", group: "VCS",
bind: "pageup,ctrl+b",
run: focusRunner({ run: focusRunner({
files() { files() {
moveFileSelection(-8) moveFileSelection(-8)
@@ -484,9 +491,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}), }),
}, },
{ {
name: "diff.toggle", id: "diff.toggle",
title: "Toggle diff viewer item", title: "Toggle diff viewer item",
category: "VCS", group: "VCS",
run: focusRunner({ run: focusRunner({
files() { files() {
toggleSelectedFileTreeRow() toggleSelectedFileTreeRow()
@@ -495,9 +502,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}), }),
}, },
{ {
name: "diff.expand", id: "diff.expand",
title: "Expand diff viewer item", title: "Expand diff viewer item",
category: "VCS", group: "VCS",
run: focusRunner({ run: focusRunner({
files() { files() {
const highlighted = highlightedFileNode() 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", title: "Expand all diff viewer folders",
category: "VCS", group: "VCS",
run: focusRunner({ run: focusRunner({
files() { files() {
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree())) setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
@@ -524,9 +531,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}), }),
}, },
{ {
name: "diff.collapse", id: "diff.collapse",
title: "Collapse diff viewer item", title: "Collapse diff viewer item",
category: "VCS", group: "VCS",
run: focusRunner({ run: focusRunner({
files() { files() {
const highlighted = highlightedFileNode() 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", title: "Jump to next diff hunk",
category: "VCS", group: "VCS",
run() { run() {
jumpRelativeHunk(1) jumpRelativeHunk(1)
}, },
}, },
{ {
name: "diff.previous_hunk", id: "diff.previous_hunk",
title: "Jump to previous diff hunk", title: "Jump to previous diff hunk",
category: "VCS", group: "VCS",
run() { run() {
jumpRelativeHunk(-1) jumpRelativeHunk(-1)
}, },
}, },
{ {
name: "diff.next_file", id: "diff.next_file",
title: "Jump to next diff file", title: "Jump to next diff file",
category: "VCS", group: "VCS",
run() { run() {
jumpRelativePatchFile(1) jumpRelativePatchFile(1)
}, },
}, },
{ {
name: "diff.previous_file", id: "diff.previous_file",
title: "Jump to previous diff file", title: "Jump to previous diff file",
category: "VCS", group: "VCS",
run() { run() {
jumpRelativePatchFile(-1) jumpRelativePatchFile(-1)
}, },
}, },
{ {
name: "diff.mark_reviewed", id: "diff.mark_reviewed",
title: "Toggle selected diff file reviewed", title: "Toggle selected diff file reviewed",
category: "VCS", group: "VCS",
bind: "m",
run() { run() {
toggleSelectedFileReviewed() toggleSelectedFileReviewed()
}, },
}, },
{ {
name: "diff.switch_focus", id: "diff.switch_focus",
title: "Switch diff viewer focus", title: "Switch diff viewer focus",
category: "VCS", group: "VCS",
run() { run() {
if (!showFileTree()) return if (!showFileTree()) return
setFocus((current) => { 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", title: "Toggle diff viewer file tree",
category: "VCS", group: "VCS",
hidden: true,
run() { run() {
const next = !fileTreeEnabled() const next = !fileTreeEnabled()
if (!next) setFocus("patches") 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", title: "Toggle single patch view",
category: "VCS", group: "VCS",
hidden: true,
run() { run() {
setSelectedHunk(undefined) setSelectedHunk(undefined)
if (!singlePatch()) { if (!singlePatch()) {
@@ -648,18 +654,17 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}, },
}, },
{ {
name: "diff.switch_source", id: "diff.switch_source",
title: "Switch diff viewer source", title: "Switch diff viewer source",
category: "VCS", group: "VCS",
run() { run() {
openSwitchDiffDialog() openSwitchDiffDialog()
}, },
}, },
{ {
name: "diff.toggle_view", id: "diff.toggle_view",
title: "Toggle diff viewer split or unified view", title: "Toggle diff viewer split or unified view",
category: "VCS", group: "VCS",
hidden: true,
run() { run() {
if (!splitAvailable()) return if (!splitAvailable()) return
setSelectedHunk(undefined) setSelectedHunk(undefined)
@@ -673,9 +678,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}, },
}, },
{ {
name: "diff.help", id: "diff.help",
title: "Show more diff viewer shortcuts", title: "Show more diff viewer shortcuts",
category: "VCS", group: "VCS",
run() { run() {
openHelpDialog() openHelpDialog()
}, },
@@ -698,7 +703,7 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}) })
const openSwitchDiffDialog = () => { const openSwitchDiffDialog = () => {
props.api.ui.dialog.replace(() => ( dialog.replace(() => (
<DialogSelect <DialogSelect
title="Switch source" title="Switch source"
skipFilter={true} skipFilter={true}
@@ -708,10 +713,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
...option, ...option,
onSelect(dialog) { onSelect(dialog) {
dialog.clear() dialog.clear()
props.api.route.navigate(ROUTE, { props.context.ui.router.navigate({
type: "plugin",
name: ROUTE,
data: {
mode: option.value, mode: option.value,
sessionID: params()?.sessionID, sessionID: params()?.sessionID,
returnRoute: params()?.returnRoute, returnRoute: params()?.returnRoute,
},
}) })
}, },
}))} }))}
@@ -720,20 +729,12 @@ function DiffViewer(props: { api: TuiPluginApi }) {
} }
const openHelpDialog = () => { const openHelpDialog = () => {
props.api.ui.dialog.replace(() => <DiffViewerHelpDialog />) dialog.replace(() => <DiffViewerHelpDialog context={props.context} />)
props.api.ui.dialog.setSize("large") dialog.setSize("large")
} }
useBindings(() => ({ props.context.keymap.layer(() => ({
commands, 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 ( return (
@@ -932,8 +933,9 @@ function DiffViewer(props: { api: TuiPluginApi }) {
) )
} }
function DiffViewerHelpDialog() { function DiffViewerHelpDialog(props: { context: Plugin.Context }) {
const { theme } = useTheme() const { theme } = useTheme()
const shortcut = (id: string) => () => props.context.keymap.shortcut(id)
const rows = [ const rows = [
{ {
shortcut: () => "q", shortcut: () => "q",
@@ -941,57 +943,57 @@ function DiffViewerHelpDialog() {
description: "Quit the diff viewer", description: "Quit the diff viewer",
}, },
{ {
shortcut: useCommandShortcut("diff.switch_focus"), shortcut: shortcut("diff.switch_focus"),
action: "Focus file tree", action: "Focus file tree",
description: "Move keyboard focus between the file tree and patch pane", description: "Move keyboard focus between the file tree and patch pane",
}, },
{ {
shortcut: useCommandShortcut("diff.next_hunk"), shortcut: shortcut("diff.next_hunk"),
action: "Next hunk", action: "Next hunk",
description: "Jump to the next diff hunk", description: "Jump to the next diff hunk",
}, },
{ {
shortcut: useCommandShortcut("diff.previous_hunk"), shortcut: shortcut("diff.previous_hunk"),
action: "Previous hunk", action: "Previous hunk",
description: "Jump to the previous diff hunk", description: "Jump to the previous diff hunk",
}, },
{ {
shortcut: useCommandShortcut("diff.next_file"), shortcut: shortcut("diff.next_file"),
action: "Next file", action: "Next file",
description: "Select the next changed file in file-tree order", description: "Select the next changed file in file-tree order",
}, },
{ {
shortcut: useCommandShortcut("diff.previous_file"), shortcut: shortcut("diff.previous_file"),
action: "Previous file", action: "Previous file",
description: "Select the previous changed file in file-tree order", 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", action: "Toggle file tree",
description: "Show or hide the file tree sidebar", description: "Show or hide the file tree sidebar",
}, },
{ {
shortcut: useCommandShortcut("diff.single_patch"), shortcut: shortcut("diff.single_patch"),
action: "Toggle patches", action: "Toggle patches",
description: "Switch between one selected patch and all patches", description: "Switch between one selected patch and all patches",
}, },
{ {
shortcut: useCommandShortcut("diff.switch_source"), shortcut: shortcut("diff.switch_source"),
action: "Switch source", action: "Switch source",
description: "Choose working tree or main branch changes", description: "Choose working tree or main branch changes",
}, },
{ {
shortcut: useCommandShortcut("diff.toggle_view"), shortcut: shortcut("diff.toggle_view"),
action: "Toggle view", action: "Toggle view",
description: "Switch between split and unified diff layout", description: "Switch between split and unified diff layout",
}, },
{ {
shortcut: useCommandShortcut("diff.expand_all"), shortcut: shortcut("diff.expand_all"),
action: "Expand all folders", action: "Expand all folders",
description: "Open every folder in the file tree", description: "Open every folder in the file tree",
}, },
{ {
shortcut: useCommandShortcut("diff.mark_reviewed"), shortcut: shortcut("diff.mark_reviewed"),
action: "Mark reviewed", action: "Mark reviewed",
description: "Toggle reviewed state for the selected file", description: "Toggle reviewed state for the selected file",
}, },
@@ -1031,36 +1033,54 @@ function DiffViewerHelpDialog() {
) )
} }
const tui: TuiPlugin = async (api) => { function Commands(props: { context: Plugin.Context }) {
api.route.register([ const dialog = useDialog()
{ props.context.keymap.layer(() => ({
name: ROUTE, mode: "global",
render: () => <DiffViewer api={api} />,
},
])
api.keymap.registerLayer({
commands: [ commands: [
{ {
name: "diff.open", id: "diff.open",
title: "Open diff viewer", title: "Open diff viewer",
slash: { name: "diff" }, slash: { name: "diff" },
category: "VCS", group: "VCS",
namespace: "palette", palette: true,
run() { run() {
api.route.navigate(ROUTE, { 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", mode: "working",
sessionID: "params" in api.route.current ? api.route.current.params?.sessionID : undefined, sessionID: route.type === "session" ? route.sessionID : undefined,
returnRoute: api.route.current, returnRoute,
},
}) })
api.ui.dialog.clear() dialog.clear()
}, },
}, },
], ],
}) }))
return null
} }
export default { export default Plugin.define({
id: "diff-viewer", id: "diff-viewer",
tui, setup(context) {
} context.ui.router.register({
name: ROUTE,
render: () => <DiffViewer context={context} />,
})
context.ui.slot("app", () => <Commands context={context} />)
},
})
+11 -1
View File
@@ -4,6 +4,16 @@ import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarFooter from "../feature-plugins/sidebar/footer" import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp" import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp" import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer"
import Scrap from "../feature-plugins/system/scrap" import Scrap from "../feature-plugins/system/scrap"
export const builtins = [HomeFooter, HomeTips, SidebarContext, SidebarMcp, SidebarLsp, SidebarFooter, Scrap] export const builtins = [
HomeFooter,
HomeTips,
SidebarContext,
SidebarMcp,
SidebarLsp,
SidebarFooter,
Scrap,
DiffViewer,
]
+9
View File
@@ -18,6 +18,7 @@ import { createStore, produce, reconcile as reconcileStore } from "solid-js/stor
import { useConfig } from "../config" import { useConfig } from "../config"
import { useClient } from "../context/client" import { useClient } from "../context/client"
import { useData } from "../context/data" import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { useTuiLifecycle } from "../context/runtime" import { useTuiLifecycle } from "../context/runtime"
import { builtins } from "./builtins" import { builtins } from "./builtins"
@@ -59,6 +60,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const data = useData() const data = useData()
const route = useRoute() const route = useRoute()
const config = useConfig() const config = useConfig()
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const lifecycle = useTuiLifecycle() const lifecycle = useTuiLifecycle()
const directory = config.path ? path.dirname(config.path) : process.cwd() const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
@@ -81,6 +84,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
options: item.options ?? {}, options: item.options ?? {},
client: client.api, client: client.api,
data, data,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
shortcut: shortcuts.get,
mode: keymap.mode,
},
ui: { ui: {
router: { router: {
register(page) { register(page) {
+99 -69
View File
@@ -1,27 +1,38 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test" import { expect, test } from "bun:test"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core" import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid" import { testRender } from "@opentui/solid"
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui" import type {
Context,
Destination,
KeymapCommand,
KeymapLayer,
Page,
Route,
Slot,
} from "@opencode-ai/plugin/v2/tui/context"
import { ThemeProvider } from "../../../src/context/theme" import { ThemeProvider } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config" import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
import { TuiKeybind } from "../../../src/config/keybind" import { TuiKeybind } from "../../../src/config/keybind"
import { OpencodeKeymapProvider } from "../../../src/keymap" import { Keymap } from "../../../src/context/keymap"
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer" import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
import { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime" import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TestTuiContexts } from "../../fixture/tui-environment" import { TestTuiContexts } from "../../fixture/tui-environment"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client" import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
test("closing the diff viewer returns to the route it opened from", async () => { test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([]) const viewer = await renderDiffViewer([])
try { try {
expect(viewer.current()).toEqual({ expect(viewer.current()).toEqual({
type: "plugin",
id: "diff-viewer",
name: "diff", name: "diff",
params: { mode: "working", sessionID: "session-1", returnRoute: startRoute }, data: { mode: "working", sessionID: "session-1", returnRoute: startRoute },
}) })
const route = viewer.current()
expect(route.type === "plugin" ? route.data?.returnRoute : undefined).not.toBe(startRoute)
expect(viewer.vcsDiffInput()).toEqual({ expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/session" }, location: { directory: "/repo/session" },
mode: "working", mode: "working",
@@ -29,7 +40,7 @@ test("closing the diff viewer returns to the route it opened from", async () =>
}) })
expect(viewer.commands.has("diff.close")).toBe(true) expect(viewer.commands.has("diff.close")).toBe(true)
viewer.commands.get("diff.close")!.run?.({} as never) viewer.commands.get("diff.close")!.run()
expect(viewer.current()).toEqual(startRoute) expect(viewer.current()).toEqual(startRoute)
} finally { } finally {
viewer.app.renderer.destroy() viewer.app.renderer.destroy()
@@ -46,6 +57,19 @@ test("shows an error instead of an empty diff when loading fails", async () => {
} }
}) })
test("uses the active location when opened outside a session", async () => {
const viewer = await renderDiffViewer([], 20, { type: "home" })
try {
expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/default" },
mode: "working",
context: "12",
})
} finally {
viewer.app.renderer.destroy()
}
})
test("brackets navigate diff hunks", async () => { test("brackets navigate diff hunks", async () => {
const viewer = await renderDiffViewer( const viewer = await renderDiffViewer(
[ [
@@ -85,26 +109,26 @@ test("brackets navigate diff hunks", async () => {
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]") expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[") expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
viewer.commands.get("diff.next_hunk")!.run?.({} as never) viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce() await viewer.app.renderOnce()
const first = scroll.scrollTop const first = scroll.scrollTop
expect(first).toBeGreaterThan(initial) expect(first).toBeGreaterThan(initial)
viewer.commands.get("diff.next_hunk")!.run?.({} as never) viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce() await viewer.app.renderOnce()
const second = scroll.scrollTop const second = scroll.scrollTop
expect(second).toBeGreaterThan(first) expect(second).toBeGreaterThan(first)
viewer.commands.get("diff.previous_hunk")!.run?.({} as never) viewer.commands.get("diff.previous_hunk")!.run()
await viewer.app.renderOnce() await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first) expect(scroll.scrollTop).toBe(first)
viewer.commands.get("diff.next_hunk")!.run?.({} as never) viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce() await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(second) expect(scroll.scrollTop).toBe(second)
scroll.scrollTo(initial) scroll.scrollTo(initial)
viewer.commands.get("diff.next_hunk")!.run?.({} as never) viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce() await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first) expect(scroll.scrollTop).toBe(first)
} finally { } finally {
@@ -112,13 +136,11 @@ test("brackets navigate diff hunks", async () => {
} }
}) })
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent, fail = false) { async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) {
const commands = new Map< const commands = new Map<string, KeymapCommand>()
string,
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
>()
let current = initialRoute ?? startRoute let current = initialRoute ?? startRoute
let renderDiff: TuiRouteDefinition["render"] | undefined let renderDiff: Page["render"] | undefined
let renderCommands: Slot | undefined
let vcsDiffInput: unknown let vcsDiffInput: unknown
const config = createTuiResolvedConfig() const config = createTuiResolvedConfig()
const transport = createFetch((url) => { const transport = createFetch((url) => {
@@ -135,51 +157,68 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
}) })
}, createEventStream()) }, createEventStream())
function Harness() { function Harness() {
const renderer = useRenderer() const context = {
const keymap = createDefaultOpenTuiKeymap(renderer) options: {},
const registerLayer = keymap.registerLayer.bind(keymap) client: createApi(transport.fetch),
keymap.registerLayer = (layer) => { data: {
layer.commands?.forEach((command) => commands.set(command.name, command)) session: { get: () => session },
return registerLayer(layer) location: { default: () => ({ directory: "/repo/default" }) },
}
const base = createTuiPluginApi({
keymap,
state: {
session: {
get: () => session,
},
}, },
keymap: {
layer(input: () => KeymapLayer) {
input().commands?.forEach((command) => {
if (command.id) commands.set(command.id, command)
}) })
const api = { },
...base, dispatch() {},
route: { shortcut: () => undefined,
register(routes) { mode: { current: () => "base", push: () => () => {} },
renderDiff = routes.find((route) => route.name === "diff")?.render },
ui: {
router: {
register(page: Page) {
if (page.name === "diff") renderDiff = page.render
return () => {} return () => {}
}, },
navigate(name, params) { navigate(destination: Destination) {
current = params ? { name, params } : { name } current = destination.type === "plugin" && !("id" in destination)
? { ...destination, id: "diff-viewer" }
: destination
}, },
get current() { current: () => current,
return current },
slot(_name: string, render: Slot) {
renderCommands = render
return () => {}
}, },
}, },
} satisfies TuiPluginApi } as unknown as Context
void diffViewerPlugin.tui(api, undefined, pluginMeta) void diffViewerPlugin.setup(context)
if (!initialRoute) commands.get("diff.open")?.run?.({} as never) function Content() {
const commandView = renderCommands?.({})
if (current.type !== "plugin") commands.get("diff.open")?.run()
return (
<>
{commandView}
{renderDiff?.({ data: current.type === "plugin" ? current.data : undefined })}
</>
)
}
return ( return (
<TestTuiContexts> <TestTuiContexts>
<ClientProvider api={createApi(transport.fetch)}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}> <ConfigProvider config={config}>
<Keymap.Provider>
<ToastProvider>
<ThemeProvider mode="dark"> <ThemeProvider mode="dark">
{renderDiff?.({ params: "params" in current ? current.params : undefined })} <DialogProvider>
<Content />
</DialogProvider>
</ThemeProvider> </ThemeProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider> </ConfigProvider>
</OpencodeKeymapProvider>
</ClientProvider>
</TestTuiContexts> </TestTuiContexts>
) )
} }
@@ -194,7 +233,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
} }
} }
const startRoute: TuiRouteCurrent = { name: "session", params: { sessionID: "session-1" } } const startRoute: Route = { type: "session", sessionID: "session-1" }
function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined { function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
@@ -208,26 +247,30 @@ function containsDiff(root: Renderable): boolean {
const session = { const session = {
id: "session-1", id: "session-1",
slug: "session-1",
projectID: "project-1", projectID: "project-1",
directory: "/repo/session", location: { directory: "/repo/session" },
title: "Session", title: "Session",
version: "1", cost: { currency: "USD", amount: 0 },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { time: {
created: 0, created: 0,
updated: 0, updated: 0,
}, },
} satisfies NonNullable<ReturnType<TuiPluginApi["state"]["session"]["get"]>> }
test("branch diff source requests branch VCS diff", async () => { test("branch diff source requests branch VCS diff", async () => {
const viewer = await renderDiffViewer([], 20, { const viewer = await renderDiffViewer([], 20, {
type: "plugin",
id: "diff-viewer",
name: "diff", name: "diff",
params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
}) })
try { try {
expect(viewer.current()).toEqual({ expect(viewer.current()).toEqual({
type: "plugin",
id: "diff-viewer",
name: "diff", name: "diff",
params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute }, data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
}) })
expect(viewer.vcsDiffInput()).toEqual({ expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/session" }, location: { directory: "/repo/session" },
@@ -250,16 +293,3 @@ async function waitForCommand(
await new Promise((resolve) => setTimeout(resolve, 25)) await new Promise((resolve) => setTimeout(resolve, 25))
} }
} }
const pluginMeta = {
id: "diff-viewer",
source: "internal",
spec: "diff-viewer",
target: "diff-viewer",
first_time: 0,
last_time: 0,
time_changed: 0,
load_count: 1,
fingerprint: "test",
state: "same",
} satisfies TuiPluginMeta