packages/app/component-tests/plugin-host.fixture.tsx
Rama dev · réplica de lectura del repositorio original.
import type { PluginInfo, SessionMessageInfo } from "@opencode/client/promise"
import { DialogProvider, useDialog } from "@opencode/ui/context/dialog"
import { Menu } from "@opencode/ui/menu"
import { Toast } from "@opencode/ui/toast"
import {
Panel,
type Appearance,
type App,
type Keybinds,
type MountedSession,
type Router,
type Servers,
type SessionScreen,
type Ipc,
type IpcClient,
type IpcSpec,
type Plugin,
type Storage,
type StoreOptions,
type Workspaces,
} from "@opencode/plugin/gui"
import type { BuiltinLayout, BuiltinLocale } from "@opencode/plugin/gui/builtin"
import {
createComponent,
createMemo,
createRoot,
createSignal,
ErrorBoundary,
getOwner,
onCleanup,
runWithOwner,
Show,
type ParentProps,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { Schema } from "effect"
import { render } from "solid-js/web"
import { PlatformProvider, type Platform } from "@/runtime/platform/platform"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { ServerScope } from "@/runtime/server/scope"
import { PluginHostProvider, usePluginHost, type HostApiFactories, type PluginInput } from "../src/runtime/plugin/host"
import { createHostApis } from "../src/runtime/plugin/host-apis"
import type { Bridge, Entry } from "@opencode/plugin/gui/host"
import { createRegion, RegionContent } from "../src/runtime/plugin/panels"
import { createSessionScreenLayout } from "../src/session/screen-layout"
import type { SessionModel } from "../src/session/model"
import { createClosedTabs } from "../src/runtime/plugin/closed-tabs"
import { PluginLinks, PluginSlot, PluginThemes } from "../src/runtime/plugin/render"
import { Markdown } from "@opencode/session-ui/markdown"
import { ThemeProvider, useTheme, type ColorScheme } from "@opencode/ui/theme/context"
import { persistedHandle } from "../src/runtime/plugin/stores"
import { LanguageProvider } from "../src/runtime/i18n/language"
import { ExtensionPlugins } from "../src/runtime/plugin/extension-plugins"
import { SessionPluginPills } from "../src/session/composer/plugin-pills"
import { ui } from "../src/runtime/plugin/ui"
export {
bindPlugin,
usePlugin,
CodeBlock,
CodeBlockAction,
ComposerPill,
Contract,
createKeyed,
Ipc,
Panel,
Slot,
Store,
Theme,
} from "@opencode/plugin/gui"
export { Schema }
type PluginHost = ReturnType<typeof usePluginHost>
/** What the app interface hands the real host APIs when it mounts. */
type Interface = Parameters<ReturnType<typeof createHostApis>["attach"]>[0]
/** The host APIs a mounted fixture created, once it rendered. */
type Captured = { apis?: ReturnType<typeof createHostApis> }
/** A value the fixture's storage holds as JSON. */
type Json = string | number | boolean | null | readonly Json[] | { readonly [key: string]: Json }
export { createComponent, createMemo, createRoot, createSignal, getOwner, onCleanup, runWithOwner, useDialog }
const layout: BuiltinLayout = {
narrow: () => false,
ready: () => true,
open() {},
close() {},
toggle() {},
hide() {},
state: () => "closed",
stored: () => [],
side: { opened: () => false, toggle() {}, menu: () => [], width: () => undefined, resize() {} },
sidebar: { opened: () => true },
scroll: { get: () => undefined, set() {} },
settings() {},
project() {},
draft() {},
command() {},
}
const about: App = { name: "opencode", version: "", channel: "dev", platform: "desktop", packaged: false }
const locale: BuiltinLocale = { locale: () => "en", direction: () => "ltr", setDirection() {} }
const appearance: Appearance = { font: () => "monospace", mode: () => "light" }
const router: Router = { routing: () => false, path: () => "/" }
const keybinds: Keybinds = { keybind: () => [], keys: (bind) => bind.split("+"), matches: () => false }
const servers: Servers = { list: () => [], get: () => undefined }
const workspaces: Workspaces = { on: () => () => undefined }
/** Every HostApi faked at its boundary, with the given storage; `ui` is the app's own components, which have none. */
function fakeApis(storage: (plugin: string) => Storage): HostApiFactories {
return {
app: () => about,
locale: () => locale,
appearance: () => appearance,
router: () => router,
keybinds: () => keybinds,
servers: () => servers,
workspaces: () => workspaces,
desktop: () => undefined,
sessions: () => ({ list: () => [], current: () => undefined }),
screen: () => ({ current: () => undefined }),
layout: () => layout,
storage,
system: () => ({ copy: async () => {}, save: async () => false, openExternal() {} }),
embeds: () => ({ View: () => null, capture: async () => undefined }),
toast: () => ({ show: () => ({ dismiss() {} }) }),
tabs: () => ({ list: () => [], open() {}, focus() {}, move() {}, close() {} }),
attention: () => ({ notify: async () => ({ notification: false, sound: false }) }),
commands: () => ({ run: () => false }),
ui: () => ui,
}
}
/** The fake HostApis' app interface, which is always mounted: a dialog shows at once. */
function mounted(run: () => void) {
run()
return () => undefined
}
/** Storage that no test of `mountPluginHost` reads. */
const unused = (): Storage => {
throw new Error("The fixture plugin reads no storage")
}
/** Resolves once `check` holds, checking every frame; rejects after five seconds. */
export async function until(check: () => boolean) {
const deadline = performance.now() + 5000
while (!check()) {
if (performance.now() > deadline) throw new Error("The fixture never reached the expected state")
await new Promise((resolve) => requestAnimationFrame(resolve))
}
}
/** Mounts the real plugin host with one plugin whose every renderer load settles when the test says so. */
export function mountPluginHost() {
const loads: PromiseWithResolvers<{ default: Plugin.Setup<Entry> }>[] = []
const [disabled, setDisabled] = createSignal<ReadonlySet<string>>(new Set())
const hosts: PluginHost[] = []
const container = document.createElement("div")
document.body.appendChild(container)
function Capture() {
hosts.push(usePluginHost())
return null
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<DialogProvider>
<PluginHostProvider
definitions={() => [
{
id: "fixture",
windowEntry: () => {
const load = Promise.withResolvers<{ default: Plugin.Setup<Entry> }>()
loads.push(load)
return load.promise
},
},
]}
disabled={disabled}
apis={fakeApis(unused)}
whenMounted={mounted}
>
<Capture />
</PluginHostProvider>
</DialogProvider>
</LanguageProvider>
),
container,
)
return {
unmount: () => {
dispose()
container.remove()
},
/** Resolves the nth renderer load (the first by default) with this setup. */
load: (setup: Plugin.Setup<Entry>, index = 0) => loads[index].resolve({ default: setup }),
/** Rejects the nth renderer load. */
fail: (index: number, cause: unknown) => loads[index].reject(cause),
/** Renderer loads requested so far. */
count: () => loads.length,
reload: () => hosts[0]?.reload("fixture"),
disable: () => setDisabled(new Set(["fixture"])),
enable: () => setDisabled(new Set<string>()),
status: () => hosts[0]?.status("fixture"),
/** Contributions the host holds for a registry; readable after the host unmounts. */
entries: (registry: string) => hosts[0]?.state.entries[registry]?.length ?? 0,
}
}
/**
* Mounts the real host over these definitions, before any session mounts, with the HostApis faked at their
* boundary. Storage is the real persisted store of a desktop window whose reads wait until `release()`; `stored` seeds
* it. Renders the `window.bottom` slot once the startup gate opens.
*/
export function mountPlugins(input: {
definitions: readonly Entry[]
disabled?: readonly string[]
bridge?: Bridge
stored?: Readonly<Record<string, Json>>
/** Shows the production Extensions › Plugins rows of the desktop app's own server, which lists these plugins. */
plugins?: readonly PluginInfo[]
/** The client of an Ipc while main provides it, as a desktop window's bridge reports it. Omitted: the web. */
ipc?: (token: Ipc) => IpcClient<IpcSpec> | undefined
/** Whether main failed to answer the window's subscribe to an Ipc. */
ipcFailed?: (token: Ipc) => boolean
/** What a plugin's GUI reads of its server plugin. Omitted: every plugin is a built-in. */
plugin?: (id: string) => PluginInput | undefined
}) {
const held = Promise.withResolvers<void>()
const saved = { ...input.stored }
const [disabled, setDisabled] = createSignal<ReadonlySet<string>>(new Set(input.disabled ?? []))
const [definitions, setDefinitions] = createSignal(input.definitions)
const hosts: PluginHost[] = []
const container = document.createElement("div")
document.body.appendChild(container)
const platform: Platform = {
platform: "desktop",
plugins: input.bridge,
windowID: "plugin-host-fixture",
openExternal: () => undefined,
openDirectoryPickerDialog: async () => null,
restart: async () => undefined,
notify: async () => undefined,
storage: () => ({
getItem: async (key: string) => {
await held.promise
return key in saved ? JSON.stringify(saved[key]) : null
},
setItem: async (key: string, value: string) => {
saved[key] = JSON.parse(value)
},
removeItem: async () => undefined,
}),
}
const storage = (plugin: string): Storage => ({
store<S extends Schema.ConstraintCodec<object, unknown>>(key: string, options: StoreOptions<S>) {
const pair = persisted(Persist.global(`plugin.${plugin}.${key}`), options.schema, options.initial, platform)
return persistedHandle({ store: pair[0], set: pair[1], init: pair[3].promise })
},
memory: (_key, options) => {
const [value, set] = createStore(options.initial)
return [value, (mutation) => set(produce(mutation))] as const
},
remove() {},
})
function Capture() {
const host = usePluginHost()
hosts.push(host)
return (
<Show when={host.ready()}>
<Show when={input.plugins}>
{(plugins) => (
<div data-testid="extension-plugins">
<ExtensionPlugins plugins={plugins()} own />
</div>
)}
</Show>
<PluginSlot at="window.bottom" input={{}} />
</Show>
)
}
// Errors that escape the host to the boundary above it, which in the app replaces the whole window.
const escaped: string[] = []
const dispose = render(
() => (
<ErrorBoundary
fallback={(error) => {
escaped.push(String(error))
return null
}}
>
<PlatformProvider value={platform}>
<LanguageProvider locale="en">
<DialogProvider>
{/* With a bridge, main's plugin manager holds the enable settings, as in a desktop window. */}
<PluginHostProvider
definitions={definitions}
disabled={input.bridge ? undefined : disabled}
bridge={input.bridge}
apis={fakeApis(storage)}
whenMounted={mounted}
ipc={input.ipc}
ipcFailed={input.ipcFailed}
plugin={input.plugin}
>
<Capture />
</PluginHostProvider>
</DialogProvider>
</LanguageProvider>
</PlatformProvider>
</ErrorBoundary>
),
container,
)
return {
container,
unmount: () => {
dispose()
container.remove()
},
/** What the window has written to storage, seeded with `stored`. */
saved: () => ({ ...saved }),
/** Lets the held storage reads answer. */
release: () => held.resolve(),
disable: (ids: readonly string[]) => setDisabled(new Set(ids)),
/** Replaces the definitions, as plugin GUIs join, reload and leave. */
define: (next: readonly Entry[]) => setDefinitions(next),
reload: (id: string) => hosts[0]?.reload(id),
ready: () => hosts[0]?.ready() ?? false,
status: (id: string) => hosts[0]?.status(id),
failure: (id: string) => hosts[0]?.failure(id),
entries: (registry: string) => hosts[0]?.state.entries[registry]?.length ?? 0,
/** Errors that reached the error boundary above the host. */
escaped: () => [...escaped],
}
}
const web: Platform = {
platform: "web",
openExternal: () => undefined,
restart: async () => undefined,
notify: async () => undefined,
}
/** A stand-in for the app interface the real host APIs attach to; each test overrides what it observes. */
function standIn(overrides: Partial<Interface>): Interface {
return {
sessions: () => [],
current: () => undefined,
tabs: { list: () => [], open() {}, focus() {}, move() {}, close() {} },
screen: () => undefined,
scope: () => ServerScope.local,
scoped: () => undefined,
layout: {
ready: () => true,
open() {},
close() {},
toggle() {},
hide() {},
state: () => "closed",
stored: () => [],
side: { opened: () => true, toggle() {}, menu: () => [], width: () => undefined, resize() {} },
sidebar: { opened: () => false },
scroll: { get: () => undefined, set() {} },
},
settings() {},
project() {},
draft() {},
command() {},
run: () => false,
notify: async () => ({ notification: false, sound: false }),
font: () => "fixture mono",
mode: () => "dark",
routing: () => false,
path: () => "/",
keybind: () => [],
matches: () => false,
servers: () => ["local"],
server: () => undefined,
...overrides,
}
}
/** The real host over the real HostApis of a web window; `mounted`, when given, attaches as the app interface. */
function RealHost(props: ParentProps<{ definitions: readonly Entry[]; captured: Captured; mounted?: Interface }>) {
const apis = createHostApis()
props.captured.apis = apis
if (props.mounted) onCleanup(apis.attach(props.mounted))
return (
<PluginHostProvider
definitions={() => props.definitions}
disabled={() => new Set<string>()}
apis={apis.apis}
whenMounted={apis.whenMounted}
>
{props.children}
</PluginHostProvider>
)
}
/**
* Mounts the real host, the side region and the session screen's layout in a row `width` pixels wide, with every
* panel's `main` tab stored. `select(key)` selects a tab as the user does; `drag(width)` drags the divider to that chat
* panel width, once `handle()` shows the row is measured. The chat panel (`data-testid="chat"`) takes the width the
* layout gives it. The conversation's own width
* and the per-panel widths are in-memory stores, read back through `conversation()` and `widths()`.
*/
export function mountPanelWidths(input: { definitions: readonly Entry[]; active: string; width: number }) {
const container = document.createElement("div")
document.body.appendChild(container)
const captured: Captured = {}
const [active, setActive] = createSignal(input.active)
const [conversation, setConversation] = createStore<{ width: number; resized: boolean }>({
width: 600,
resized: false,
})
const [widths, setWidths] = createStore<Record<string, number>>({})
const layouts: ReturnType<typeof createSessionScreenLayout>[] = []
const value = { key: "fixture\na", id: "a", tab: "a", pending: false, location: { directory: "/repo" }, visit: {} }
// SAFETY: the region passes the session through; the test's plugins read nothing of it.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const view = () => value as unknown as MountedSession
// SAFETY: the test's plugins never read the screen.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const screen = {} as unknown as SessionScreen
const model = {
isDesktop: () => true,
identity: { params: { id: "a" } },
layout: {
view: () => ({
side: { opened: () => true },
session: {
width: () => conversation.width,
resized: () => conversation.resized,
resize: (width: number) => setConversation({ width, resized: true }),
},
}),
},
}
// SAFETY: the screen layout reads only `isDesktop`, `identity.params.id` and the view's side and session fields.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const session = model as unknown as SessionModel
function Screen() {
const host = usePluginHost()
const all = () => host.items(Panel).map((item) => `${item.plugin}:main`)
const region = createRegion({
region: "side",
view,
screen,
tabs: () => ({
tabs: () => ({ all: all(), active: active() }),
all,
active,
setAll() {},
setActive: (key) => key && setActive(key),
close() {},
remap() {},
target() {},
}),
closed: createClosedTabs(),
open() {},
side: () => ({ opened: () => true, toggle() {} }),
})
const layout = createSessionScreenLayout(session, {
wide: region.wide,
panel: region.pane("side").selected,
widths: { get: (panel) => widths[panel], set: (panel, width) => setWidths(panel, width) },
})
layouts.push(layout)
return (
<div ref={layout.panel.ref} style={{ display: "flex", gap: "8px", width: `${input.width}px` }}>
<div data-testid="chat" style={{ "flex-shrink": "0", width: layout.panel.width() }} />
<div data-testid="side" style={{ flex: "1" }} />
</div>
)
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<PlatformProvider value={web}>
<DialogProvider>
<RealHost definitions={input.definitions} captured={captured}>
<Screen />
</RealHost>
</DialogProvider>
</PlatformProvider>
</LanguageProvider>
),
container,
)
return {
select: (key: string) => setActive(key),
drag: (width: number) => layouts[0]?.panel.resize(width),
/** The chat panel width the divider's handle starts a drag from, once the row is measured. */
handle: () => layouts[0]?.panel.resizedWidth(),
chat: () => container.querySelector<HTMLElement>('[data-testid="chat"]')?.getBoundingClientRect().width,
conversation: () => ({ ...conversation }),
widths: () => ({ ...widths }),
unmount: () => {
dispose()
container.remove()
},
}
}
/**
* Mounts the real host and HostApis, and the parts of the session screen that render plugin content for the routed
* session: the side region (`createRegion`, `RegionContent`) with every panel's tabs stored, the
* `session.panel.end` slot, whose input the screen builds this way, and the `session.location.menu` slot inside a
* "Project menu" the way the session header's project menu holds it. `route(id)` routes a session the way the screen
* does: a new session object each time, delivered through the same reactive inputs, while the one screen object
* `ctx.screen` returns follows the route. Its composer records which session each attachment reached.
*/
export function mountSessionRegion(input: {
definitions: readonly Entry[]
active: string
/** The routed session's messages, as its server holds them. */
messages?: readonly SessionMessageInfo[]
}) {
const container = document.createElement("div")
document.body.appendChild(container)
const hosts: PluginHost[] = []
const captured: Captured = {}
const attached: string[] = []
const object = (id: string) => {
const value = {
key: `fixture\n${id}`,
id,
tab: id,
pending: false,
location: { directory: "/repo" },
visit: {},
server: { data: { session: { message: { list: () => input.messages ?? [] } } } },
}
// SAFETY: the region and slot pass the object through; the test's plugins read only `key`, `id` and the
// server's message list.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
return value as unknown as MountedSession
}
const [view, setView] = createSignal(object("a"))
const [routed, setRouted] = createSignal(true)
const route = {
composer: { attach: () => void attached.push(view().id), update() {}, detach() {} },
}
// SAFETY: the test's plugin reads only `composer.attach` of the screen.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const screen = route as unknown as SessionScreen
const sidebar = { opened: () => false, width: () => 0, transition: () => false, resize() {}, toggle() {} }
function Region() {
const host = usePluginHost()
hosts.push(host)
// Every panel's `main` tab is stored, and `active` selected, as a strip restored after a reload.
const all = () => host.items(Panel).map((item) => `${item.plugin}:main`)
const region = createRegion({
region: "side",
view,
screen,
tabs: () => ({
tabs: () => ({ all: all(), active: input.active }),
all,
active: () => input.active,
setAll() {},
setActive() {},
close() {},
remap() {},
target() {},
}),
closed: createClosedTabs(),
open() {},
side: () => ({ opened: () => true, toggle() {} }),
})
return (
<Show when={host.ready()}>
<PluginSlot
at="session.panel.end"
input={{
get session() {
return view()
},
screen,
}}
/>
<Menu modal={false}>
{/* Without text, so the cases that read the region's text see only the plugins'. */}
<Menu.Trigger aria-label="Project menu" style={{ width: "16px", height: "16px" }} />
<Menu.Portal>
<Menu.Content>
<PluginSlot
at="session.location.menu"
input={{
get session() {
return view()
},
screen,
shown: true,
}}
/>
</Menu.Content>
</Menu.Portal>
</Menu>
<RegionContent
pane={region.pane("side")}
view={view()}
screen={screen}
frame={{
shown: () => true,
present: () => true,
placement: () => "side",
reserve: () => false,
animate: () => false,
sidebar,
}}
/>
</Show>
)
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<PlatformProvider value={web}>
<DialogProvider>
<RealHost
definitions={input.definitions}
captured={captured}
mounted={standIn({ current: () => (routed() ? view() : undefined), screen: () => screen })}
>
<Region />
</RealHost>
</DialogProvider>
</PlatformProvider>
</LanguageProvider>
),
container,
)
return {
container,
/** Routes a session: the screen creates a new session object for it. */
route: (id: string) => setView(object(id)),
/** The route leaves while its old screen has not unmounted yet. */
leave: () => setRouted(false),
/** The session each composer attachment reached, in order. */
attached,
status: (id: string) => hosts[0]?.status(id),
unmount: () => {
dispose()
container.remove()
},
}
}
/**
* Mounts the real host's theme contributions into the app's theme provider, as the window does. `options` reads the
* theme picker's choices as Settings › Appearance lists them; `disable` and `enable` toggle a plugin as
* Extensions › Plugins does. With `pending`, the plugin list has not arrived until `load()`. `applied` counts the
* provider's `onThemeApplied` calls, which show a desktop window. `preview`, `previewScheme`, `commit` and `cancel`
* drive the provider's preview, as the components lab does.
*/
export function mountThemes(input: { definitions: readonly Entry[]; pending?: true }) {
const container = document.createElement("div")
document.body.appendChild(container)
const [disabled, setDisabled] = createSignal<ReadonlySet<string>>(new Set())
const [loaded, setLoaded] = createSignal(!input.pending)
const hosts: PluginHost[] = []
const themes: ReturnType<typeof useTheme>[] = []
const applied = { count: 0 }
function Capture() {
hosts.push(usePluginHost())
themes.push(useTheme())
return null
}
const dispose = render(
() => (
<ThemeProvider onThemeApplied={() => void applied.count++}>
<LanguageProvider locale="en">
<DialogProvider>
<PluginHostProvider
definitions={() => input.definitions}
disabled={disabled}
apis={fakeApis(unused)}
whenMounted={mounted}
>
<PluginThemes loaded={loaded} />
<Capture />
</PluginHostProvider>
</DialogProvider>
</LanguageProvider>
</ThemeProvider>
),
container,
)
return {
status: (id: string) => hosts[0]?.status(id),
options: () => themes[0]?.ids().map((id) => ({ id, name: themes[0]?.name(id) })) ?? [],
select: (id: string) => themes[0]?.setTheme(id),
selected: () => themes[0]?.themeId(),
mode: () => themes[0]?.mode(),
preview: (id: string) => themes[0]?.previewTheme(id),
previewScheme: (scheme: ColorScheme) => themes[0]?.previewColorScheme(scheme),
commit: () => themes[0]?.commitPreview(),
cancel: () => themes[0]?.cancelPreview(),
applied: () => applied.count,
load: () => setLoaded(true),
disable: (id: string) => setDisabled(new Set([id])),
enable: () => setDisabled(new Set<string>()),
unmount: () => {
dispose()
container.remove()
},
}
}
/**
* Mounts the real host and the composer lane's plugin pills for one session, as the composer region renders them
* (right-aligned). The session's directory is `/repo`.
*/
export function mountComposerPills(input: { definitions: readonly Entry[] }) {
const container = document.createElement("div")
container.dataset.testid = "composer-pills"
container.style.position = "relative"
container.style.marginTop = "240px"
document.body.appendChild(container)
const hosts: PluginHost[] = []
const captured: Captured = {}
const value = { key: "fixture\na", id: "a", directory: "/repo", visit: {} }
// SAFETY: the pill renderer passes the session through; the test's plugins read only `directory`.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const session = value as unknown as MountedSession
// SAFETY: as above; the test's plugins never read the screen.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const screen = {} as unknown as SessionScreen
function Pills() {
const host = usePluginHost()
hosts.push(host)
return (
<Show when={host.ready()}>
<SessionPluginPills session={session} screen={screen} alignment={() => "end"} />
</Show>
)
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<PlatformProvider value={web}>
<DialogProvider>
<RealHost definitions={input.definitions} captured={captured}>
<Pills />
</RealHost>
</DialogProvider>
</PlatformProvider>
</LanguageProvider>
),
container,
)
return {
status: (id: string) => hosts[0]?.status(id),
failure: (id: string) => hosts[0]?.failure(id),
unmount: () => {
dispose()
container.remove()
},
}
}
/**
* Mounts the real host and one message's production Markdown inside the session screen's `PluginLinks`, for a session
* whose directory is `/repo`. `show(text, streaming)` replaces the message as a stream does; `define` replaces the
* plugins, as plugin GUIs join and leave.
*/
export function mountCodeBlocks(input: { definitions: readonly Entry[] }) {
const container = document.createElement("div")
container.dataset.testid = "code-blocks"
document.body.appendChild(container)
const hosts: PluginHost[] = []
const captured: Captured = {}
const [definitions, setDefinitions] = createSignal(input.definitions)
const [message, setMessage] = createStore({ text: "", streaming: false })
const value = { key: "fixture\na", id: "a", tab: "a", pending: false, directory: "/repo", visit: {} }
// SAFETY: PluginLinks passes the session through; the test's plugins read only `directory`.
// oxlint-disable-next-line anti-slop/no-chained-type-assertions -- see SAFETY above
const session = value as unknown as MountedSession
function Message() {
const host = usePluginHost()
hosts.push(host)
return (
<Show when={host.ready()}>
<PluginLinks session={session}>
<Markdown text={message.text} streaming={message.streaming} />
</PluginLinks>
</Show>
)
}
function Host() {
const apis = createHostApis()
captured.apis = apis
return (
<PluginHostProvider
definitions={definitions}
disabled={() => new Set<string>()}
apis={apis.apis}
whenMounted={apis.whenMounted}
>
<Message />
</PluginHostProvider>
)
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<PlatformProvider value={web}>
<DialogProvider>
<Host />
</DialogProvider>
</PlatformProvider>
</LanguageProvider>
),
container,
)
return {
show: (text: string, streaming = false) => setMessage({ text, streaming }),
define: (next: readonly Entry[]) => setDefinitions(next),
status: (id: string) => hosts[0]?.status(id),
failure: (id: string) => hosts[0]?.failure(id),
unmount: () => {
dispose()
container.remove()
},
}
}
/**
* Mounts the real host over the real HostApis of a web window, before the app interface mounts, with the window's
* toast region. `attach()` mounts a stand-in for the interface: its layout records each write and reports a tab it
* opened as visible, its server is the local one, and its mono font is "fixture mono".
*/
export function mountHostApis(input: { definitions: readonly Entry[] }) {
const container = document.createElement("div")
document.body.appendChild(container)
const writes: string[] = []
const [opened, setOpened] = createSignal<readonly string[]>([])
const hosts: PluginHost[] = []
const captured: Captured = {}
const layout = standIn({}).layout
const mounted = standIn({
layout: {
...layout,
open(key) {
writes.push(`open ${key}`)
setOpened((keys) => [...keys, key])
},
close: (key) => void writes.push(`close ${key}`),
toggle: (key) => void writes.push(`toggle ${key}`),
state: (key) => (opened().includes(key) ? "visible" : "closed"),
scroll: { get: () => undefined, set: (_session, key) => void writes.push(`scroll ${key}`) },
},
settings: (page) => void writes.push(`settings ${page}`),
})
function Capture() {
hosts.push(usePluginHost())
return null
}
const dispose = render(
() => (
<LanguageProvider locale="en">
<PlatformProvider value={web}>
<DialogProvider>
<RealHost definitions={input.definitions} captured={captured}>
<Capture />
</RealHost>
<Toast.Region />
</DialogProvider>
</PlatformProvider>
</LanguageProvider>
),
container,
)
return {
/** Layout writes the interface received, in order. */
writes,
/** Mounts the interface. */
attach: () => void captured.apis?.attach(mounted),
reload: (id: string) => hosts[0]?.reload(id),
status: (id: string) => hosts[0]?.status(id),
unmount: () => {
dispose()
container.remove()
},
}
}