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

@ -19,6 +19,7 @@ import type {
ShellInfo,
SkillInfo,
} from "@opencode-ai/client"
import type { Renderable } from "@opentui/core"
import type { JSX } from "@opentui/solid"
interface LocationCollection<Value> {
@ -105,6 +106,61 @@ export interface Page {
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 {
readonly router: {
register(page: Page): () => void
@ -118,5 +174,6 @@ export interface Context {
readonly options: Readonly<Record<string, any>>
readonly client: OpenCodeClient
readonly data: Data
readonly keymap: Keymap
readonly ui: UI
}

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 {
registerBackspacePopsPendingSequence,
@ -117,44 +118,7 @@ function Provider(props: ParentProps) {
)
}
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 type { KeymapCommand, KeymapLayer } from "@opencode-ai/plugin/v2/tui/context"
export interface Keymap {
/** Dispatches a reachable command by ID. */

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} />)
},
})

View file

@ -4,6 +4,16 @@ import SidebarContext from "../feature-plugins/sidebar/context"
import SidebarFooter from "../feature-plugins/sidebar/footer"
import SidebarLsp from "../feature-plugins/sidebar/lsp"
import SidebarMcp from "../feature-plugins/sidebar/mcp"
import DiffViewer from "../feature-plugins/system/diff-viewer"
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,
]

View file

@ -18,6 +18,7 @@ import { createStore, produce, reconcile as reconcileStore } from "solid-js/stor
import { useConfig } from "../config"
import { useClient } from "../context/client"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { useRoute } from "../context/route"
import { useTuiLifecycle } from "../context/runtime"
import { builtins } from "./builtins"
@ -59,6 +60,8 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
const data = useData()
const route = useRoute()
const config = useConfig()
const keymap = Keymap.use()
const shortcuts = Keymap.useShortcuts()
const lifecycle = useTuiLifecycle()
const directory = config.path ? path.dirname(config.path) : process.cwd()
const [store, setStore] = createStore({
@ -81,6 +84,12 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
options: item.options ?? {},
client: client.api,
data,
keymap: {
layer: Keymap.createLayer,
dispatch: keymap.dispatch,
shortcut: shortcuts.get,
mode: keymap.mode,
},
ui: {
router: {
register(page) {

View file

@ -1,27 +1,38 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { testRender, useRenderer } from "@opentui/solid"
import type { TuiPluginApi, TuiPluginMeta, TuiRouteCurrent, TuiRouteDefinition } from "@opencode-ai/plugin/tui"
import { testRender } from "@opentui/solid"
import type {
Context,
Destination,
KeymapCommand,
KeymapLayer,
Page,
Route,
Slot,
} from "@opencode-ai/plugin/v2/tui/context"
import { ThemeProvider } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider } from "../../../src/context/client"
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 { createTuiPluginApi } from "../../fixture/tui-plugin"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TestTuiContexts } from "../../fixture/tui-environment"
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 () => {
const viewer = await renderDiffViewer([])
try {
expect(viewer.current()).toEqual({
type: "plugin",
id: "diff-viewer",
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({
location: { directory: "/repo/session" },
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)
viewer.commands.get("diff.close")!.run?.({} as never)
viewer.commands.get("diff.close")!.run()
expect(viewer.current()).toEqual(startRoute)
} finally {
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 () => {
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_previous_hunk")).toBe("[")
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
const first = scroll.scrollTop
expect(first).toBeGreaterThan(initial)
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
const second = scroll.scrollTop
expect(second).toBeGreaterThan(first)
viewer.commands.get("diff.previous_hunk")!.run?.({} as never)
viewer.commands.get("diff.previous_hunk")!.run()
await viewer.app.renderOnce()
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()
expect(scroll.scrollTop).toBe(second)
scroll.scrollTo(initial)
viewer.commands.get("diff.next_hunk")!.run?.({} as never)
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
} finally {
@ -112,13 +136,11 @@ test("brackets navigate diff hunks", async () => {
}
})
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent, fail = false) {
const commands = new Map<
string,
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
>()
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) {
const commands = new Map<string, KeymapCommand>()
let current = initialRoute ?? startRoute
let renderDiff: TuiRouteDefinition["render"] | undefined
let renderDiff: Page["render"] | undefined
let renderCommands: Slot | undefined
let vcsDiffInput: unknown
const config = createTuiResolvedConfig()
const transport = createFetch((url) => {
@ -135,51 +157,68 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
})
}, createEventStream())
function Harness() {
const renderer = useRenderer()
const keymap = createDefaultOpenTuiKeymap(renderer)
const registerLayer = keymap.registerLayer.bind(keymap)
keymap.registerLayer = (layer) => {
layer.commands?.forEach((command) => commands.set(command.name, command))
return registerLayer(layer)
}
const base = createTuiPluginApi({
keymap,
state: {
session: {
get: () => session,
},
const context = {
options: {},
client: createApi(transport.fetch),
data: {
session: { get: () => session },
location: { default: () => ({ directory: "/repo/default" }) },
},
})
const api = {
...base,
route: {
register(routes) {
renderDiff = routes.find((route) => route.name === "diff")?.render
keymap: {
layer(input: () => KeymapLayer) {
input().commands?.forEach((command) => {
if (command.id) commands.set(command.id, command)
})
},
dispatch() {},
shortcut: () => undefined,
mode: { current: () => "base", push: () => () => {} },
},
ui: {
router: {
register(page: Page) {
if (page.name === "diff") renderDiff = page.render
return () => {}
},
navigate(destination: Destination) {
current = destination.type === "plugin" && !("id" in destination)
? { ...destination, id: "diff-viewer" }
: destination
},
current: () => current,
},
slot(_name: string, render: Slot) {
renderCommands = render
return () => {}
},
navigate(name, params) {
current = params ? { name, params } : { name }
},
get current() {
return current
},
},
} satisfies TuiPluginApi
} as unknown as Context
void diffViewerPlugin.tui(api, undefined, pluginMeta)
if (!initialRoute) commands.get("diff.open")?.run?.({} as never)
void diffViewerPlugin.setup(context)
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 (
<TestTuiContexts>
<ClientProvider api={createApi(transport.fetch)}>
<OpencodeKeymapProvider keymap={keymap}>
<ConfigProvider config={config}>
<ConfigProvider config={config}>
<Keymap.Provider>
<ToastProvider>
<ThemeProvider mode="dark">
{renderDiff?.({ params: "params" in current ? current.params : undefined })}
<DialogProvider>
<Content />
</DialogProvider>
</ThemeProvider>
</ConfigProvider>
</OpencodeKeymapProvider>
</ClientProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</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 {
if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
@ -208,26 +247,30 @@ function containsDiff(root: Renderable): boolean {
const session = {
id: "session-1",
slug: "session-1",
projectID: "project-1",
directory: "/repo/session",
location: { directory: "/repo/session" },
title: "Session",
version: "1",
cost: { currency: "USD", amount: 0 },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: 0,
updated: 0,
},
} satisfies NonNullable<ReturnType<TuiPluginApi["state"]["session"]["get"]>>
}
test("branch diff source requests branch VCS diff", async () => {
const viewer = await renderDiffViewer([], 20, {
type: "plugin",
id: "diff-viewer",
name: "diff",
params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
})
try {
expect(viewer.current()).toEqual({
type: "plugin",
id: "diff-viewer",
name: "diff",
params: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
})
expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/session" },
@ -250,16 +293,3 @@ async function waitForCommand(
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