feat(tui): port diff viewer to v2 plugins
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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} />)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user