mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-08 18:06:25 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebed189984 |
@@ -84,6 +84,7 @@ export const Definitions = {
|
||||
"diff.single_patch": keybind("s", "Toggle single patch view"),
|
||||
"diff.switch_source": keybind("d", "Switch diff viewer source"),
|
||||
"diff.toggle_view": keybind("v", "Toggle diff viewer split or unified view"),
|
||||
"diff.toggle_fullscreen": keybind("f", "Toggle diff viewer full screen"),
|
||||
"diff.mark_reviewed": keybind("m", "Toggle selected diff file reviewed"),
|
||||
"diff.help": keybind("?,shift+?,shift+/", "Show more diff viewer shortcuts"),
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { PanelInput } from "@opencode-ai/plugin/tui/context"
|
||||
import { MouseButton } from "@opentui/core"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { useTheme } from "../../context/theme"
|
||||
import { stringWidth } from "../../util/string-width"
|
||||
|
||||
export function DiffViewerFooter(props: { context: Plugin.Context; panel: PanelInput; onHelp: () => void }) {
|
||||
const theme = useTheme()
|
||||
const key = (...ids: string[]) =>
|
||||
ids
|
||||
.map((id) => props.context.keymap.shortcuts(id)[0])
|
||||
.filter(Boolean)
|
||||
.join("/")
|
||||
const focus = () => key("pane.focus.right")
|
||||
const help = () => key("diff.help")
|
||||
const hints = createMemo(() => {
|
||||
const available = props.panel.width - 4 - stringWidth(`${help()} see all`) - 2
|
||||
return [
|
||||
{ key: key("pane.focus.left"), label: "focus session" },
|
||||
{ key: key("diff.toggle_fullscreen"), label: "full screen" },
|
||||
{ key: key("diff.down", "diff.up"), label: "scroll" },
|
||||
{ key: key("diff.next_file", "diff.previous_file"), label: "files" },
|
||||
{ key: key("diff.next_hunk", "diff.previous_hunk"), label: "hunks" },
|
||||
{ key: key("diff.close"), label: "close" },
|
||||
]
|
||||
.filter((hint) => hint.key)
|
||||
.reduce<{ width: number; items: { key: string; label: string }[] }>(
|
||||
(result, hint) => {
|
||||
const width = result.width + (result.items.length ? 2 : 0) + stringWidth(`${hint.key} ${hint.label}`)
|
||||
return width <= available ? { width, items: [...result.items, hint] } : result
|
||||
},
|
||||
{ width: 0, items: [] },
|
||||
).items
|
||||
})
|
||||
|
||||
return (
|
||||
<box id="diff-footer" height={3} paddingTop={1} paddingBottom={1} paddingLeft={2} paddingRight={2} flexShrink={0}>
|
||||
<Show
|
||||
when={props.panel.focused}
|
||||
fallback={
|
||||
<text
|
||||
fg={theme.text.subdued}
|
||||
wrapMode="none"
|
||||
truncate
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === MouseButton.LEFT) props.panel.focus()
|
||||
}}
|
||||
>
|
||||
<span style={{ fg: theme.text.default }}>{focus() || "click"}</span>
|
||||
{" focus diff"}
|
||||
</text>
|
||||
}
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<text fg={theme.text.subdued} flexGrow={1} minWidth={0} wrapMode="none" truncate>
|
||||
<For each={hints()}>
|
||||
{(hint, index) => (
|
||||
<>
|
||||
{index() ? " " : ""}
|
||||
<span style={{ fg: theme.text.default }}>{hint.key}</span>
|
||||
{` ${hint.label}`}
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</text>
|
||||
<text
|
||||
fg={theme.text.subdued}
|
||||
flexShrink={0}
|
||||
wrapMode="none"
|
||||
onMouseUp={(event) => {
|
||||
if (event.button === MouseButton.LEFT) props.onHelp()
|
||||
}}
|
||||
>
|
||||
<span style={{ fg: theme.text.default }}>{help()}</span>
|
||||
{help() ? " see all" : "see all"}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { FileDiffInfo, LocationRef } from "@opencode-ai/client"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import type { KeymapCommand, Route } from "@opencode-ai/plugin/tui/context"
|
||||
import type { KeymapCommand, PanelInput, Route } from "@opencode-ai/plugin/tui/context"
|
||||
import {
|
||||
MouseButton,
|
||||
TextAttributes,
|
||||
@@ -15,6 +15,7 @@ import { useRenderer, useTerminalDimensions } from "@opentui/solid"
|
||||
import { createEffect, createMemo, createResource, createSignal, For, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
import { DiffViewerFileTree } from "./diff-viewer-file-tree"
|
||||
import { DiffFileMenu } from "./diff-viewer-file-menu"
|
||||
import { DiffViewerFooter } from "./diff-viewer-footer"
|
||||
import { DiffViewerImage, isDiffImageFile } from "./diff-viewer-image"
|
||||
import { DialogSelect } from "../../ui/dialog-select"
|
||||
import { EmptyBorder } from "../../ui/border"
|
||||
@@ -23,7 +24,7 @@ import { getScrollAcceleration } from "../../util/scroll"
|
||||
import { createDebouncedSignal } from "../../util/signal"
|
||||
import { useConfig } from "../../config"
|
||||
import { locationKey } from "../../context/data"
|
||||
import { useThemes } from "../../context/theme"
|
||||
import { useTheme, useThemes } from "../../context/theme"
|
||||
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
|
||||
import {
|
||||
allExpandedFileTreeDirectories,
|
||||
@@ -76,27 +77,22 @@ function diffSourceLabel(mode: DiffMode) {
|
||||
return "Uncommitted"
|
||||
}
|
||||
|
||||
function DiffViewer(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
function DiffViewer(props: {
|
||||
context: Plugin.Context
|
||||
sessionID?: string
|
||||
initialMode?: DiffMode
|
||||
panel?: PanelInput
|
||||
onClose: () => void
|
||||
}) {
|
||||
const config = useConfig()
|
||||
const [memory, updateMemory] = props.context.storage.memory<{
|
||||
source?: DiffMode
|
||||
bases: Record<string, string>
|
||||
}>("review", { initial: { bases: {} } })
|
||||
const params = () => {
|
||||
const route = props.context.ui.router.current()
|
||||
return (route.type === "plugin" ? route.data : undefined) as
|
||||
| {
|
||||
mode?: DiffMode
|
||||
sessionID?: string
|
||||
returnRoute?: Route
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
|
||||
const [mode, setMode] = createSignal(props.initialMode ?? memory.source ?? config.data.diffs?.source ?? "branch")
|
||||
const location = createMemo(
|
||||
() => {
|
||||
const sessionID = params()?.sessionID
|
||||
const sessionID = props.sessionID
|
||||
return sessionID
|
||||
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
|
||||
: props.context.data.location.default()
|
||||
@@ -158,51 +154,66 @@ function DiffViewer(props: { context: Plugin.Context }) {
|
||||
if (!base) return "Base not reported"
|
||||
return `vs ${base.name}`
|
||||
}
|
||||
return (
|
||||
<DiffViewerContent
|
||||
context={props.context}
|
||||
files={result()?.files ?? []}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
mode={mode()}
|
||||
sourceDetail={sourceDetail()}
|
||||
sourceBase={sourceBase()}
|
||||
unavailable={mode() === "committed" && !!result() && !result()?.base}
|
||||
preferences={config.data.diffs}
|
||||
panel={props.panel}
|
||||
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
|
||||
onPreferencesChange={(value) => {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, ...value }
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
onClose={props.onClose}
|
||||
onSwitchSource={(mode) => {
|
||||
updateMemory((draft) => {
|
||||
draft.source = mode
|
||||
})
|
||||
setMode(mode)
|
||||
}}
|
||||
onChooseBase={() => {
|
||||
const target = { ...location() }
|
||||
const key = baseKey()
|
||||
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DiffBaseDialog
|
||||
context={props.context}
|
||||
location={target}
|
||||
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
|
||||
onSelect={(ref) =>
|
||||
updateMemory((draft) => {
|
||||
draft.bases[key] = ref
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DiffRoute(props: {
|
||||
context: Plugin.Context
|
||||
data?: { mode?: DiffMode; sessionID?: string; returnRoute?: Route }
|
||||
}) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
return (
|
||||
<box position="absolute" zIndex={2500} left={0} top={0} width={dimensions().width} height={dimensions().height}>
|
||||
<DiffViewerContent
|
||||
<DiffViewer
|
||||
context={props.context}
|
||||
files={result()?.files ?? []}
|
||||
loading={diff.loading}
|
||||
error={diff.error}
|
||||
mode={mode()}
|
||||
sourceDetail={sourceDetail()}
|
||||
sourceBase={sourceBase()}
|
||||
unavailable={mode() === "committed" && !!result() && !result()?.base}
|
||||
preferences={config.data.diffs}
|
||||
loadImage={(file, signal) => props.context.client.file.read({ path: file, location: location() }, { signal })}
|
||||
onPreferencesChange={(value) => {
|
||||
void config
|
||||
.update((draft) => {
|
||||
draft.diffs = { ...draft.diffs, ...value }
|
||||
})
|
||||
.catch(() => {})
|
||||
}}
|
||||
onClose={() => props.context.ui.router.navigate(params()?.returnRoute ?? { type: "home" })}
|
||||
onSwitchSource={(mode) => {
|
||||
updateMemory((draft) => {
|
||||
draft.source = mode
|
||||
})
|
||||
setMode(mode)
|
||||
}}
|
||||
onChooseBase={() => {
|
||||
const target = { ...location() }
|
||||
const key = baseKey()
|
||||
if (!memory.bases[key]) void loadBase(target, key).catch(() => {})
|
||||
props.context.ui.dialog.show(() => (
|
||||
<DiffBaseDialog
|
||||
context={props.context}
|
||||
location={target}
|
||||
current={memory.bases[key] ?? reportedBases().get(key)?.ref}
|
||||
onSelect={(ref) =>
|
||||
updateMemory((draft) => {
|
||||
draft.bases[key] = ref
|
||||
})
|
||||
}
|
||||
/>
|
||||
))
|
||||
}}
|
||||
sessionID={props.data?.sessionID}
|
||||
initialMode={props.data?.mode}
|
||||
onClose={() => props.context.ui.router.navigate(props.data?.returnRoute ?? { type: "home" })}
|
||||
/>
|
||||
</box>
|
||||
)
|
||||
@@ -267,6 +278,7 @@ export function DiffViewerContent(props: {
|
||||
navigation?: "tree" | "list"
|
||||
loadImage?: (file: string, signal: AbortSignal) => Promise<Uint8Array>
|
||||
preferences?: DiffPreferences
|
||||
panel?: PanelInput
|
||||
onPreferencesChange?: (value: DiffPreferences) => void
|
||||
onClose: () => void
|
||||
onSwitchSource: (mode: DiffMode) => void
|
||||
@@ -276,19 +288,21 @@ export function DiffViewerContent(props: {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const config = useConfig()
|
||||
const dialog = props.context.ui.dialog
|
||||
const theme = useThemes().current
|
||||
const theme = useTheme()
|
||||
const currentSyntax = useThemes().currentSyntax
|
||||
const files = () => props.files
|
||||
const docked = () => props.panel?.presentation === "panel"
|
||||
const width = () => props.panel?.width ?? dimensions().width
|
||||
const mode = () => props.mode
|
||||
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(props.preferences?.tree ?? true)
|
||||
const showFileTree = createMemo(
|
||||
() => dimensions().width >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
|
||||
() => !docked() && width() >= 90 && showDiffViewerFileTree(fileTreeEnabled(), files().length),
|
||||
)
|
||||
const [singlePatch, setSinglePatch] = createSignal(props.preferences?.single ?? false)
|
||||
const fileTreeWidth = createMemo(() =>
|
||||
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(dimensions().width / 4))),
|
||||
Math.max(FILE_TREE_MIN_WIDTH, Math.min(FILE_TREE_MAX_WIDTH, Math.floor(width() / 4))),
|
||||
)
|
||||
const patchPaneWidth = createMemo(() => dimensions().width - (showFileTree() ? fileTreeWidth() : 0) - 4)
|
||||
const patchPaneWidth = createMemo(() => width() - (showFileTree() ? fileTreeWidth() : 0) - (docked() ? 2 : 4))
|
||||
const splitAvailable = createMemo(() => patchPaneWidth() >= MIN_SPLIT_WIDTH)
|
||||
const [viewOverride, setViewOverride] = createSignal<DiffView | undefined>(storedView(props.preferences?.view))
|
||||
const view = createMemo(() =>
|
||||
@@ -308,8 +322,6 @@ export function DiffViewerContent(props: {
|
||||
const [selectedHunk, setSelectedHunk] = createSignal<SelectedHunk | undefined>()
|
||||
const [pendingPatchScrollFileIndex, setPendingPatchScrollFileIndex] = createSignal<number | undefined>()
|
||||
|
||||
onCleanup(() => dialog.clear())
|
||||
|
||||
createEffect(() => {
|
||||
setExpandedFileNodes(allExpandedFileTreeDirectories(fileTree()))
|
||||
setSelectedFileIndex(undefined)
|
||||
@@ -517,6 +529,7 @@ export function DiffViewerContent(props: {
|
||||
if (event.button !== MouseButton.RIGHT) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.panel?.focus()
|
||||
setFileMenu({ fileIndex, x: event.x, y: event.y })
|
||||
}
|
||||
|
||||
@@ -636,7 +649,9 @@ export function DiffViewerContent(props: {
|
||||
id: "diff.toggle_file_tree",
|
||||
title: "Toggle diff viewer file tree",
|
||||
group: "VCS",
|
||||
enabled: () => !docked(),
|
||||
run() {
|
||||
if (docked()) return
|
||||
const next = !fileTreeEnabled()
|
||||
setFileTreeEnabled(next)
|
||||
props.onPreferencesChange?.({ tree: next })
|
||||
@@ -684,6 +699,13 @@ export function DiffViewerContent(props: {
|
||||
props.onPreferencesChange?.({ view: next })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "diff.toggle_fullscreen",
|
||||
title: "Toggle diff viewer full screen",
|
||||
group: "VCS",
|
||||
enabled: () => props.panel !== undefined,
|
||||
run: () => props.panel?.toggleFullscreen(),
|
||||
},
|
||||
{
|
||||
id: "diff.help",
|
||||
title: "Show more diff viewer shortcuts",
|
||||
@@ -749,7 +771,14 @@ export function DiffViewerContent(props: {
|
||||
}
|
||||
|
||||
const openHelpDialog = () => {
|
||||
dialog.show(() => <DiffViewerHelpDialog context={props.context} single={singlePatch()} />)
|
||||
dialog.show(() => (
|
||||
<DiffViewerHelpDialog
|
||||
context={props.context}
|
||||
single={singlePatch()}
|
||||
fullscreen={props.panel !== undefined}
|
||||
tree={!docked()}
|
||||
/>
|
||||
))
|
||||
dialog.set({ size: "medium", centered: true })
|
||||
}
|
||||
|
||||
@@ -875,7 +904,13 @@ export function DiffViewerContent(props: {
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<box flexGrow={1} minWidth={0} minHeight={0} paddingLeft={2} paddingRight={2}>
|
||||
<box
|
||||
flexGrow={1}
|
||||
minWidth={0}
|
||||
minHeight={0}
|
||||
paddingLeft={docked() ? 1 : 2}
|
||||
paddingRight={docked() ? 1 : 2}
|
||||
>
|
||||
<box
|
||||
id="diff-patch-top-edge"
|
||||
ref={(edge: BoxRenderable) => {
|
||||
@@ -954,7 +989,7 @@ export function DiffViewerContent(props: {
|
||||
zIndex={1}
|
||||
backgroundColor={background()}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
paddingRight={docked() ? 0 : 1}
|
||||
paddingBottom={1}
|
||||
>
|
||||
<box flexGrow={1} minWidth={0}>
|
||||
@@ -1057,7 +1092,10 @@ export function DiffViewerContent(props: {
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<Show when={!showFileTree()}>
|
||||
<Show when={docked() ? props.panel : undefined}>
|
||||
{(panel) => <DiffViewerFooter context={props.context} panel={panel()} onHelp={openHelpDialog} />}
|
||||
</Show>
|
||||
<Show when={!showFileTree() && !docked()}>
|
||||
<box position="absolute" top={0} right={0} width={1} height={1}>
|
||||
<HelpShortcut compact />
|
||||
</box>
|
||||
@@ -1077,7 +1115,7 @@ export function DiffViewerContent(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean }) {
|
||||
function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean; fullscreen: boolean; tree: boolean }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme.contextual.elevated
|
||||
const shortcut =
|
||||
@@ -1115,8 +1153,11 @@ function DiffViewerHelpDialog(props: { context: Plugin.Context; single: boolean
|
||||
rows: [
|
||||
{ shortcut: shortcut("diff.toggle_view"), label: "Split / unified" },
|
||||
{ shortcut: shortcut("diff.single_patch"), label: "All files / single file" },
|
||||
{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" },
|
||||
...(props.tree ? [{ shortcut: shortcut("diff.toggle_file_tree"), label: "Show / hide file tree" }] : []),
|
||||
{ shortcut: shortcut("diff.switch_source"), label: "Switch diff source" },
|
||||
...(props.fullscreen
|
||||
? [{ shortcut: shortcut("diff.toggle_fullscreen"), label: "Full screen / split view" }]
|
||||
: []),
|
||||
{ shortcut: () => props.context.keymap.shortcuts("diff.close").join(" / "), label: "Close diff viewer" },
|
||||
],
|
||||
},
|
||||
@@ -1185,6 +1226,13 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
palette: true,
|
||||
run() {
|
||||
const route = props.context.ui.router.current()
|
||||
props.context.ui.dialog.clear()
|
||||
const current = props.context.ui.panel.current()
|
||||
if (route.type === "session" && current?.sessionID === route.sessionID && current.name === ROUTE) {
|
||||
props.context.ui.panel.close()
|
||||
return
|
||||
}
|
||||
if (props.context.ui.panel.open(ROUTE)) return
|
||||
const returnRoute: Route =
|
||||
route.type === "home"
|
||||
? { type: "home" }
|
||||
@@ -1204,7 +1252,6 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
returnRoute,
|
||||
},
|
||||
})
|
||||
props.context.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1215,9 +1262,17 @@ function Commands(props: { context: Plugin.Context }) {
|
||||
export default Plugin.define({
|
||||
id: "opencode.diffs",
|
||||
setup(context) {
|
||||
context.ui.slot({
|
||||
append: "session.panel",
|
||||
render: (panel) => (
|
||||
<Show when={panel.name === ROUTE}>
|
||||
<DiffViewer context={context} sessionID={panel.sessionID} panel={panel} onClose={panel.close} />
|
||||
</Show>
|
||||
),
|
||||
})
|
||||
context.ui.router.register({
|
||||
name: ROUTE,
|
||||
render: () => <DiffViewer context={context} />,
|
||||
render: ({ data }) => <DiffRoute context={context} data={data} />,
|
||||
})
|
||||
context.ui.slot({ append: "app", render: () => <Commands context={context} /> })
|
||||
},
|
||||
|
||||
@@ -69,6 +69,28 @@ test("closing the diff viewer returns to the route it opened from", async () =>
|
||||
}
|
||||
})
|
||||
|
||||
test.each([80, 160])("standalone full-screen diff does not expose panel-only toggling at %i columns", async (width) => {
|
||||
const viewer = await renderDiffViewer([], {
|
||||
width,
|
||||
initialRoute: {
|
||||
type: "plugin",
|
||||
id: "opencode.diffs",
|
||||
name: "diff",
|
||||
data: { sessionID: "session-1", returnRoute: startRoute, split: true },
|
||||
},
|
||||
})
|
||||
try {
|
||||
const command = viewer.commands.get("diff.toggle_fullscreen")
|
||||
expect(typeof command?.enabled === "function" ? command.enabled() : command?.enabled).toBe(false)
|
||||
const route = viewer.current()
|
||||
viewer.app.mockInput.pressKey("f")
|
||||
await viewer.app.flush()
|
||||
expect(viewer.current()).toEqual(route)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("ctrl+c closes the diff viewer without exiting the application", async () => {
|
||||
const viewer = await renderDiffViewer([])
|
||||
|
||||
@@ -1918,6 +1940,7 @@ async function renderDiffViewer(
|
||||
},
|
||||
ui: {
|
||||
dialog: createDialogApi(dialog, (render) => render()),
|
||||
panel: { open: () => false, close: () => {}, current: () => undefined },
|
||||
router: {
|
||||
register(page: Page) {
|
||||
if (page.name === "diff") renderDiff = page.render
|
||||
|
||||
@@ -209,6 +209,7 @@ test("centralizes named command defaults and resolves explicit none", () => {
|
||||
"diff.next_hunk": "]",
|
||||
"diff.previous_hunk": "[",
|
||||
"diff.mark_reviewed": "m",
|
||||
"diff.toggle_fullscreen": "f",
|
||||
"diff.help": "?,shift+?,shift+/",
|
||||
}
|
||||
const config = resolve({}, { terminalSuspend: true })
|
||||
|
||||
@@ -0,0 +1,625 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import {
|
||||
DiffRenderable,
|
||||
InputRenderable,
|
||||
MouseButton,
|
||||
type Renderable,
|
||||
ScrollBoxRenderable,
|
||||
TextareaRenderable,
|
||||
} from "@opentui/core"
|
||||
import type { FileDiffInfo, FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/client"
|
||||
import type { Config } from "../src/config"
|
||||
import { createAppFixture } from "./fixture/app"
|
||||
import { directory, json } from "./fixture/tui-client"
|
||||
import { tmpdir } from "./fixture/fixture"
|
||||
|
||||
test("/diff focuses the production session panel without sending typing to the prompt", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path)
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
const transcript = node(setup, "msg_panel_11")
|
||||
|
||||
expect(setup.requests.base).toHaveLength(0)
|
||||
expect(setup.requests.diff).toHaveLength(0)
|
||||
await setup.mockInput.typeText("/diff")
|
||||
await setup.waitForFrame((frame) => frame.includes("Open diff viewer"))
|
||||
setup.mockInput.pressEnter()
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
expect(panel.width).toBeLessThan(setup.renderer.width)
|
||||
expect(setup.renderer.currentFocusedEditor).toBeNull()
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
|
||||
await setup.mockInput.typeText("xyz")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.flush()
|
||||
expect(prompt.plainText).toBe("")
|
||||
expect(setup.requests.prompt).toHaveLength(0)
|
||||
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
await setup.mockInput.typeText("fjm")
|
||||
await setup.flush()
|
||||
expect(prompt.plainText).toBe("fjm")
|
||||
expect(panel.width).toBeLessThan(setup.renderer.width)
|
||||
expect(patches.scrollTop).toBe(0)
|
||||
expect(text(setup, "diff-review-count")).toBe("0/4")
|
||||
expect(text(setup, "diff-footer")).toContain("focus diff")
|
||||
|
||||
focus(setup, "right")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedRenderable === panel)
|
||||
setup.mockInput.pressKey("j")
|
||||
await setup.waitFor(() => patches.scrollTop > 0)
|
||||
expect(prompt.plainText).toBe("fjm")
|
||||
setup.mockInput.pressKey("q")
|
||||
await setup.waitFor(() => !setup.renderer.root.findDescendantById("session-panel"))
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
expect(prompt.plainText).toBe("fjm")
|
||||
expect(setup.requests.prompt).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("repeated fullscreen toggles retain the viewer, transcript, scroll, and reviewed files", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path)
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
const transcript = node(setup, "msg_panel_11")
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
const splitWidth = panel.width
|
||||
const history = descendants(setup.renderer.root).find(
|
||||
(item) => item instanceof ScrollBoxRenderable && item.findDescendantById("msg_panel_11"),
|
||||
)
|
||||
if (!(history instanceof ScrollBoxRenderable)) throw new Error("Missing session transcript scrollbox")
|
||||
history.scrollTo(4)
|
||||
await setup.flush()
|
||||
const historyTop = history.scrollTop
|
||||
expect(historyTop).toBeGreaterThan(0)
|
||||
|
||||
setup.mockInput.pressKey("m")
|
||||
await setup.waitForFrame(() => text(setup, "diff-review-count") === "1/4")
|
||||
await setup.flush()
|
||||
setup.mockInput.pressKey("n")
|
||||
await setup.waitFor(() => patches.scrollTop > 0)
|
||||
setup.mockInput.pressKey("j")
|
||||
await setup.flush()
|
||||
const top = patches.scrollTop
|
||||
const header = node(setup, "diff-file-header-1")
|
||||
const diffs = descendants(patches).filter((item) => item instanceof DiffRenderable)
|
||||
expect(diffs).toHaveLength(3)
|
||||
expect(top).toBeGreaterThan(0)
|
||||
|
||||
for (const fullscreen of [true, false, true, false]) {
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width === (fullscreen ? setup.renderer.width : splitWidth))
|
||||
await setup.flush()
|
||||
expect(node(setup, "session-panel")).toBe(panel)
|
||||
expect(scroll(setup)).toBe(patches)
|
||||
expect(patches.scrollTop).toBe(top)
|
||||
expect(node(setup, "diff-file-header-1")).toBe(header)
|
||||
const current = descendants(patches).filter((item) => item instanceof DiffRenderable)
|
||||
expect(current).toHaveLength(diffs.length)
|
||||
current.forEach((item, index) => expect(item).toBe(diffs[index]))
|
||||
expect(text(setup, "diff-review-count")).toBe("1/4")
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
expect(transcript.isDestroyed).toBe(false)
|
||||
expect(history.isDestroyed).toBe(false)
|
||||
expect(history.scrollTop).toBe(historyTop)
|
||||
expect(prompt.isDestroyed).toBe(false)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
expect(setup.requests.base).toHaveLength(1)
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
}
|
||||
|
||||
setup.mockInput.pressKey("HOME")
|
||||
await setup.waitForFrame((frame) => frame.includes("file00.txt") && text(setup, "diff-file-header-0").includes("✓"))
|
||||
expect(text(setup, "diff-file-header-0")).toContain("✓")
|
||||
expect(setup.requests.diff[0].searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(setup.requests.diff[0].searchParams.get("base")).toBe("refs/heads/v2")
|
||||
})
|
||||
|
||||
test("docked tree commands leave the full-screen tree preference unchanged", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, { diffs: { tree: true } })
|
||||
await ready(setup)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
expect(setup.renderer.root.findDescendantById("diff-files")).toBeUndefined()
|
||||
setup.mockInput.pressKey("b")
|
||||
await setup.flush()
|
||||
expect(setup.renderer.root.findDescendantById("diff-files")).toBeUndefined()
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => setup.renderer.root.findDescendantById("diff-files") !== undefined)
|
||||
setup.mockInput.pressKey("b")
|
||||
await setup.waitFor(() => setup.renderer.root.findDescendantById("diff-files") === undefined)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width < setup.renderer.width)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width === setup.renderer.width)
|
||||
expect(setup.renderer.root.findDescendantById("diff-files")).toBeUndefined()
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("right-clicking an inactive diff focuses its screen-relative file menu", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path)
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
const header = node(setup, "diff-file-header-0")
|
||||
await setup.mockMouse.click(header.x + 1, header.y, MouseButton.RIGHT)
|
||||
await setup.waitFor(() => setup.renderer.root.findDescendantById("diff-file-menu") !== undefined)
|
||||
const menu = node(setup, "diff-file-menu")
|
||||
expect(menu.x).toBe(Math.min(header.x + 1, setup.renderer.width - menu.width))
|
||||
expect(menu.x + menu.width).toBeLessThanOrEqual(setup.renderer.width)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame(() => text(setup, "diff-review-count") === "1/4")
|
||||
expect(setup.renderer.root.findDescendantById("diff-file-menu")).toBeUndefined()
|
||||
expect(prompt.plainText).toBe("")
|
||||
})
|
||||
|
||||
test.each([60, 80])("a panel opened at %i columns can split after growing without remounting", async (width) => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, { width })
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
const transcript = node(setup, "msg_panel_11")
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
expect(panel.width).toBe(width)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.flush()
|
||||
expect(panel.width).toBe(width)
|
||||
expect(prompt.plainText).toBe("")
|
||||
|
||||
setup.resize(160, 36)
|
||||
await setup.flush()
|
||||
const grownWidth = panel.width
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width !== grownWidth)
|
||||
expect(node(setup, "session-panel")).toBe(panel)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === grownWidth)
|
||||
if (panel.width === setup.renderer.width) {
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width < setup.renderer.width)
|
||||
}
|
||||
expect(panel.x).toBeGreaterThan(0)
|
||||
setup.resize(80, 36)
|
||||
await setup.waitFor(() => panel.width === 80)
|
||||
expect(scroll(setup)).toBe(patches)
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
expect(prompt.isDestroyed).toBe(false)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
expect(setup.requests.base).toHaveLength(1)
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("split eligibility uses the columns remaining beside vertical session tabs", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, {
|
||||
width: 122,
|
||||
tabs: { enabled: true, layout: "vertical" },
|
||||
})
|
||||
await ready(setup)
|
||||
await setup.waitFor(() => editor(setup).x >= 42)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
expect(panel.x).toBe(0)
|
||||
expect(panel.width).toBe(122)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.flush()
|
||||
expect(panel.x).toBe(0)
|
||||
expect(panel.width).toBe(122)
|
||||
|
||||
setup.resize(180, 36)
|
||||
await setup.flush()
|
||||
const grownWidth = panel.width
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => node(setup, "session-panel").width !== grownWidth)
|
||||
expect(node(setup, "session-panel")).toBe(panel)
|
||||
expect(setup.requests.base).toHaveLength(1)
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("panel focus, scrolling, and fullscreen use customized keys and footer hints", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, {
|
||||
width: 240,
|
||||
keybinds: {
|
||||
leader: "ctrl+space",
|
||||
"pane.focus.left": "<leader>h",
|
||||
"pane.focus.right": "<leader>l",
|
||||
"diff.toggle_fullscreen": "f7",
|
||||
"diff.down": "x",
|
||||
"diff.up": "z",
|
||||
},
|
||||
})
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
const splitWidth = panel.width
|
||||
expect(text(setup, "diff-footer")).toContain("ctrl+space h")
|
||||
expect(text(setup, "diff-footer")).toContain("f7")
|
||||
expect(text(setup, "diff-footer")).not.toContain("ctrl+x")
|
||||
setup.mockInput.pressKey("f")
|
||||
setup.mockInput.pressKey("j")
|
||||
focus(setup, "left")
|
||||
await setup.flush()
|
||||
expect(panel.width).toBe(splitWidth)
|
||||
expect(patches.scrollTop).toBe(0)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
setup.mockInput.pressKey("x")
|
||||
await setup.waitForFrame(() => patches.scrollTop > 0)
|
||||
setup.mockInput.pressKey("F7")
|
||||
await setup.waitFor(() => panel.width === setup.renderer.width)
|
||||
setup.mockInput.pressKey("F7")
|
||||
await setup.waitFor(() => panel.width === splitWidth)
|
||||
|
||||
setup.mockInput.pressKey(" ", { ctrl: true })
|
||||
setup.mockInput.pressKey("h")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
await setup.waitForFrame(() => text(setup, "diff-footer").includes("ctrl+space l"))
|
||||
setup.mockInput.pressKey(" ", { ctrl: true })
|
||||
setup.mockInput.pressKey("l")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedRenderable === panel)
|
||||
expect(prompt.plainText).toBe("")
|
||||
})
|
||||
|
||||
test.each(["none", false] as const)(
|
||||
"disabled panel keys (%s) have no behavioral or footer fallbacks",
|
||||
async (binding) => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, {
|
||||
width: 240,
|
||||
keybinds: {
|
||||
"pane.focus.left": binding,
|
||||
"pane.focus.right": binding,
|
||||
"diff.toggle_fullscreen": binding,
|
||||
"diff.down": binding,
|
||||
"diff.up": binding,
|
||||
"diff.next_file": binding,
|
||||
"diff.previous_file": binding,
|
||||
"diff.next_hunk": binding,
|
||||
"diff.previous_hunk": binding,
|
||||
"diff.mark_reviewed": binding,
|
||||
"diff.close": binding,
|
||||
"diff.help": binding,
|
||||
},
|
||||
})
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const splitWidth = panel.width
|
||||
const patches = scroll(setup)
|
||||
await setup.mockInput.typeText("fjknpm?[]q")
|
||||
focus(setup, "left")
|
||||
await setup.flush()
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
expect(panel.width).toBe(splitWidth)
|
||||
expect(patches.scrollTop).toBe(0)
|
||||
expect(text(setup, "diff-review-count")).toBe("0/4")
|
||||
expect(prompt.plainText).toBe("")
|
||||
expect(text(setup, "diff-footer")).not.toMatch(/ctrl\+x|full screen|j\/k|n\/p|hunks|q close|\? see all/)
|
||||
|
||||
await setup.mockMouse.click(prompt.x + 1, prompt.y)
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
await setup.flush()
|
||||
expect(text(setup, "diff-footer")).not.toContain("ctrl+x")
|
||||
focus(setup, "right")
|
||||
await setup.flush()
|
||||
expect(setup.renderer.currentFocusedEditor).toBe(prompt)
|
||||
},
|
||||
)
|
||||
|
||||
test("Enter in the diff panel cannot approve a pending permission in either presentation", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, { permission: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("Permission required"))
|
||||
const permissionNode = node(setup, "session.permission")
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const panel = node(setup, "session-panel")
|
||||
const splitWidth = panel.width
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.flush()
|
||||
expect(setup.requests.permission).toHaveLength(0)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === setup.renderer.width)
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.flush()
|
||||
expect(setup.requests.permission).toHaveLength(0)
|
||||
expect(node(setup, "session.permission")).toBe(permissionNode)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === splitWidth)
|
||||
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedRenderable !== panel)
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitFor(() => setup.requests.permission.length === 1)
|
||||
expect(setup.requests.permission).toEqual([{ reply: "once" }])
|
||||
})
|
||||
|
||||
test.each([true, false])("a form cannot capture diff keys (form already pending: %s)", async (pending) => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path, { form: pending })
|
||||
await setup.waitForFrame((frame) => frame.includes(pending ? setup.form.title : "Transcript item 11"))
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
if (!pending) {
|
||||
setup.events.emit({
|
||||
id: "evt_panel_form",
|
||||
created: 20,
|
||||
type: "form.created",
|
||||
location: { directory },
|
||||
data: { form: setup.form },
|
||||
})
|
||||
await setup.waitForFrame((frame) => frame.includes(setup.form.title))
|
||||
}
|
||||
const panel = node(setup, "session-panel")
|
||||
const patches = scroll(setup)
|
||||
const splitWidth = panel.width
|
||||
setup.mockInput.pressKey("j")
|
||||
await setup.waitFor(() => patches.scrollTop > 0)
|
||||
setup.mockInput.pressKey("m")
|
||||
await setup.waitForFrame(() => text(setup, "diff-review-count") === "1/4")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.flush()
|
||||
expect(setup.requests.form).toHaveLength(0)
|
||||
expect(setup.renderer.currentFocusedRenderable).toBe(panel)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === setup.renderer.width)
|
||||
setup.mockInput.pressKey("f")
|
||||
await setup.waitFor(() => panel.width === splitWidth)
|
||||
expect(setup.requests.form).toHaveLength(0)
|
||||
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedRenderable !== panel)
|
||||
setup.mockInput.pressKey("x")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor?.plainText === "answerx")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitFor(() => setup.requests.form.length === 1)
|
||||
expect(setup.requests.form).toEqual([{ answer: { target: "answerx" } }])
|
||||
})
|
||||
|
||||
test("disabling the registered diff plugin closes its open panel and removes its bindings", async () => {
|
||||
await using state = await tmpdir()
|
||||
await using setup = await createSessionFixture(state.path)
|
||||
await ready(setup)
|
||||
const prompt = editor(setup)
|
||||
const transcript = node(setup, "msg_panel_11")
|
||||
setup.mockInput.pressKey("F6")
|
||||
await panelReady(setup)
|
||||
const patches = scroll(setup)
|
||||
focus(setup, "left")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
await setup.mockInput.typeText("/plugins")
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitForFrame((frame) => frame.includes("Plugins") && frame.includes("show internal"))
|
||||
setup.mockInput.pressKey("a", { ctrl: true })
|
||||
await setup.waitForFrame((frame) => frame.includes("opencode.diffs"))
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof InputRenderable)
|
||||
await setup.mockInput.typeText("opencode.diffs")
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor?.plainText === "opencode.diffs")
|
||||
await setup.flush()
|
||||
setup.mockInput.pressEnter()
|
||||
await setup.waitFor(() => !setup.renderer.root.findDescendantById("session-panel"))
|
||||
await setup.flush()
|
||||
if (setup.captureCharFrame().includes("Plugins")) setup.mockInput.pressEscape()
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor === prompt)
|
||||
expect(patches.isDestroyed).toBe(true)
|
||||
expect(node(setup, "msg_panel_11")).toBe(transcript)
|
||||
setup.mockInput.pressKey("F6")
|
||||
await setup.flush()
|
||||
expect(setup.renderer.root.findDescendantById("session-panel")).toBeUndefined()
|
||||
await setup.mockInput.typeText("fjm")
|
||||
expect(prompt.plainText).toBe("fjm")
|
||||
expect(setup.requests.base).toHaveLength(1)
|
||||
expect(setup.requests.diff).toHaveLength(1)
|
||||
})
|
||||
|
||||
const files = Array.from({ length: 4 }, (_, index) => ({
|
||||
file: `src/file0${index}.txt`,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: [
|
||||
`--- a/src/file0${index}.txt`,
|
||||
`+++ b/src/file0${index}.txt`,
|
||||
"@@ -1,30 +1,30 @@",
|
||||
`-before ${index}`,
|
||||
`+after ${index}`,
|
||||
...Array.from({ length: 29 }, (_, line) => ` context ${index}-${line}`),
|
||||
"",
|
||||
].join("\n"),
|
||||
})) satisfies FileDiffInfo[]
|
||||
|
||||
async function createSessionFixture(
|
||||
state: string,
|
||||
input: {
|
||||
width?: number
|
||||
tabs?: Config.Info["tabs"]
|
||||
diffs?: Config.Info["diffs"]
|
||||
keybinds?: Config.Info["keybinds"]
|
||||
permission?: boolean
|
||||
form?: boolean
|
||||
} = {},
|
||||
) {
|
||||
// Drafts survive app disposal in memory, so each production app needs its own session identity.
|
||||
const session = {
|
||||
id: `ses_panel_${crypto.randomUUID()}`,
|
||||
title: "Panel fixture",
|
||||
projectID: "proj_panel",
|
||||
location: { directory },
|
||||
agent: "build",
|
||||
model: { providerID: "fixture", id: "model" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1, updated: 12 },
|
||||
} satisfies SessionInfo
|
||||
const permission = {
|
||||
id: `per_panel_${crypto.randomUUID()}`,
|
||||
sessionID: session.id,
|
||||
action: "shell",
|
||||
resources: ["echo permission-fixture"],
|
||||
} satisfies PermissionRequest
|
||||
const form = {
|
||||
id: `frm_panel_${crypto.randomUUID()}`,
|
||||
sessionID: session.id,
|
||||
title: "Panel form fixture",
|
||||
fields: [
|
||||
{
|
||||
key: "target",
|
||||
type: "string",
|
||||
title: "Target",
|
||||
options: [{ value: "staging", label: "Staging" }],
|
||||
custom: true,
|
||||
default: "answer",
|
||||
},
|
||||
],
|
||||
} satisfies FormInfo
|
||||
const location = { directory, project: { id: session.projectID, directory, canonical: directory } }
|
||||
const requests = {
|
||||
base: [] as URL[],
|
||||
diff: [] as URL[],
|
||||
prompt: [] as unknown[],
|
||||
permission: [] as unknown[],
|
||||
form: [] as unknown[],
|
||||
}
|
||||
const setup = await createAppFixture({
|
||||
width: input.width ?? 160,
|
||||
height: 36,
|
||||
state,
|
||||
args: { sessionID: session.id },
|
||||
config: {
|
||||
animations: false,
|
||||
tabs: input.tabs ?? { enabled: false },
|
||||
session: { sidebar: "hide", terminal: false },
|
||||
// Keep patch geometry stable while testing host presentation changes, not split/unified diff layout.
|
||||
diffs: { tree: false, view: "unified", ...input.diffs },
|
||||
keybinds: { "diff.open": "f6", ...input.keybinds },
|
||||
},
|
||||
fetch: async (url, request) => {
|
||||
if (url.pathname === "/api/location") return json(location)
|
||||
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
|
||||
if (url.pathname === "/api/agent")
|
||||
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
|
||||
if (url.pathname === "/api/model")
|
||||
return json({ location, data: [{ id: "model", providerID: "fixture", name: "Model", variants: [] }] })
|
||||
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "fixture", name: "Fixture" }] })
|
||||
if (url.pathname === "/api/vcs") return json({ location, data: { branch: { current: "panel", default: "v2" } } })
|
||||
if (url.pathname === "/api/vcs/base") {
|
||||
requests.base.push(url)
|
||||
return json({ location, data: { name: "v2", ref: "refs/heads/v2", source: "default" } })
|
||||
}
|
||||
if (url.pathname === "/api/vcs/diff") {
|
||||
requests.diff.push(url)
|
||||
return json({ location, data: files })
|
||||
}
|
||||
if (url.pathname === `/api/session/${session.id}`) return json({ data: session })
|
||||
if (url.pathname === `/api/session/${session.id}/message`)
|
||||
return json({
|
||||
data: Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `msg_panel_${String(index).padStart(2, "0")}`,
|
||||
type: "user",
|
||||
text: `Transcript item ${index}`,
|
||||
time: { created: index + 1 },
|
||||
})).toReversed(),
|
||||
cursor: {},
|
||||
})
|
||||
if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] })
|
||||
if (url.pathname === `/api/session/${session.id}/permission`)
|
||||
return json({ data: input.permission ? [permission] : [] })
|
||||
if (url.pathname === `/api/session/${session.id}/form`) return json({ data: input.form ? [form] : [] })
|
||||
if (url.pathname === `/api/session/${session.id}/prompt`) {
|
||||
requests.prompt.push(await request.json())
|
||||
return json({ data: {} })
|
||||
}
|
||||
if (url.pathname === `/api/session/${session.id}/permission/${permission.id}/reply`) {
|
||||
requests.permission.push(await request.json())
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
if (url.pathname === `/api/session/${session.id}/form/${form.id}/reply`) {
|
||||
requests.form.push(await request.json())
|
||||
return new Response(null, { status: 204 })
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
return { ...setup, requests, form }
|
||||
}
|
||||
|
||||
type AppFixture = Awaited<ReturnType<typeof createAppFixture>>
|
||||
|
||||
async function ready(setup: AppFixture) {
|
||||
await setup.ready
|
||||
await setup.waitForFrame((frame) => frame.includes("Transcript item 11") && frame.includes("Build · Model Fixture"), {
|
||||
maxPasses: 120,
|
||||
})
|
||||
await setup.waitFor(() => setup.renderer.currentFocusedEditor instanceof TextareaRenderable)
|
||||
}
|
||||
|
||||
async function panelReady(setup: AppFixture) {
|
||||
await setup.waitForFrame(
|
||||
(frame) => frame.includes("file00.txt") && setup.renderer.currentFocusedRenderable?.id === "session-panel",
|
||||
{ maxPasses: 120 },
|
||||
)
|
||||
await setup.flush()
|
||||
}
|
||||
|
||||
function focus(setup: AppFixture, direction: "left" | "right") {
|
||||
setup.mockInput.pressKey("x", { ctrl: true })
|
||||
setup.mockInput.pressArrow(direction)
|
||||
}
|
||||
|
||||
function node(setup: AppFixture, id: string) {
|
||||
const result = setup.renderer.root.findDescendantById(id)
|
||||
if (!result) throw new Error(`Missing renderable: ${id}\n${setup.captureCharFrame()}`)
|
||||
return result
|
||||
}
|
||||
|
||||
function scroll(setup: AppFixture) {
|
||||
const result = node(setup, "diff-patches")
|
||||
if (!(result instanceof ScrollBoxRenderable)) throw new Error("Missing diff scrollbox")
|
||||
return result
|
||||
}
|
||||
|
||||
function editor(setup: AppFixture) {
|
||||
const result = setup.renderer.currentFocusedEditor
|
||||
if (!(result instanceof TextareaRenderable)) throw new Error("Missing focused session prompt")
|
||||
return result
|
||||
}
|
||||
|
||||
function text(setup: AppFixture, id: string) {
|
||||
const item = setup.renderer.root.findDescendantById(id)
|
||||
if (!item) return ""
|
||||
return setup
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.slice(item.y, item.y + item.height)
|
||||
.map((line) => line.slice(item.x, item.x + item.width).trim())
|
||||
.join("\n")
|
||||
.trim()
|
||||
}
|
||||
|
||||
function descendants(root: Renderable): Renderable[] {
|
||||
return root.getChildren().flatMap((item) => [item, ...descendants(item)])
|
||||
}
|
||||
Reference in New Issue
Block a user