opencode/packages/tui/test/cli/tui/diff-viewer.test.tsx
2026-07-28 16:25:32 -04:00

307 lines
9.2 KiB
TypeScript

/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { DiffRenderable, type Renderable, ScrollBoxRenderable } from "@opentui/core"
import { testRender } from "@opentui/solid"
import type {
Context,
Destination,
KeymapCommand,
KeymapLayer,
Page,
Route,
Slot,
} from "@opencode-ai/plugin/tui/context"
import { ThemeProvider, useTheme, useThemes } from "../../../src/context/theme"
import { ConfigProvider } from "../../../src/config"
import { TuiKeybind } from "../../../src/config/keybind"
import { Keymap } from "../../../src/context/keymap"
import diffViewerPlugin from "../../../src/feature-plugins/system/diff-viewer"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { TestTuiContexts } from "../../fixture/tui-environment"
import { createApi, createEventStream, createFetch, json } from "../../fixture/tui-client"
import { DialogProvider } from "../../../src/ui/dialog"
import { ToastProvider } from "../../../src/ui/toast"
import { createPluginTheme } from "../../../src/plugin/context"
test("closing the diff viewer returns to the route it opened from", async () => {
const viewer = await renderDiffViewer([])
try {
expect(viewer.current()).toEqual({
type: "plugin",
id: "diff-viewer",
name: "diff",
data: { mode: "working", sessionID: "session-1", returnRoute: startRoute },
})
const route = viewer.current()
expect(route.type === "plugin" ? route.data?.returnRoute : undefined).not.toBe(startRoute)
expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/session" },
mode: "working",
context: "12",
})
expect(viewer.commands.has("diff.close")).toBe(true)
viewer.commands.get("diff.close")!.run()
expect(viewer.current()).toEqual(startRoute)
} finally {
viewer.app.renderer.destroy()
}
})
test("shows an error instead of an empty diff when loading fails", async () => {
const viewer = await renderDiffViewer([], 20, undefined, true)
try {
await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff"))
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
} finally {
viewer.app.renderer.destroy()
}
})
test("uses the active location when opened outside a session", async () => {
const viewer = await renderDiffViewer([], 20, { type: "home" })
try {
expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/default" },
mode: "working",
context: "12",
})
} finally {
viewer.app.renderer.destroy()
}
})
test("brackets navigate diff hunks", async () => {
const viewer = await renderDiffViewer(
[
{
file: "src/file.ts",
additions: 3,
deletions: 3,
status: "modified",
patch: `--- a/src/file.ts
+++ b/src/file.ts
@@ -1,3 +1,3 @@
const first = true
-const oldFirst = true
+const newFirst = true
const afterFirst = true
@@ -20,3 +20,3 @@
const second = true
-const oldSecond = true
+const newSecond = true
const afterSecond = true
@@ -40,3 +40,3 @@
const third = true
-const oldThird = true
+const newThird = true
const afterThird = true`,
},
],
12,
)
try {
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
await viewer.app.waitFor(() => Boolean(findScrollBox(viewer.app.renderer.root)))
await viewer.app.flush()
const scroll = findScrollBox(viewer.app.renderer.root)!
const initial = scroll.scrollTop
expect(TuiKeybind.defaultValue("diff_next_hunk")).toBe("]")
expect(TuiKeybind.defaultValue("diff_previous_hunk")).toBe("[")
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
const first = scroll.scrollTop
expect(first).toBeGreaterThan(initial)
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
const second = scroll.scrollTop
expect(second).toBeGreaterThan(first)
viewer.commands.get("diff.previous_hunk")!.run()
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(second)
scroll.scrollTo(initial)
viewer.commands.get("diff.next_hunk")!.run()
await viewer.app.renderOnce()
expect(scroll.scrollTop).toBe(first)
} finally {
viewer.app.renderer.destroy()
}
})
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: Route, fail = false) {
const commands = new Map<string, KeymapCommand>()
let current = initialRoute ?? startRoute
let renderDiff: Page["render"] | undefined
let renderCommands: Slot | undefined
let vcsDiffInput: unknown
const config = createTuiResolvedConfig()
const transport = createFetch((url) => {
if (url.pathname !== "/api/vcs/diff") return
if (fail) return json({ message: "boom" }, { status: 500 })
vcsDiffInput = {
location: { directory: url.searchParams.get("location[directory]") },
mode: url.searchParams.get("mode"),
context: url.searchParams.get("context"),
}
return json({
location: { directory: "/repo/session", project: { id: "project-1", directory: "/repo/session" } },
data: vcsDiff,
})
}, createEventStream())
function Harness() {
let theme: ReturnType<typeof createPluginTheme>
const context = {
options: {},
client: createApi(transport.fetch),
data: {
session: { get: () => session },
location: { default: () => ({ directory: "/repo/default" }) },
},
get theme() {
return theme
},
keymap: {
layer(input: () => KeymapLayer) {
input().commands?.forEach((command) => {
if (command.id) commands.set(command.id, command)
})
},
dispatch() {},
shortcuts: () => [],
mode: { current: () => "base", push: () => () => {} },
},
ui: {
dialog: {
show: () => () => {},
set() {},
clear() {},
},
router: {
register(page: Page) {
if (page.name === "diff") renderDiff = page.render
return () => {}
},
navigate(destination: Destination) {
current =
destination.type === "plugin" && !("id" in destination)
? { ...destination, id: "diff-viewer" }
: destination
},
current: () => current,
},
slot(_name: string, render: Slot) {
renderCommands = render
return () => {}
},
},
} as unknown as Context
void diffViewerPlugin.setup(context)
function Content() {
theme = createPluginTheme(useTheme(), useThemes())
const commandView = renderCommands?.({})
if (current.type !== "plugin") commands.get("diff.open")?.run()
return (
<>
{commandView}
{renderDiff?.({ data: current.type === "plugin" ? current.data : undefined })}
</>
)
}
return (
<TestTuiContexts>
<ConfigProvider config={config}>
<Keymap.Provider>
<ToastProvider>
<ThemeProvider mode="dark">
<DialogProvider>
<Content />
</DialogProvider>
</ThemeProvider>
</ToastProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
)
}
const app = await testRender(() => <Harness />, { width: 80, height })
await waitForCommand(app, commands, "diff.close")
return {
app,
commands,
current: () => current,
vcsDiffInput: () => vcsDiffInput,
}
}
const startRoute: Route = { type: "session", sessionID: "session-1" }
function findScrollBox(root: Renderable): ScrollBoxRenderable | undefined {
if (root instanceof ScrollBoxRenderable && containsDiff(root)) return root
return root.getChildren().map(findScrollBox).find(Boolean)
}
function containsDiff(root: Renderable): boolean {
if (root instanceof DiffRenderable) return true
return root.getChildren().some(containsDiff)
}
const session = {
id: "session-1",
projectID: "project-1",
location: { directory: "/repo/session" },
title: "Session",
cost: { currency: "USD", amount: 0 },
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: {
created: 0,
updated: 0,
},
}
test("branch diff source requests branch VCS diff", async () => {
const viewer = await renderDiffViewer([], 20, {
type: "plugin",
id: "diff-viewer",
name: "diff",
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
})
try {
expect(viewer.current()).toEqual({
type: "plugin",
id: "diff-viewer",
name: "diff",
data: { mode: "branch", sessionID: "session-1", returnRoute: startRoute },
})
expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/session" },
mode: "branch",
context: "12",
})
} finally {
viewer.app.renderer.destroy()
}
})
async function waitForCommand(
app: Awaited<ReturnType<typeof testRender>>,
commands: Map<string, unknown>,
command: string,
) {
for (let attempt = 0; attempt < 10; attempt++) {
await app.renderOnce()
if (commands.has(command)) return
await new Promise((resolve) => setTimeout(resolve, 25))
}
}