fix(gui-plugins): bound terminal, updater and lazy chunk work with explicit state machines (#62123)

This commit is contained in:
Luke Parker
2026-11-05 10:02:54 -06:00
committed by GitHub
Unverified
parent b903ae5cc9
commit f019ceaafd
30 changed files with 2166 additions and 373 deletions
@@ -189,6 +189,29 @@ test("a click into the composer while a new terminal is created keeps the focus
await expect(editor).toBeFocused()
})
// Both presses used to send a create, so two tabs both titled "Terminal 1" opened.
test("pressing New terminal again while one is created creates one terminal", async ({ page }) => {
const { persistentPty, editor } = await openSession(page, { name: "Terminals", persistentPty: {} })
const create = await holdRoute(page, /\/api\/experimental\/session\/ses_terminals\/terminal(\?|$)/, {
method: "POST",
})
await editor.click()
await page.keyboard.press("Control+Alt+T")
await create.arrived
await page.keyboard.press("Control+Alt+T")
create.release()
await expect(side(page).getByRole("tab", { name: "Terminal 1", exact: true })).toHaveAttribute(
"aria-selected",
"true",
)
await expect(terminalScreen(page, persistentPty.created[0]!.id).locator("textarea")).toBeFocused()
expect(create.requests).toHaveLength(1)
expect(persistentPty.created.map((info) => info.title)).toEqual(["Terminal 1"])
})
test("another client's control ends at a click, which resizes the terminal to the pane", async ({ page }) => {
const { persistentPty } = await openSession(page, {
name: "Terminals",
@@ -288,6 +311,37 @@ test("a terminal list that fails says why, and Try again lists the terminals", a
await expect(side(page).getByText("Couldn't load terminals", { exact: true })).toHaveCount(0)
})
// A list the server accepted and never answered used to leave "Loading terminal." on screen with no way to retry.
test("a terminal list the server never answers says so, and Try again lists the terminals", async ({ page }) => {
await openSession(page, {
name: "Terminals",
persistentPty: { initial: [{ id: "pty_build", sessionID: SESSION, title: "Build", output: "still-here\r\n" }] },
})
await openSide(page)
await side(page).getByRole("tab", { name: "Build", exact: true }).click()
const screen = terminalScreen(page, "pty_build")
await expect.poll(() => terminalText(page, screen)).toBe("still-here")
const list = await holdRoute(page, /\/api\/experimental\/session\/ses_terminals\/terminal(\?|$)/, { method: "GET" })
await page.clock.install()
await page.reload()
await list.arrived
await expect(side(page).getByText("Loading terminal…", { exact: true })).toBeVisible()
// The request budget, `TERMINAL_REQUEST_TIMEOUT_MS`.
await page.clock.fastForward(15_000)
const failure = side(page).getByRole("alert")
await expect(failure.getByText("Couldn't load terminals", { exact: true })).toBeVisible()
await expect(failure.getByText("The server took too long to answer", { exact: true })).toBeVisible()
list.release()
await failure.getByRole("button", { name: "Try again", exact: true }).click()
await expect(side(page).getByRole("tab", { name: "Build", exact: true })).toHaveAttribute("aria-selected", "true")
await expect.poll(() => terminalText(page, screen)).toBe("still-here")
})
test("after a server restart, showing the terminal again restores it from a new snapshot", async ({ page }) => {
const output = "before-restart\r\n"
+3 -6
View File
@@ -391,12 +391,9 @@ The updater's actions branch on the state its main entry pushes and still answer
<!-- source: src/updater/renderer.tsx#act -->
```tsx
const act = (name: "check" | "install") => {
if (state()) return void import("./actions").then((module) => module[name](ctx, updater))
// Not loaded yet, or not active (failed, or not on this platform): nothing can check or install.
showToast({ title: ctx.t("common.requestFailed") })
}
// Main missing (not loaded yet, failed, or not on this platform) answers that nothing can check or install.
const act = (name: "check" | "install") =>
dispatchWindow({ type: "action.requested", action: name, available: state() !== undefined })
```
The pairing settings page waits for its main side only where it needs it. Pairing codes come from the local server's `ServerRef`, so they never wait for main; remote access and the display setting render only while main is active, so the page never offers a switch that cannot answer:
@@ -1,4 +1,5 @@
export default {
"model.timedOut": "The browser took too long to load",
"command.open": "Open browser",
"command.reload": "Reload browser page",
"command.inspect": "Select an element in the browser page",
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test"
import {
createBrowserModelState,
selectBrowserModel,
selectBrowserModelError,
selectBrowserTabHidden,
transitionBrowserModel,
type BrowserModelEffect,
type BrowserModelEvent,
type BrowserModelState,
} from "./model-machine"
type Model = { readonly name: string }
const replay = (events: readonly BrowserModelEvent<Model>[], state = createBrowserModelState<Model>()) =>
events.reduce<{ state: BrowserModelState<Model>; effects: BrowserModelEffect[] }>(
(last, event) => {
const next = transitionBrowserModel(last.state, event)
return { state: next.state, effects: [...last.effects, ...next.effects] }
},
{ state, effects: [] },
)
const known = () => true
describe("browser model machine", () => {
// A model chunk that failed to load used to leave the panel blank, its stored tabs hidden and its commands disabled
// for the rest of the run.
test("a load that fails shows its error and the stored tabs, and a retry loads again", () => {
const failed = replay([
{ type: "load.requested" },
{ type: "load.failed", attempt: 1, error: "chunk" },
{ type: "load.requested" },
])
expect(selectBrowserModelError(failed.state)).toBe("chunk")
expect(selectBrowserTabHidden(failed.state, known)).toBe(false)
expect(failed.effects).toEqual([{ type: "load-model", attempt: 1 }])
const retried = replay(
[{ type: "retry.requested" }, { type: "load.succeeded", attempt: 2, model: { name: "m" } }],
failed.state,
)
expect(retried.effects).toEqual([{ type: "load-model", attempt: 2 }])
expect(selectBrowserModel(retried.state)).toEqual({ name: "m" })
expect(selectBrowserTabHidden(retried.state, () => false)).toBe(true)
})
test("stored tabs stay hidden while the model loads, and a second request loads once", () => {
const loading = replay([{ type: "load.requested" }, { type: "load.requested" }, { type: "retry.requested" }])
expect(loading.effects).toEqual([{ type: "load-model", attempt: 1 }])
expect(selectBrowserTabHidden(loading.state, known)).toBe(true)
})
test("a load the plugin's cleanup or a newer attempt overtook settles nothing", () => {
const disposed = replay([
{ type: "load.requested" },
{ type: "disposed" },
{ type: "load.succeeded", attempt: 1, model: { name: "late" } },
{ type: "retry.requested" },
])
expect(disposed.state).toEqual({ status: "disposed" })
const stale = replay([
{ type: "load.requested" },
{ type: "load.failed", attempt: 1, error: "chunk" },
{ type: "retry.requested" },
{ type: "load.failed", attempt: 1, error: "late" },
])
expect(stale.state).toEqual({ status: "loading", attempt: 2 })
})
})
@@ -0,0 +1,92 @@
// The window's browser model, loaded when the first session opens. Each load takes the next attempt number, so a load
// the plugin's cleanup or a retry overtook never hands out its model.
/** How long the model's chunk may take to load before the panel offers a retry. */
export const BROWSER_MODEL_LOAD_TIMEOUT_MS = 15_000
/**
* - `unloaded`: no session opened yet; `issued` is the last attempt handed out.
* - `loading`: the model's chunk loads.
* - `loaded`: the model serves the panel, its tabs and commands.
* - `failed`: the chunk failed to load or ran out of time; the panel shows why, with a retry.
* - `disposed`: the plugin instance went away.
*/
export type BrowserModelState<M> =
| { readonly status: "unloaded"; readonly issued: number }
| { readonly status: "loading"; readonly attempt: number }
| { readonly status: "loaded"; readonly model: M }
| { readonly status: "failed"; readonly issued: number; readonly error: string }
| { readonly status: "disposed" }
export type BrowserModelEvent<M> =
/** The first session opened. */
| { readonly type: "load.requested" }
/** The user asks to load it again after a failure. */
| { readonly type: "retry.requested" }
| { readonly type: "load.succeeded"; readonly attempt: number; readonly model: M }
/** The chunk failed to load or ran out of time. */
| { readonly type: "load.failed"; readonly attempt: number; readonly error: string }
| { readonly type: "disposed" }
export type BrowserModelEffect = { readonly type: "load-model"; readonly attempt: number }
export type BrowserModelTransition<M> = {
readonly state: BrowserModelState<M>
readonly effects: readonly BrowserModelEffect[]
}
export const createBrowserModelState = <M>(): BrowserModelState<M> => ({ status: "unloaded", issued: 0 })
export function transitionBrowserModel<M>(
state: BrowserModelState<M>,
event: BrowserModelEvent<M>,
): BrowserModelTransition<M> {
if (state.status === "disposed") return unchanged(state)
if (event.type === "disposed") return unchanged({ status: "disposed" })
if (
(event.type === "load.requested" && state.status === "unloaded") ||
(event.type === "retry.requested" && state.status === "failed")
) {
const attempt = state.issued + 1
return { state: { status: "loading", attempt }, effects: [{ type: "load-model", attempt }] }
}
if (event.type === "load.succeeded" && isLoadingBrowserModel(state, event.attempt))
return unchanged({ status: "loaded", model: event.model })
if (event.type === "load.failed" && isLoadingBrowserModel(state, event.attempt))
return unchanged({ status: "failed", issued: event.attempt, error: event.error })
return unchanged(state)
}
/** Whether a load that just imported its module should still create the model. */
export function isLoadingBrowserModel<M>(state: BrowserModelState<M>, attempt: number) {
return state.status === "loading" && state.attempt === attempt
}
export function selectBrowserModel<M>(state: BrowserModelState<M>) {
return state.status === "loaded" ? state.model : undefined
}
/** Why the model failed to load, while the panel offers a retry. */
export function selectBrowserModelError<M>(state: BrowserModelState<M>) {
return state.status === "failed" ? state.error : undefined
}
/**
* A stored tab stays listed but hidden until the model knows it. Once the load failed it shows, so the panel can say
* why and offer a retry.
*/
export function selectBrowserTabHidden<M>(state: BrowserModelState<M>, known: (model: M) => boolean) {
if (state.status === "failed") return false
return state.status !== "loaded" || !known(state.model)
}
function unchanged<M>(state: BrowserModelState<M>): BrowserModelTransition<M> {
return { state, effects: [] }
}
+93 -21
View File
@@ -1,9 +1,32 @@
import { createMemo, createSignal, getOwner, lazy, onCleanup, runWithOwner, Show, Suspense } from "solid-js"
import {
createMemo,
createSignal,
getOwner,
lazy,
Match,
onCleanup,
runWithOwner,
Show,
Suspense,
Switch,
untrack,
} from "solid-js"
import { Button } from "@opencode/ui/button"
import { Command, createKeyed, LinkHandler, MenuItem, Style, type Plugin } from "@opencode/plugin/gui"
import { BuiltinCommand, BuiltinPanel, onIdle, type BuiltinPanelTab } from "@opencode/plugin/gui/builtin"
import { Browser } from "@opencode/plugin/gui/browser"
import type definition from "./index"
import type { Model } from "./model"
import {
BROWSER_MODEL_LOAD_TIMEOUT_MS,
createBrowserModelState,
isLoadingBrowserModel,
selectBrowserModel,
selectBrowserModelError,
selectBrowserTabHidden,
transitionBrowserModel,
type BrowserModelEvent,
} from "./model-machine"
import { PageIcon } from "./page-icon"
import barStyles from "./bar.css?inline"
import commentStyles from "./comment.css?inline"
@@ -11,7 +34,8 @@ import tabStyles from "./tabs.css?inline"
const setup: Plugin.Setup<typeof definition> = (ctx) => {
const sessions = ctx.sessions
const [model, setModel] = createSignal<Model>()
const [modelState, setModelState] = createSignal(createBrowserModelState<Model>())
const model = () => selectBrowserModel(modelState())
const mac = /(Mac|iPhone|iPad)/.test(navigator.platform)
// Settings > Shortcuts lists the command on every platform; it stays disabled until the pane can open. Bound like
// Review's and Open file's launchers, since the sidebar owns mod+shift+b and mod+t opens a tab inside the pane.
@@ -40,15 +64,42 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
ctx.add(Style, barStyles)
// Everything here serves a mounted session, so the attachment model and the pane's protocol
// schemas load when the first session opens instead of at startup.
const owner = getOwner()
const dispatchModel = (event: BrowserModelEvent<Model>) => {
const next = transitionBrowserModel(untrack(modelState), event)
if (next.state !== untrack(modelState)) setModelState(() => next.state)
next.effects.forEach((effect) => loadModel(effect.attempt))
}
// A chunk that fails or never loads, such as after an update replaced the app's files, ends in `failed`.
const loadModel = (attempt: number) => {
const deadline = Promise.withResolvers<never>()
const timer = setTimeout(() => deadline.reject(new Error(ctx.t("model.timedOut"))), BROWSER_MODEL_LOAD_TIMEOUT_MS)
void Promise.race([import("./model"), deadline.promise])
.finally(() => clearTimeout(timer))
.then(
(module) => {
if (ctx.signal.aborted || !isLoadingBrowserModel(untrack(modelState), attempt)) return
const created = runWithOwner(owner, () => module.createModel(ctx))!
dispatchModel({ type: "load.succeeded", attempt, model: created })
},
(cause: unknown) =>
dispatchModel({
type: "load.failed",
attempt,
error: cause instanceof Error ? cause.message : String(cause),
}),
)
}
onCleanup(() => dispatchModel({ type: "disposed" }))
const opened = createMemo((seen: boolean) => seen || !!sessions.current(), false)
createKeyed(opened, () => {
const owner = getOwner()
void import("./model").then((module) => {
if (ctx.signal.aborted) return
const created = runWithOwner(owner, () => module.createModel(ctx))
setModel(() => created)
})
})
createKeyed(opened, () => dispatchModel({ type: "load.requested" }))
ctx.provide(Browser, {
attached: (session) => model()?.attached(session) ?? false,
@@ -224,9 +275,10 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
<span class="max-w-40 truncate">{text()}</span>
</div>
),
// Listed only to hold the restored selection until the desktop's first inventory names the tab.
// Listed only to hold the restored selection until the desktop's first inventory names the tab, or the model
// failed to load and the panel says so.
get hidden() {
return !model()?.tab({ key: session }, id)
return selectBrowserTabHidden(modelState(), (value) => !!value.tab({ key: session }, id))
},
group: "browser",
dom: { tab: `session-side-panel-browser-tab-${id}`, panel: "session-side-panel-browser-tabpanel" },
@@ -256,15 +308,35 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
},
// The pane shows nothing until the desktop's first inventory names its tabs.
render: (props) => (
<Show when={model()}>
{(value) => (
<Show when={!value().pending(props.session)}>
<Suspense>
<SessionBrowserPane tab={() => props.tab} session={props.session} screen={props.screen} model={value()} />
</Suspense>
</Show>
)}
</Show>
<Switch>
<Match when={model()}>
{(value) => (
<Show when={!value().pending(props.session)}>
<Suspense>
<SessionBrowserPane
tab={() => props.tab}
session={props.session}
screen={props.screen}
model={value()}
/>
</Suspense>
</Show>
)}
</Match>
<Match when={selectBrowserModelError(modelState())}>
{(error) => (
<div role="alert" class="size-full flex flex-col items-center justify-center gap-3 px-4 text-center">
<div class="flex flex-col gap-1">
<div class="text-13-regular text-text-strong">{ctx.t("common.requestFailed")}</div>
<div class="text-12-regular text-text-weak break-words">{error()}</div>
</div>
<Button size="small" variant="outline" onClick={() => dispatchModel({ type: "retry.requested" })}>
{ctx.t("common.retry")}
</Button>
</div>
)}
</Match>
</Switch>
),
close: (input) => model()?.closeTab(input.session, input.tab.id),
// A blank new tab does not come back; a page comes back as a new tab on its address.
@@ -0,0 +1,60 @@
import { describe, expect, test } from "bun:test"
import { initialSshAdd, transitionSshAdd, type SshAddEffect, type SshAddEvent, type SshAddState } from "./add-machine"
type Module = { readonly name: string }
const replay = (events: readonly SshAddEvent<Module>[]) =>
events.reduce<{ state: SshAddState; effects: SshAddEffect<Module>[] }>(
(last, event) => {
const next = transitionSshAdd(last.state, event)
return { state: next.state, effects: [...last.effects, ...next.effects] }
},
{ state: initialSshAdd, effects: [] },
)
const module: Module = { name: "dialog" }
describe("ssh add dialog machine", () => {
// A double click used to load the chunk twice and open two dialogs.
test("a second click joins the dialog loading and opens one", () => {
const result = replay([
{ type: "add.requested", openProject: false },
{ type: "add.requested", openProject: true },
{ type: "add.loaded", attempt: 1, module },
{ type: "add.loaded", attempt: 1, module },
])
expect(result.effects).toEqual([
{ type: "load", attempt: 1 },
{ type: "open", module, openProject: true },
])
})
// A chunk that failed to load used to leave the click unanswered, with an unhandled rejection.
test("a chunk that fails or stalls says so and the next click loads again", () => {
const result = replay([
{ type: "add.requested", openProject: false },
{ type: "add.failed", attempt: 1, error: "chunk" },
{ type: "add.requested", openProject: false },
])
expect(result.effects).toEqual([
{ type: "load", attempt: 1 },
{ type: "toast-failed", error: "chunk" },
{ type: "load", attempt: 2 },
])
})
test("a chunk that loads after the plugin went away opens nothing", () => {
const result = replay([
{ type: "add.requested", openProject: false },
{ type: "disposed" },
{ type: "add.loaded", attempt: 1, module },
{ type: "add.requested", openProject: false },
])
expect(result.state).toEqual({ status: "disposed" })
expect(result.effects).toEqual([{ type: "load", attempt: 1 }])
})
})
@@ -0,0 +1,63 @@
// The "Add SSH server" dialog, whose chunk loads on the first click. Each load takes the next attempt number, so a load
// the plugin's cleanup overtook never opens its dialog.
/** How long the dialog's chunk may take to load before the click fails. */
export const SSH_DIALOG_LOAD_TIMEOUT_MS = 15_000
/**
* - `idle`: no dialog loading; `issued` is the last attempt handed out.
* - `loading`: the chunk loads; a second click joins it. `openProject` opens the server's project once it connects.
* - `disposed`: the plugin instance went away.
*/
export type SshAddState =
| { readonly status: "idle"; readonly issued: number }
| { readonly status: "loading"; readonly attempt: number; readonly openProject: boolean }
| { readonly status: "disposed" }
export type SshAddEvent<M> =
| { readonly type: "add.requested"; readonly openProject: boolean }
| { readonly type: "add.loaded"; readonly attempt: number; readonly module: M }
/** The chunk failed to load or ran out of time. */
| { readonly type: "add.failed"; readonly attempt: number; readonly error: string }
| { readonly type: "disposed" }
export type SshAddEffect<M> =
| { readonly type: "load"; readonly attempt: number }
| { readonly type: "open"; readonly module: M; readonly openProject: boolean }
| { readonly type: "toast-failed"; readonly error: string }
export type SshAddTransition<M> = { readonly state: SshAddState; readonly effects: readonly SshAddEffect<M>[] }
export const initialSshAdd: SshAddState = { status: "idle", issued: 0 }
export function transitionSshAdd<M>(state: SshAddState, event: SshAddEvent<M>): SshAddTransition<M> {
if (state.status === "disposed") return unchanged(state)
if (event.type === "disposed") return unchanged({ status: "disposed" })
if (event.type === "add.requested") {
// A second click joins the dialog already loading; the command's project choice wins over the menu's.
if (state.status === "loading")
return unchanged(event.openProject && !state.openProject ? { ...state, openProject: true } : state)
const attempt = state.issued + 1
return {
state: { status: "loading", attempt, openProject: event.openProject },
effects: [{ type: "load", attempt }],
}
}
if (state.status !== "loading" || event.attempt !== state.attempt) return unchanged(state)
if (event.type === "add.loaded")
return {
state: { status: "idle", issued: state.attempt },
effects: [{ type: "open", module: event.module, openProject: state.openProject }],
}
return { state: { status: "idle", issued: state.attempt }, effects: [{ type: "toast-failed", error: event.error }] }
}
function unchanged<M>(state: SshAddState): SshAddTransition<M> {
return { state, effects: [] }
}
+56 -11
View File
@@ -1,5 +1,5 @@
import { showToast } from "@opencode/ui/toast"
import { createRoot, lazy, onCleanup, Suspense, type JSX } from "solid-js"
import { createRoot, createSignal, lazy, onCleanup, Suspense, untrack, type JSX } from "solid-js"
import { Command, createKeyed, MenuItem, Server, Style, type ServerEntry, type Plugin } from "@opencode/plugin/gui"
import { onIdle } from "@opencode/plugin/gui/builtin"
import { Ssh, type SshConfig, type SshItem } from "./contract"
@@ -7,9 +7,18 @@ import { SshCover } from "./cover"
import type definition from "./index"
import { sshName, sshServerState } from "./name"
import { createSshController } from "./state"
import {
initialSshAdd,
SSH_DIALOG_LOAD_TIMEOUT_MS,
transitionSshAdd,
type SshAddEffect,
type SshAddEvent,
} from "./add-machine"
const loadDialog = () => import("./dialog")
type DialogModule = Awaited<ReturnType<typeof loadDialog>>
const setup: Plugin.Setup<typeof definition> = (ctx) => {
// SSH lives in the desktop main process.
if (ctx.app.platform !== "desktop") return
@@ -57,24 +66,54 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
const styled = { added: false }
const byKey = (key: string) => (key.startsWith("ssh:") ? ssh.item(key.slice(4)) : undefined)
const open = (module: Awaited<ReturnType<typeof loadDialog>>, render: () => JSX.Element) => {
// The dialog chunk under its load budget: one that fails or never loads rejects.
const boundedDialog = () => {
const deadline = Promise.withResolvers<never>()
const timer = setTimeout(() => deadline.reject(new Error()), SSH_DIALOG_LOAD_TIMEOUT_MS)
return Promise.race([loadDialog(), deadline.promise]).finally(() => clearTimeout(timer))
}
const open = (module: DialogModule, render: () => JSX.Element) => {
if (!styled.added) ctx.add(Style, module.css)
styled.added = true
dialog.open(render)
}
const show = (render: (module: Awaited<ReturnType<typeof loadDialog>>) => JSX.Element) =>
void loadDialog().then((module) => {
if (ctx.signal.aborted) return
open(module, () => render(module))
})
const [addState, setAddState] = createSignal(initialSshAdd)
const add = (openProject: boolean) =>
show((module) => (
const dispatchAdd = (event: SshAddEvent<DialogModule>) => {
const next = transitionSshAdd(untrack(addState), event)
if (next.state !== untrack(addState)) setAddState(next.state)
next.effects.forEach(runAdd)
}
const runAdd = (effect: SshAddEffect<DialogModule>) => {
if (effect.type === "toast-failed")
return void showToast({
variant: "error",
title: ctx.t("common.requestFailed"),
description: effect.error || undefined,
})
if (effect.type === "load") {
const attempt = effect.attempt
return void boundedDialog().then(
(module) => dispatchAdd({ type: "add.loaded", attempt, module }),
(cause: unknown) =>
dispatchAdd({ type: "add.failed", attempt, error: cause instanceof Error ? cause.message : String(cause) }),
)
}
const module = effect.module
open(module, () => (
<module.DialogSsh
ssh={ssh}
onOpenProject={
openProject
effect.openProject
? (id) => {
const item = ssh.item(id)
@@ -85,6 +124,11 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
}
/>
))
}
onCleanup(() => dispatchAdd({ type: "disposed" }))
const add = (openProject: boolean) => dispatchAdd({ type: "add.requested", openProject })
const entry = (item: SshItem): ServerEntry => {
const id = item.config.id
@@ -183,7 +227,8 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
const id = target.item.config.id
const config = target.item.config
ssh.dialog.loading(id, target.attempt)
void loadDialog().then(
// A chunk that never loads ends the attempt too, so it never waits on a dialog that cannot show.
void boundedDialog().then(
(module) => {
if (ctx.signal.aborted || !ssh.dialog.canOpen(id, target.attempt)) return
ssh.dialog.opened(id, target.attempt)
@@ -95,6 +95,35 @@ describe("terminal focus machine", () => {
expect(focusRequestToken(transitionFocus(plain, target("pty_2")), "ses_1", "pty_2")).toBe(2)
})
// A second new-terminal request joins the create in flight; it used to replace the request, so the create's answer
// for the first one was stale and the new terminal took no focus.
test("asking again for a terminal being created keeps the request", () => {
const state = replay([pending()])
expect(transitionFocus(state, pending())).toBe(state)
expect(transitionFocus(state, pending(true))).toEqual({
status: "pending",
request: 1,
session: "ses_1",
overlay: true,
})
expect(
isFocusRequested(
replay([pending(), { type: "pending.resolved", request: 1, id: "pty_1" }], state),
"ses_1",
"pty_1",
),
).toBe(true)
// Once the user moved on, a new request starts again.
expect(replay([{ type: "cancelled" }, pending()], state)).toEqual({
status: "pending",
request: 2,
session: "ses_1",
overlay: false,
})
})
test("the terminal taking focus, or a cancel for its session, ends the request", () => {
const state = replay([target("pty_1")])
@@ -45,8 +45,13 @@ export const initialFocusState: FocusState = { status: "idle", request: 0 }
* return of a closing dialog or menu that started the request does not.
*/
export function transitionFocus(state: FocusState, event: FocusRequestEvent): FocusState {
if (event.type === "request.pending")
if (event.type === "request.pending") {
// A second new-terminal request joins the create in flight, so it keeps that create's request.
if (state.status === "pending" && state.session === event.session)
return event.overlay && !state.overlay ? { ...state, overlay: true } : state
return { status: "pending", request: state.request + 1, session: event.session, overlay: event.overlay }
}
if (event.type === "request.target") {
// Showing the tab asks again for the terminal already asked for; that keeps the request and its overlay.
@@ -9,6 +9,8 @@ export default {
unavailable: "Terminals aren't available on this server",
"title.numbered": "Terminal {{number}}",
close: "Close terminal",
"close.failed": "Couldn't close the terminal",
"request.timedOut": "The server took too long to answer",
"create.failed": "Couldn't create a terminal",
"create.unsupported": "This server is too old for terminals. Update OpenCode on this server.",
"rename.failed": "Couldn't rename the terminal",
+206 -27
View File
@@ -4,9 +4,11 @@ import {
initialState,
serverAvailable,
sessionConnection,
sessionCreating,
sessionView,
tabHidden,
transition,
viewItems,
viewTabs,
type Event,
type State,
@@ -100,7 +102,10 @@ describe("terminal machine", () => {
{ type: "server.connected" },
])
expect(rechecked.commands).toEqual([{ type: "check-capability", attempt: 2 }])
expect(rechecked.commands).toEqual([
{ type: "abort", attempts: [1] },
{ type: "check-capability", attempt: 2 },
])
const stale = replay(
[
@@ -183,7 +188,11 @@ describe("terminal machine", () => {
const rechecking = transition(supported.state, { type: "server.connected" })
expect(rechecking.commands).toEqual([{ type: "check-capability", attempt: 4 }])
// The reconnect stops the list it no longer waits for.
expect(rechecking.commands).toEqual([
{ type: "abort", attempts: [3] },
{ type: "check-capability", attempt: 4 },
])
expect(sessionView(rechecking.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1")] })
expect(sessionView(rechecking.state, "ses_2")).toEqual({ type: "loading" })
@@ -216,51 +225,221 @@ describe("terminal machine", () => {
expect(unknown.commands).toEqual([{ type: "load-list", sessionID: "ses_1", attempt: 3 }])
// A local change to a terminal the list doesn't have, or an update for a session never loaded, changes nothing.
expect(transition(state, { type: "terminal.replaced", sessionID: "ses_1", terminal: info("pty_9") }).state).toBe(
state,
)
// An update for a session never loaded changes nothing.
expect(transition(state, { type: "terminal.updated", sessionID: "ses_9", terminal: info("pty_9") }).state).toBe(
state,
)
})
// The create answer and the server's `persistent-pty.added` race: the creator's refresh can lose to the one the event
// started, which answers later. The new tab is listed meanwhile, so selecting it settles focus at once.
// The create answer and the server's `persistent-pty.added` race: the refresh the event started can answer without
// the new terminal. The new tab is listed at once, so selecting it settles focus.
test("a terminal this client created is listed before a newer refresh answers", () => {
const own = transition(listed(), { type: "session.refresh", sessionID: "ses_1" }).state
const added = transition(own, { type: "session.refresh", sessionID: "ses_1" }).state
const lost = transition(added, { type: "list.loaded", sessionID: "ses_1", attempt: 3, items: [info("pty_1")] })
const requested = transition(listed(), { type: "create.requested", sessionID: "ses_1" })
expect(lost.state).toBe(added)
expect(requested.commands).toEqual([
{ type: "create-terminal", sessionID: "ses_1", attempt: 3, known: [info("pty_1")] },
])
const created = transition(lost.state, { type: "terminal.created", sessionID: "ses_1", terminal: info("pty_2") })
const added = transition(requested.state, { type: "session.refresh", sessionID: "ses_1" }).state
expect(created.commands).toEqual([])
const created = transition(added, {
type: "create.succeeded",
sessionID: "ses_1",
attempt: 3,
terminal: info("pty_2"),
})
expect(created.commands).toEqual([
{ type: "settle-create", sessionID: "ses_1", outcome: { ok: true, terminal: info("pty_2") } },
{ type: "abort", attempts: [4] },
{ type: "load-list", sessionID: "ses_1", attempt: 5 },
])
expect(sessionView(created.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1"), info("pty_2")] })
expect(sessionCreating(created.state, "ses_1")).toBe(false)
const settled = transition(created.state, {
// The refresh it replaced answers without the new terminal; it is stale.
const stale = transition(created.state, {
type: "list.loaded",
sessionID: "ses_1",
attempt: 4,
items: [info("pty_1"), info("pty_2")],
items: [info("pty_1")],
})
expect(sessionView(settled.state, "ses_1")).toEqual({ type: "ready", items: [info("pty_1"), info("pty_2")] })
expect(stale.state).toBe(created.state)
})
// A terminal already listed, or a list not known yet, changes nothing.
expect(
transition(created.state, { type: "terminal.created", sessionID: "ses_1", terminal: info("pty_2") }).state,
).toBe(created.state)
// Double-clicking "+" or pressing the shortcut twice used to send two creates, both titled "Terminal 1".
test("a second new-terminal request joins the create in flight", () => {
const first = transition(listed(), { type: "create.requested", sessionID: "ses_1" })
const second = transition(first.state, { type: "create.requested", sessionID: "ses_1" })
const loading = replay([
{ type: "session.loaded", sessionID: "ses_1" },
{ type: "capability.checked", attempt: 1, supported: true },
]).state
expect(first.commands).toEqual([
{ type: "create-terminal", sessionID: "ses_1", attempt: 3, known: [info("pty_1")] },
])
expect(second.state).toBe(first.state)
expect(second.commands).toEqual([])
expect(sessionCreating(first.state, "ses_1")).toBe(true)
expect(transition(loading, { type: "terminal.created", sessionID: "ses_1", terminal: info("pty_2") }).state).toBe(
loading,
// A failed or timed-out create answers every request it joined once, and the next request creates again.
const failed = transition(first.state, {
type: "create.failed",
sessionID: "ses_1",
attempt: 3,
kind: "failed",
error: "timed out",
})
expect(failed.commands).toEqual([
{ type: "settle-create", sessionID: "ses_1", outcome: { ok: false, kind: "failed", error: "timed out" } },
])
expect(sessionCreating(failed.state, "ses_1")).toBe(false)
expect(transition(failed.state, { type: "create.requested", sessionID: "ses_1" }).commands).toEqual([
{ type: "create-terminal", sessionID: "ses_1", attempt: 4, known: [info("pty_1")] },
])
})
test("a create while support is checked lists first, and a server without terminals answers it at once", () => {
const checking = replay([{ type: "server.connected" }, { type: "session.loaded", sessionID: "ses_1" }]).state
const requested = transition(checking, { type: "create.requested", sessionID: "ses_1" })
expect(requested.commands).toEqual([{ type: "create-terminal", sessionID: "ses_1", attempt: 2, known: undefined }])
// Losing support ends the create in flight and tells the user why.
const unsupported = transition(requested.state, { type: "capability.checked", attempt: 1, supported: false })
expect(unsupported.commands).toEqual([
{ type: "abort", attempts: [2] },
{ type: "settle-create", sessionID: "ses_1", outcome: { ok: false, kind: "unsupported" } },
])
expect(transition(unsupported.state, { type: "create.requested", sessionID: "ses_1" }).commands).toEqual([
{ type: "settle-create", sessionID: "ses_1", outcome: { ok: false, kind: "unsupported" } },
])
})
// An older rename that answered after a newer one used to put the older title back; two failed renames rolled back
// to the first one's unconfirmed title.
test("only the newest rename settles a terminal's title", () => {
const alpha = transition(listed(), { type: "rename.requested", sessionID: "ses_1", ptyID: "pty_1", title: "alpha" })
const beta = transition(alpha.state, {
type: "rename.requested",
sessionID: "ses_1",
ptyID: "pty_1",
title: "beta",
})
expect(beta.commands).toEqual([
{ type: "abort", attempts: [3] },
{ type: "rename-terminal", sessionID: "ses_1", ptyID: "pty_1", title: "beta", attempt: 4 },
])
expect(viewItems(sessionView(beta.state, "ses_1"))[0]).toMatchObject({ title: "beta", userTitle: "beta" })
const renamed = (title: string) => ({ ...info("pty_1", title), userTitle: title })
const settled = replay(
[
{ type: "rename.succeeded", sessionID: "ses_1", ptyID: "pty_1", attempt: 4, terminal: renamed("beta") },
{ type: "rename.succeeded", sessionID: "ses_1", ptyID: "pty_1", attempt: 3, terminal: renamed("alpha") },
],
beta.state,
)
expect(viewItems(sessionView(settled.state, "ses_1"))).toEqual([renamed("beta")])
const failed = replay(
[
{ type: "rename.failed", sessionID: "ses_1", ptyID: "pty_1", attempt: 3, kind: "failed", error: "old" },
{ type: "rename.failed", sessionID: "ses_1", ptyID: "pty_1", attempt: 4, kind: "failed", error: "boom" },
],
beta.state,
)
// The confirmed title shows again, not the first rename's.
expect(viewItems(sessionView(failed.state, "ses_1"))).toEqual([info("pty_1")])
expect(failed.commands).toEqual([{ type: "toast-rename-failed", kind: "failed", error: "boom" }])
// Asking for the title a pending rename already sends, or the current title, sends nothing.
expect(
transition(beta.state, { type: "rename.requested", sessionID: "ses_1", ptyID: "pty_1", title: "beta" }).state,
).toBe(beta.state)
expect(
transition(listed(), { type: "rename.requested", sessionID: "ses_1", ptyID: "pty_1", title: "pty_1" }).commands,
).toEqual([])
})
// A failed close used to end in a console line: the tab stayed gone with its terminal still running.
test("a close runs once, ends a pending rename, and brings the tab back when it fails", () => {
const renaming = transition(listed(), {
type: "rename.requested",
sessionID: "ses_1",
ptyID: "pty_1",
title: "alpha",
}).state
const closing = transition(renaming, { type: "close.requested", sessionID: "ses_1", ptyID: "pty_1" })
expect(closing.commands).toEqual([
{ type: "abort", attempts: [3] },
{ type: "close-terminal", sessionID: "ses_1", ptyID: "pty_1", attempt: 4 },
])
expect(transition(closing.state, { type: "close.requested", sessionID: "ses_1", ptyID: "pty_1" }).state).toBe(
closing.state,
)
expect(
transition(closing.state, { type: "rename.requested", sessionID: "ses_1", ptyID: "pty_1", title: "beta" }).state,
).toBe(closing.state)
const failed = transition(closing.state, {
type: "close.failed",
sessionID: "ses_1",
ptyID: "pty_1",
attempt: 4,
error: "boom",
})
expect(failed.commands).toEqual([
{ type: "sync-tabs", sessionID: "ses_1", items: [info("pty_1")] },
{ type: "toast-close-failed", error: "boom" },
])
const closed = transition(closing.state, {
type: "close.succeeded",
sessionID: "ses_1",
ptyID: "pty_1",
attempt: 4,
})
expect(viewItems(sessionView(closed.state, "ses_1"))).toEqual([])
expect(closed.commands).toEqual([{ type: "load-list", sessionID: "ses_1", attempt: 5 }])
})
test("removing a session or stopping the model ends its changes in flight and keeps confirmed lists", () => {
const busy = replay(
[
{ type: "create.requested", sessionID: "ses_1" },
{ type: "rename.requested", sessionID: "ses_1", ptyID: "pty_1", title: "alpha" },
{ type: "session.refresh", sessionID: "ses_1" },
],
listed(),
).state
const removed = transition(busy, { type: "sessions.removed", sessionIDs: ["ses_1"] })
expect(removed.commands).toEqual([
{ type: "abort", attempts: [5, 3, 4] },
{ type: "settle-create", sessionID: "ses_1", outcome: { ok: false, kind: "cancelled" } },
])
expect(sessionCreating(removed.state, "ses_1")).toBe(false)
const stopped = transition(busy, { type: "instance.stopped" })
expect(stopped.commands).toEqual([
{ type: "settle-create", sessionID: "ses_1", outcome: { ok: false, kind: "cancelled" } },
])
expect(sessionCreating(stopped.state, "ses_1")).toBe(false)
// The pending rename's title leaves with it; the confirmed list stays for the next instance.
expect(viewItems(sessionView(stopped.state, "ses_1"))).toEqual([info("pty_1")])
expect(transition(stopped.state, { type: "instance.stopped" }).state).toBe(stopped.state)
})
test("answers for a removed session are ignored", () => {
+459 -71
View File
@@ -1,8 +1,14 @@
import type { PersistentPtyInfo } from "@opencode/client"
/**
* How long one terminal request may take before it fails: the support check, a list, a create (including the wait
* for a session the server is still creating), a close or a rename. A server can accept a request and never answer.
*/
export const TERMINAL_REQUEST_TIMEOUT_MS = 15_000
/**
* A session's terminal list once the server offers terminals. `connection` is the server connection whose answer the
* known list is: a refresh keeps the previous one until it answers.
* known list is: a refresh keeps the previous one until it answers. `items` holds only what the server confirmed.
*/
export type List =
| { readonly status: "loading"; readonly attempt: number }
@@ -20,6 +26,17 @@ export type Held =
| { readonly status: "waiting" }
| { readonly status: "ready"; readonly connection: number; readonly items: readonly PersistentPtyInfo[] }
/**
* This client's terminal changes in flight for one session, each with its request's attempt. Every new-terminal
* request joins the one `creating` create. A terminal has at most one `closing` request and one rename; a pending
* rename's title shows over the confirmed list until the server answers.
*/
export type Mutations = {
readonly creating?: number
readonly closing: Readonly<Record<string, number>>
readonly renames: Readonly<Record<string, { readonly attempt: number; readonly title: string | null }>>
}
/**
* One server's terminal support and the terminal lists of its loaded sessions. Each request takes the server's next
* attempt number, so an answer whose attempt the state no longer holds is stale. `issued` is the last one handed out.
@@ -31,7 +48,9 @@ export type State =
readonly status: "checking"
readonly connections: number
readonly attempt: number
readonly issued: number
readonly sessions: Readonly<Record<string, Held>>
readonly mutations: Readonly<Record<string, Mutations>>
}
| {
readonly status: "unsupported"
@@ -51,12 +70,17 @@ export type State =
readonly connections: number
readonly issued: number
readonly sessions: Readonly<Record<string, List>>
readonly mutations: Readonly<Record<string, Mutations>>
}
/** Why a create ended without a terminal: an outdated server, a failed or timed-out request, or a cancel. */
export type CreateFailure = "unsupported" | "failed" | "cancelled"
export type Event =
/** The model started following the server, its event stream connected again, or a restarted server replaced it. */
| { readonly type: "server.connected" }
| { readonly type: "capability.checked"; readonly attempt: number; readonly supported: boolean }
/** The support check failed or timed out. */
| { readonly type: "capability.failed"; readonly attempt: number; readonly error: string }
| { readonly type: "session.loaded"; readonly sessionID: string }
/** The server reported a terminal change, or this client changed the session's terminals. */
@@ -71,23 +95,100 @@ export type Event =
}
/** The list answered 503: the server's terminal daemon can't run. */
| { readonly type: "list.unavailable"; readonly sessionID: string; readonly attempt: number }
/** The list failed or timed out. */
| { readonly type: "list.failed"; readonly sessionID: string; readonly attempt: number; readonly error: string }
/** The server sent a terminal's new info. */
| { readonly type: "terminal.updated"; readonly sessionID: string; readonly terminal: PersistentPtyInfo }
/** This client changed a listed terminal's info. */
| { readonly type: "terminal.replaced"; readonly sessionID: string; readonly terminal: PersistentPtyInfo }
/**
* This client created a terminal. It joins a known list at once: the refresh that follows can lose to a newer one
* the server's `persistent-pty.added` started, and the tab must be listed when it is selected.
*/
| { readonly type: "terminal.created"; readonly sessionID: string; readonly terminal: PersistentPtyInfo }
/** The user asks for a new terminal. A request while one is being created joins it. */
| { readonly type: "create.requested"; readonly sessionID: string }
| {
readonly type: "create.succeeded"
readonly sessionID: string
readonly attempt: number
readonly terminal: PersistentPtyInfo
}
/** The create failed or timed out. */
| {
readonly type: "create.failed"
readonly sessionID: string
readonly attempt: number
readonly kind: "unsupported" | "failed"
readonly error: string
}
/** The user closes a terminal. A request while it is closing joins it. */
| { readonly type: "close.requested"; readonly sessionID: string; readonly ptyID: string }
/** The server removed the terminal, or it was already gone. */
| { readonly type: "close.succeeded"; readonly sessionID: string; readonly ptyID: string; readonly attempt: number }
/** The close failed or timed out. */
| {
readonly type: "close.failed"
readonly sessionID: string
readonly ptyID: string
readonly attempt: number
readonly error: string
}
/** The user sets a terminal's title; `null` clears it. */
| {
readonly type: "rename.requested"
readonly sessionID: string
readonly ptyID: string
readonly title: string | null
}
| {
readonly type: "rename.succeeded"
readonly sessionID: string
readonly ptyID: string
readonly attempt: number
readonly terminal: PersistentPtyInfo
}
/** The rename failed or timed out. */
| {
readonly type: "rename.failed"
readonly sessionID: string
readonly ptyID: string
readonly attempt: number
readonly kind: "unsupported" | "failed"
readonly error: string
}
| { readonly type: "sessions.removed"; readonly sessionIDs: readonly string[] }
/**
* The model goes away, as on a plugin reload. Changes in flight end; the confirmed lists stay for the next instance,
* which checks the server again.
*/
| { readonly type: "instance.stopped" }
export type Command =
| { readonly type: "check-capability"; readonly attempt: number }
| { readonly type: "load-list"; readonly sessionID: string; readonly attempt: number }
/** Waits for a session the server is still creating, lists it when `known` is undefined, and creates a terminal. */
| {
readonly type: "create-terminal"
readonly sessionID: string
readonly attempt: number
readonly known: readonly PersistentPtyInfo[] | undefined
}
| { readonly type: "close-terminal"; readonly sessionID: string; readonly ptyID: string; readonly attempt: number }
| {
readonly type: "rename-terminal"
readonly sessionID: string
readonly ptyID: string
readonly title: string | null
readonly attempt: number
}
/** Cancels requests whose answers the state no longer waits for. */
| { readonly type: "abort"; readonly attempts: readonly number[] }
/** Adds the listed terminals the strip doesn't store yet, such as ones another client created. */
| { readonly type: "sync-tabs"; readonly sessionID: string; readonly items: readonly PersistentPtyInfo[] }
/** Answers every new-terminal request the create joined: selects the terminal, or tells the user why not. */
| {
readonly type: "settle-create"
readonly sessionID: string
readonly outcome:
| { readonly ok: true; readonly terminal: PersistentPtyInfo }
| { readonly ok: false; readonly kind: CreateFailure; readonly error?: string }
}
| { readonly type: "toast-close-failed"; readonly error: string }
| { readonly type: "toast-rename-failed"; readonly kind: "unsupported" | "failed"; readonly error: string }
export type Transition = { readonly state: State; readonly commands: readonly Command[] }
@@ -100,6 +201,8 @@ export type View =
type Supported = Extract<State, { status: "supported" }>
type Active = Extract<State, { status: "checking" | "supported" }>
export const initialState: State = { status: "unknown" }
const LOADING: View = { type: "loading" }
@@ -111,9 +214,17 @@ const NONE: readonly PersistentPtyInfo[] = []
export function transition(state: State, event: Event): Transition {
if (state.status === "unknown") return unknown(state, event)
if (state.status === "checking") return checking(state, event)
if (state.status === "checking" || state.status === "supported") {
if (event.type === "instance.stopped") return stopped(state)
if (state.status === "supported") return supported(state, event)
if (event.type === "sessions.removed") return removed(state, event.sessionIDs)
const mutated = mutate(state, event)
if (mutated) return mutated
return state.status === "checking" ? checking(state, event) : supported(state, event)
}
return resting(state, event)
}
@@ -136,7 +247,14 @@ export function sessionView(state: State, sessionID: string): View {
if (session.status === "failed") return { type: "failed", error: session.error }
return { type: "ready", items: session.items }
return { type: "ready", items: renamed(session.items, state.mutations[sessionID]) }
}
/** A new terminal is being created for the session; another request joins it. */
export function sessionCreating(state: State, sessionID: string) {
return (
(state.status === "checking" || state.status === "supported") && active(state, sessionID).creating !== undefined
)
}
/**
@@ -177,24 +295,26 @@ export function tabTargetable(current: View, id: string) {
}
function unknown(state: State, event: Event): Transition {
if (event.type === "server.connected") return check(1, {}, 1)
if (event.type === "server.connected") return check(1, {}, 1, {})
if (event.type === "session.loaded") return check(1, { [event.sessionID]: { status: "waiting" } }, 1)
if (event.type === "session.loaded") return check(1, { [event.sessionID]: { status: "waiting" } }, 1, {})
if (event.type === "create.requested")
return unchanged(state, [
{ type: "settle-create", sessionID: event.sessionID, outcome: { ok: false, kind: "cancelled" } },
])
return unchanged(state)
}
function checking(state: Extract<State, { status: "checking" }>, event: Event): Transition {
if (event.type === "server.connected") return check(state.attempt + 1, state.sessions, state.connections + 1)
if (event.type === "server.connected")
return check(state.issued + 1, state.sessions, state.connections + 1, state.mutations, [
{ type: "abort", attempts: [state.attempt] },
])
if (event.type === "capability.checked" && event.attempt === state.attempt) {
if (!event.supported)
return changed({
status: "unsupported",
connections: state.connections,
issued: state.attempt,
sessions: Object.keys(state.sessions),
})
if (!event.supported) return lost(state, { status: "unsupported" })
// Every session lists again; a list it already had stays shown meanwhile.
const ids = Object.keys(state.sessions)
@@ -202,7 +322,7 @@ function checking(state: Extract<State, { status: "checking" }>, event: Event):
const sessions = Object.fromEntries(
ids.map((id, index): [string, List] => {
const held = state.sessions[id]
const attempt = state.attempt + 1 + index
const attempt = state.issued + 1 + index
if (held?.status === "ready")
return [id, { status: "refreshing", connection: held.connection, items: held.items, attempt }]
@@ -212,39 +332,25 @@ function checking(state: Extract<State, { status: "checking" }>, event: Event):
)
return changed(
{ status: "supported", connections: state.connections, issued: state.attempt + ids.length, sessions },
ids.map((sessionID, index): Command => ({ type: "load-list", sessionID, attempt: state.attempt + 1 + index })),
{
status: "supported",
connections: state.connections,
issued: state.issued + ids.length,
sessions,
mutations: state.mutations,
},
ids.map((sessionID, index): Command => ({ type: "load-list", sessionID, attempt: state.issued + 1 + index })),
)
}
if (event.type === "capability.failed" && event.attempt === state.attempt)
return changed({
status: "failed",
connections: state.connections,
issued: state.attempt,
sessions: Object.keys(state.sessions),
error: event.error,
})
return lost(state, { status: "failed", error: event.error })
if (event.type === "session.loaded" && !state.sessions[event.sessionID])
return changed({ ...state, sessions: { ...state.sessions, [event.sessionID]: { status: "waiting" } } })
if (event.type === "terminal.updated" || event.type === "terminal.replaced") {
const held = state.sessions[event.sessionID]
if (held?.status !== "ready" || !listed(held.items, event.terminal.id)) return unchanged(state)
return changed({
...state,
sessions: {
...state.sessions,
[event.sessionID]: { ...held, items: replace(held.items, event.terminal) },
},
})
}
if (event.type === "sessions.removed" && event.sessionIDs.some((id) => id in state.sessions))
return changed({ ...state, sessions: omit(state.sessions, event.sessionIDs) })
if (event.type === "terminal.updated")
return changed(withItems(state, event.sessionID, (items) => replace(items, event.terminal)))
return unchanged(state)
}
@@ -255,6 +361,7 @@ function resting(state: Extract<State, { status: "unsupported" | "failed" }>, ev
state.issued + 1,
Object.fromEntries(state.sessions.map((id): [string, Held] => [id, { status: "waiting" }])),
event.type === "server.connected" ? state.connections + 1 : state.connections,
{},
)
if (event.type === "session.loaded" && !state.sessions.includes(event.sessionID))
@@ -263,6 +370,19 @@ function resting(state: Extract<State, { status: "unsupported" | "failed" }>, ev
if (event.type === "sessions.removed" && state.sessions.some((id) => event.sessionIDs.includes(id)))
return changed({ ...state, sessions: state.sessions.filter((id) => !event.sessionIDs.includes(id)) })
// A server without terminals answers a new-terminal request at once.
if (event.type === "create.requested")
return unchanged(state, [
{
type: "settle-create",
sessionID: event.sessionID,
outcome:
state.status === "unsupported"
? { ok: false, kind: "unsupported" }
: { ok: false, kind: "failed", error: state.error },
},
])
return unchanged(state)
}
@@ -279,6 +399,8 @@ function supported(state: Supported, event: Event): Transition {
}),
),
state.connections + 1,
state.mutations,
abort(Object.values(state.sessions).flatMap(listAttempt)),
)
const list = "sessionID" in event ? state.sessions[event.sessionID] : undefined
@@ -302,13 +424,7 @@ function supported(state: Supported, event: Event): Transition {
)
// A server whose terminal daemon can't run offers no terminals until the next connection checks again.
if (event.type === "list.unavailable")
return changed({
status: "unsupported",
connections: state.connections,
issued: state.issued,
sessions: Object.keys(state.sessions),
})
if (event.type === "list.unavailable") return lost(state, { status: "unsupported" })
// A failed refresh keeps the terminals already shown.
if (list.status === "refreshing")
@@ -317,23 +433,222 @@ function supported(state: Supported, event: Event): Transition {
return changed(put(state, event.sessionID, { status: "failed", error: event.error }))
}
if (event.type === "terminal.updated" || event.type === "terminal.replaced") {
if (event.type === "terminal.updated") {
if (!list) return unchanged(state)
if ((list.status === "ready" || list.status === "refreshing") && listed(list.items, event.terminal.id))
return changed(put(state, event.sessionID, { ...list, items: replace(list.items, event.terminal) }))
// A terminal the list doesn't have yet arrives with the next list; this client changes only listed ones.
return event.type === "terminal.updated" ? load(state, event.sessionID) : unchanged(state)
// A terminal the list doesn't have yet arrives with the next list.
return load(state, event.sessionID)
}
if (event.type === "terminal.created")
return (list?.status === "ready" || list?.status === "refreshing") && !listed(list.items, event.terminal.id)
? changed(put(state, event.sessionID, { ...list, items: [...list.items, event.terminal] }))
: unchanged(state)
return unchanged(state)
}
if (event.type === "sessions.removed" && event.sessionIDs.some((id) => id in state.sessions))
return changed({ ...state, sessions: omit(state.sessions, event.sessionIDs) })
/** This client's creates, closes and renames, the same while support is checked again and once it is known. */
function mutate(state: Active, event: Event): Transition | undefined {
if (!("sessionID" in event)) return
const sessionID = event.sessionID
const current = active(state, sessionID)
if (event.type === "create.requested") {
if (current.creating !== undefined) return unchanged(state)
const attempt = state.issued + 1
return changed(withMutations({ ...state, issued: attempt }, sessionID, { ...current, creating: attempt }), [
{ type: "create-terminal", sessionID, attempt, known: known(state, sessionID) },
])
}
if (event.type === "create.succeeded") {
// A create that the state stopped waiting for still made a terminal; the list shows it once it refreshes.
if (current.creating !== event.attempt) return refresh(state, sessionID)
const next = withItems(
withMutations(state, sessionID, { closing: current.closing, renames: current.renames }),
sessionID,
(items) => (listed(items, event.terminal.id) ? items : [...items, event.terminal]),
)
const settle: Command = { type: "settle-create", sessionID, outcome: { ok: true, terminal: event.terminal } }
const refreshed = refresh(next, sessionID)
return changed(refreshed.state, [settle, ...refreshed.commands])
}
if (event.type === "create.failed") {
if (current.creating !== event.attempt) return unchanged(state)
return changed(withMutations(state, sessionID, { closing: current.closing, renames: current.renames }), [
{ type: "settle-create", sessionID, outcome: { ok: false, kind: event.kind, error: event.error } },
])
}
if (event.type === "close.requested") {
if (current.closing[event.ptyID] !== undefined) return unchanged(state)
const attempt = state.issued + 1
// Closing a terminal ends its rename.
const rename = current.renames[event.ptyID]
return changed(
withMutations({ ...state, issued: attempt }, sessionID, {
...current,
closing: { ...current.closing, [event.ptyID]: attempt },
renames: without(current.renames, event.ptyID),
}),
[...abort(rename ? [rename.attempt] : []), { type: "close-terminal", sessionID, ptyID: event.ptyID, attempt }],
)
}
if (event.type === "close.succeeded") {
if (current.closing[event.ptyID] !== event.attempt) return unchanged(state)
return refresh(
withItems(
withMutations(state, sessionID, { ...current, closing: without(current.closing, event.ptyID) }),
sessionID,
(items) => items.filter((info) => info.id !== event.ptyID),
),
sessionID,
)
}
if (event.type === "close.failed") {
if (current.closing[event.ptyID] !== event.attempt) return unchanged(state)
const items = known(state, sessionID)
// The terminal still runs, so its tab, which the strip already let go of, comes back.
return changed(withMutations(state, sessionID, { ...current, closing: without(current.closing, event.ptyID) }), [
...(items ? [{ type: "sync-tabs", sessionID, items } satisfies Command] : []),
{ type: "toast-close-failed", error: event.error },
])
}
if (event.type === "rename.requested") {
const item = known(state, sessionID)?.find((info) => info.id === event.ptyID)
const pending = current.renames[event.ptyID]
if (!item || current.closing[event.ptyID] !== undefined) return unchanged(state)
// The title already shown, or clearing a terminal never renamed, sends nothing.
if (
pending
? pending.title === event.title
: event.title === item.title || (event.title === null && item.userTitle === null)
)
return unchanged(state)
const attempt = state.issued + 1
return changed(
withMutations({ ...state, issued: attempt }, sessionID, {
...current,
renames: { ...current.renames, [event.ptyID]: { attempt, title: event.title } },
}),
[
...abort(pending ? [pending.attempt] : []),
{ type: "rename-terminal", sessionID, ptyID: event.ptyID, title: event.title, attempt },
],
)
}
if (event.type === "rename.succeeded" || event.type === "rename.failed") {
// An older rename's answer is stale: the newer one decides, and the server's update event reports the result.
if (current.renames[event.ptyID]?.attempt !== event.attempt) return unchanged(state)
const next = withMutations(state, sessionID, { ...current, renames: without(current.renames, event.ptyID) })
if (event.type === "rename.failed")
return changed(next, [{ type: "toast-rename-failed", kind: event.kind, error: event.error }])
return changed(withItems(next, sessionID, (items) => replace(items, event.terminal)))
}
return
}
/** Support went away: every change in flight ends, and new-terminal requests learn why. */
function lost(
state: Active,
next: { readonly status: "unsupported" } | { readonly status: "failed"; readonly error: string },
): Transition {
const base = { connections: state.connections, issued: state.issued, sessions: Object.keys(state.sessions) }
const outcome =
next.status === "unsupported"
? ({ ok: false, kind: "unsupported" } as const)
: ({ ok: false, kind: "failed", error: next.error } as const)
return changed(
next.status === "unsupported"
? { status: "unsupported", ...base }
: { status: "failed", ...base, error: next.error },
[
...abort(attempts(state, [...new Set([...Object.keys(state.sessions), ...Object.keys(state.mutations)])])),
...settleCreates(state, Object.keys(state.mutations), outcome),
],
)
}
function removed(state: Active, ids: readonly string[]): Transition {
if (!ids.some((id) => id in state.sessions || id in state.mutations)) return unchanged(state)
const mutations = omit(state.mutations, ids)
const commands = [
...abort(attempts(state, ids)),
...settleCreates(state, ids, { ok: false, kind: "cancelled" } as const),
]
if (state.status === "checking")
return changed({ ...state, sessions: omit(state.sessions, ids), mutations }, commands)
return changed({ ...state, sessions: omit(state.sessions, ids), mutations }, commands)
}
/** The confirmed lists stay for the next instance; the changes in flight end with this one. */
function stopped(state: Active): Transition {
if (Object.keys(state.mutations).length === 0) return unchanged(state)
return changed(
{ ...state, mutations: {} },
settleCreates(state, Object.keys(state.mutations), { ok: false, kind: "cancelled" } as const),
)
}
function settleCreates(
state: Active,
ids: readonly string[],
outcome: { readonly ok: false; readonly kind: CreateFailure; readonly error?: string },
): Command[] {
return ids.flatMap((sessionID): Command[] =>
state.mutations[sessionID]?.creating === undefined ? [] : [{ type: "settle-create", sessionID, outcome }],
)
}
/** Every request in flight for the sessions: their lists and this client's changes. */
function attempts(state: Active, ids: readonly string[]) {
return ids.flatMap((id) => {
const entry = state.sessions[id]
const mutations = state.mutations[id]
const list = entry && entry.status !== "waiting" ? listAttempt(entry) : []
if (!mutations) return list
return [
...list,
...(mutations.creating === undefined ? [] : [mutations.creating]),
...Object.values(mutations.closing),
...Object.values(mutations.renames).map((rename) => rename.attempt),
]
})
}
function listAttempt(list: List) {
return list.status === "loading" || list.status === "refreshing" ? [list.attempt] : []
}
/** Lists the session again once support is known; while it is checked, the check's lists will. */
function refresh(state: Active, sessionID: string): Transition {
if (state.status === "supported" && state.sessions[sessionID]) return load(state, sessionID)
return unchanged(state)
}
@@ -347,19 +662,92 @@ function load(state: Supported, sessionID: string): Transition {
? ({ status: "refreshing", connection: list.connection, items: list.items, attempt } satisfies List)
: ({ status: "loading", attempt } satisfies List)
return changed({ ...put(state, sessionID, next), issued: attempt }, [{ type: "load-list", sessionID, attempt }])
// Only the newest list may answer, so the one it replaces stops.
return changed({ ...put(state, sessionID, next), issued: attempt }, [
...abort(list ? listAttempt(list) : []),
{ type: "load-list", sessionID, attempt },
])
}
function check(attempt: number, sessions: Readonly<Record<string, Held>>, connections: number): Transition {
return changed({ status: "checking", connections, attempt, sessions }, [{ type: "check-capability", attempt }])
function check(
attempt: number,
sessions: Readonly<Record<string, Held>>,
connections: number,
mutations: Readonly<Record<string, Mutations>>,
commands: readonly Command[] = [],
): Transition {
return changed({ status: "checking", connections, attempt, issued: attempt, sessions, mutations }, [
...commands,
{ type: "check-capability", attempt },
])
}
function abort(attempts: readonly number[]): Command[] {
return attempts.length > 0 ? [{ type: "abort", attempts }] : []
}
/** The session's changes in flight; a fresh object each time, as the state lives in a store. */
function active(state: Active, sessionID: string): Mutations {
return state.mutations[sessionID] ?? { closing: {}, renames: {} }
}
function known(state: Active, sessionID: string) {
const entry = state.sessions[sessionID]
return entry?.status === "ready" || entry?.status === "refreshing" ? entry.items : undefined
}
function withMutations<T extends Active>(state: T, sessionID: string, next: Mutations): T {
const idle =
next.creating === undefined && Object.keys(next.closing).length === 0 && Object.keys(next.renames).length === 0
return { ...state, mutations: idle ? omit(state.mutations, [sessionID]) : { ...state.mutations, [sessionID]: next } }
}
/** Changes a known list's confirmed terminals; a list not known yet stays as it is. */
function withItems<T extends Active>(
state: T,
sessionID: string,
change: (items: readonly PersistentPtyInfo[]) => readonly PersistentPtyInfo[],
): T {
if (state.status === "checking") {
const held = state.sessions[sessionID]
if (held?.status !== "ready") return state
const items = change(held.items)
return items === held.items ? state : { ...state, sessions: { ...state.sessions, [sessionID]: { ...held, items } } }
}
const list = state.sessions[sessionID]
if (list?.status !== "ready" && list?.status !== "refreshing") return state
const items = change(list.items)
return items === list.items ? state : { ...state, sessions: { ...state.sessions, [sessionID]: { ...list, items } } }
}
/** Shows each pending rename's new title over the confirmed list; a cleared title shows what the server falls back to. */
function renamed(items: readonly PersistentPtyInfo[], mutations: Mutations | undefined) {
if (!mutations || !Object.values(mutations.renames).some((rename) => rename.title !== null)) return items
return items.map((info) => {
const title = mutations.renames[info.id]?.title
return title === undefined || title === null ? info : { ...info, title, userTitle: title }
})
}
function put(state: Supported, sessionID: string, list: List): Supported {
return { ...state, sessions: { ...state.sessions, [sessionID]: list } }
}
function omit<T extends Held | List>(sessions: Readonly<Record<string, T>>, ids: readonly string[]) {
return Object.fromEntries(Object.entries(sessions).filter(([id]) => !ids.includes(id)))
function omit<T>(record: Readonly<Record<string, T>>, ids: readonly string[]) {
return Object.fromEntries(Object.entries(record).filter(([id]) => !ids.includes(id)))
}
function without<T>(record: Readonly<Record<string, T>>, id: string) {
return omit(record, [id])
}
function listed(list: readonly PersistentPtyInfo[], id: string) {
@@ -367,13 +755,13 @@ function listed(list: readonly PersistentPtyInfo[], id: string) {
}
function replace(list: readonly PersistentPtyInfo[], terminal: PersistentPtyInfo) {
return list.map((info) => (info.id === terminal.id ? terminal : info))
return listed(list, terminal.id) ? list.map((info) => (info.id === terminal.id ? terminal : info)) : list
}
function changed(state: State, commands: readonly Command[] = []): Transition {
return { state, commands }
}
function unchanged(state: State): Transition {
return { state, commands: [] }
function unchanged(state: State, commands: readonly Command[] = []): Transition {
return { state, commands }
}
+143 -11
View File
@@ -1,12 +1,18 @@
import { describe, expect, test } from "bun:test"
import { describe, expect, jest, test } from "bun:test"
import { createStore, produce } from "solid-js/store"
import { ClientError, type PersistentPtyInfo } from "@opencode/client"
import type { Layout, ServerRef, SessionRef, Storage, ToastOptions } from "@opencode/plugin/gui"
import { PtyFeatureUnsupportedError, ServiceUnavailableError } from "@opencode/protocol/errors"
import { PtyFeatureUnsupportedError, PtyNotFoundError, ServiceUnavailableError } from "@opencode/protocol/errors"
import { TERMINAL_REQUEST_TIMEOUT_MS } from "./machine"
import { createTerminalModel } from "./model"
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
// Settles pending promise callbacks while fake timers hold `flush`.
const ticks = async () => {
for (const _ of Array.from({ length: 20 })) await Promise.resolve()
}
const info = (id: string, title: string, sessionID = "ses_1"): PersistentPtyInfo => ({
id,
title,
@@ -74,6 +80,7 @@ function fakeServer(options: { capabilities?: { persistentPty?: boolean } } = {}
},
create: async (input: { sessionID: string; title: string; cwd?: string }) => {
calls.create.push(input)
await state.create
if (state.createError) throw state.createError
@@ -85,6 +92,8 @@ function fakeServer(options: { capabilities?: { persistentPty?: boolean } } = {}
},
remove: async (input: { ptyID: string }) => {
calls.remove.push(input.ptyID)
if (state.removeError) throw state.removeError
terminals.forEach((list, sessionID) =>
terminals.set(
sessionID,
@@ -132,6 +141,8 @@ type FakeState = {
directory: string
created: number
createError?: Error
removeError?: Error
create?: Promise<void>
listError?: Error
updateError?: Error
update?: Promise<void>
@@ -337,7 +348,8 @@ describe("terminal model", () => {
expect(server.calls.list).toBe(calls)
server.state.listError = undefined
await terminal.retry()
terminal.retry()
await flush()
expect(terminal.view().type).toBe("ready")
expect(terminal.all().map((item) => item.id)).toEqual(["pty_1"])
@@ -431,7 +443,8 @@ describe("terminal model", () => {
await flush()
terminal.select("pty_1")
await terminal.close("pty_1")
terminal.close("pty_1")
await flush()
expect(server.calls.remove).toEqual(["pty_1"])
expect(terminal.all().map((item) => item.id)).toEqual(["pty_2"])
@@ -471,12 +484,12 @@ describe("terminal model", () => {
const terminal = model.load(session)
await flush()
const renamed = terminal.rename("pty_1", "build")
terminal.rename("pty_1", "build")
expect(terminal.get("pty_1")?.title).toBe("build")
update.resolve()
await renamed
await flush()
expect(server.calls.update).toEqual([{ ptyID: "pty_1", title: "build" }])
expect(terminal.get("pty_1")).toMatchObject({ title: "build", userTitle: "build" })
@@ -489,8 +502,9 @@ describe("terminal model", () => {
const terminal = model.load(session)
await flush()
await terminal.rename("pty_1", "Terminal 1")
await terminal.rename("pty_1", null)
terminal.rename("pty_1", "Terminal 1")
terminal.rename("pty_1", null)
await flush()
expect(server.calls.update).toEqual([])
})
@@ -502,7 +516,8 @@ describe("terminal model", () => {
const terminal = model.load(session)
await flush()
await terminal.rename("pty_1", null)
terminal.rename("pty_1", null)
await flush()
expect(server.calls.update).toEqual([{ ptyID: "pty_1", title: null }])
expect(terminal.get("pty_1")).toMatchObject({ title: "zsh", userTitle: null })
@@ -516,7 +531,8 @@ describe("terminal model", () => {
const terminal = model.load(session)
await flush()
await terminal.rename("pty_1", "build")
terminal.rename("pty_1", "build")
await flush()
expect(toasts).toEqual([{ variant: "error", title: "rename.unsupported" }])
expect(terminal.get("pty_1")).toMatchObject({ title: "Terminal 1", userTitle: null })
@@ -530,7 +546,8 @@ describe("terminal model", () => {
const terminal = model.load(session)
await flush()
await terminal.rename("pty_1", "build")
terminal.rename("pty_1", "build")
await flush()
expect(toasts).toEqual([{ variant: "error", title: "rename.failed", description: "boom" }])
expect(terminal.get("pty_1")?.title).toBe("Terminal 1")
@@ -549,4 +566,119 @@ describe("terminal model", () => {
},
])
})
// Double-clicking "+" or pressing the shortcut twice used to create two terminals, both titled "Terminal 1".
test("a second new-terminal request joins the create in flight", async () => {
const server = fakeServer()
const create = Promise.withResolvers<void>()
server.state.create = create.promise
const { model, session } = setup(server)
const terminal = model.load(session)
await flush()
const first = terminal.new()
const second = terminal.new()
expect(terminal.creating()).toBe(true)
create.resolve()
expect(await Promise.all([first, second])).toEqual(["pty_new1", "pty_new1"])
expect(server.calls.create).toEqual([{ sessionID: "ses_1", args: [], cwd: "/repo", title: "Terminal 1", env: {} }])
expect(terminal.creating()).toBe(false)
expect(terminal.focusRequested("pty_new1")).toBe(true)
})
test("a terminal for a session the server is still creating waits for it, and a removal ends the wait", async () => {
const server = fakeServer()
const { model, session, toasts } = setup(server)
const pending: SessionRef = {
...session,
get pending() {
return true
},
}
const terminal = model.load(pending)
await flush()
const created = terminal.new()
await flush()
expect(server.calls.create).toEqual([])
// `pending` can clear without anything to watch; the session's created event answers at once.
server.emit("session.created")
expect(await created).toBe("pty_new1")
// A removed workspace ends a wait that would never end, quietly.
const waiting = terminal.new()
model.remove({ server: server.ref.id, directory: "/repo" })
expect(await waiting).toBeUndefined()
expect(server.calls.create).toHaveLength(1)
expect(toasts).toEqual([])
})
// A failed close used to end in a console line, with the tab gone and the terminal still running.
test("a failed close tells the user and brings the tab back; a terminal already gone closes quietly", async () => {
const server = fakeServer()
server.terminals.set("ses_1", [info("pty_1", "Terminal 1"), info("pty_2", "Terminal 2")])
const { model, session, stored, toasts } = setup(server)
stored.set(session.key, ["pty_1", "pty_2"])
const terminal = model.load(session)
await flush()
// Closing the tab let the strip drop it.
stored.set(session.key, ["pty_2"])
server.state.removeError = new Error("boom")
terminal.close("pty_1")
terminal.close("pty_1")
await flush()
expect(server.calls.remove).toEqual(["pty_1"])
expect(toasts).toEqual([{ variant: "error", title: "close.failed", description: "boom" }])
expect(stored.get(session.key)).toEqual(["pty_2", "pty_1"])
// A terminal that exited meanwhile is already closed.
server.terminals.set("ses_1", [info("pty_1", "Terminal 1")])
server.state.removeError = new PtyNotFoundError({ ptyID: "pty_2", message: "gone" })
terminal.close("pty_2")
await flush()
expect(toasts).toHaveLength(1)
expect(terminal.all().map((item) => item.id)).toEqual(["pty_1"])
})
// A server that accepted the request and never answered used to leave the panel on "Loading terminal." with the
// restored tabs hidden and no Retry.
test("a list the server never answers fails after the request budget and offers a retry", async () => {
jest.useFakeTimers()
try {
const server = fakeServer()
server.state.list = new Promise(() => undefined)
server.terminals.set("ses_1", [info("pty_1", "Terminal 1")])
const { model, session } = setup(server)
const terminal = model.load(session)
await ticks()
expect(terminal.view()).toEqual({ type: "loading" })
jest.advanceTimersByTime(TERMINAL_REQUEST_TIMEOUT_MS)
await ticks()
expect(terminal.view()).toEqual({ type: "failed", error: "request.timedOut" })
expect(terminal.tabs(["pty_restored"])).toEqual(["pty_restored"])
server.state.list = undefined
terminal.retry()
await ticks()
expect(terminal.all().map((item) => item.id)).toEqual(["pty_1"])
} finally {
jest.useRealTimers()
}
})
})
+338 -175
View File
@@ -1,27 +1,22 @@
import { createRoot, createSignal, getOwner, runWithOwner, untrack, type Owner } from "solid-js"
import { createSignal, getOwner, runWithOwner, untrack } from "solid-js"
import { unwrap } from "solid-js/store"
import { Predicate, Schema } from "effect"
import {
ClientError,
isPtyFeatureUnsupportedError,
isPtyNotFoundError,
isServiceUnavailableError,
type PersistentPtyInfo,
} from "@opencode/client"
import {
createKeyed,
type Layout,
type Persisted,
type ServerRef,
type SessionRef,
type Storage,
type Toasts,
} from "@opencode/plugin/gui"
import type { Layout, Persisted, ServerRef, SessionRef, Storage, Toasts } from "@opencode/plugin/gui"
import { createDispatch } from "./dispatch"
import { defaultTitle, titleNumber } from "./title"
import {
initialState,
serverAvailable,
sessionConnection,
sessionCreating,
sessionView,
TERMINAL_REQUEST_TIMEOUT_MS,
transition,
viewItems,
viewTabs,
@@ -51,6 +46,9 @@ const PANELS = "#terminal-panel, #review-panel"
const OVERLAYS = '[role="dialog"], [role="menu"]'
// How often a new terminal checks whether the server finished creating its session.
const SESSION_POLL_MS = 250
const importGhostty = () => import("ghostty-web").then(async (mod) => ({ mod, ghostty: await mod.Ghostty.load() }))
/** The one ghostty-web load the window's terminals share; a failed load is retried by the next terminal. */
@@ -89,64 +87,216 @@ export function createTerminalModel(input: {
const serverState = (id: string) => machine.servers[id] ?? initialState
/** Resolves once the requests the event started have answered and their answers were dispatched. */
const dispatch = (server: ServerRef, event: Event): Promise<void> => {
if (lifetime.signal.aborted) return Promise.resolve()
// Reads from reactive computations, such as `load`, must not subscribe them to every state change.
const previous = untrack(() => unwrap(machine.servers[server.id])) ?? initialState
const next = transition(previous, event)
// The live ref and driver loop of each server, and the requests in flight by attempt.
const servers = new Map<string, { ref: ServerRef; dispatch: (event: Event) => void }>()
const requests = new Map<string, Map<number, AbortController>>()
// The new-terminal requests a session's create answers, and the focus request it resolves.
if (next.state !== previous) setMachine((draft) => void (draft.servers[server.id] = next.state))
const creates = new Map<
string,
{ request: number; done: { promise: Promise<string | undefined>; resolve: (id: string | undefined) => void } }
>()
return Promise.all(next.commands.map((command) => run(server, command))).then(() => undefined)
const dispatch = (server: ServerRef, event: Event) => {
if (lifetime.signal.aborted) return
const existing = servers.get(server.id)
if (existing) {
existing.ref = server
existing.dispatch(event)
return
}
const created = {
ref: server,
dispatch: createDispatch<State, Event, Command>({
// Reads from reactive computations, such as `load`, must not subscribe them to every state change.
read: () => untrack(() => unwrap(machine.servers[server.id])) ?? initialState,
write: (state) => setMachine((draft) => void (draft.servers[server.id] = state)),
transition: (state, next) => {
const result = transition(state, next)
return { state: result.state, effects: result.commands }
},
run: (command) => run(created.ref, command),
}),
}
servers.set(server.id, created)
created.dispatch(event)
}
const run = (server: ServerRef, command: Command): Promise<void> => {
/**
* Runs one attempt's request under the request budget. A request the machine stopped waiting for answers nothing; one
* that runs out of time fails, even when the server never ends it.
*/
const request = <A>(
server: ServerRef,
attempt: number,
call: (signal: AbortSignal) => Promise<A>,
settle: { ok: (value: A) => Event; failed: (error: Error) => Event },
) => {
const controller = new AbortController()
const signal = AbortSignal.any([controller.signal, lifetime.signal])
const timedOut = new Error(input.t("request.timedOut"))
const timer = setTimeout(() => controller.abort(timedOut), TERMINAL_REQUEST_TIMEOUT_MS)
const inflight = requests.get(server.id) ?? new Map<number, AbortController>()
const ended = new Promise<never>((_, reject) =>
signal.addEventListener("abort", () => reject(signal.reason), { once: true }),
)
requests.set(server.id, inflight)
inflight.set(attempt, controller)
return Promise.race([call(signal), ended])
.then(
(value) => dispatch(server, settle.ok(value)),
(cause: unknown) => {
if (signal.aborted && signal.reason !== timedOut) return
dispatch(server, settle.failed(cause instanceof Error ? cause : new Error(String(cause))))
},
)
.finally(() => {
clearTimeout(timer)
inflight.delete(attempt)
})
}
const run = (server: ServerRef, command: Command): Promise<void> | void => {
if (command.type === "check-capability")
return server.client.server.info().then(
return request(server, command.attempt, (signal) => server.client.server.info({ signal }), {
// Servers that predate the field omit it; they still offer terminals.
(info) =>
dispatch(server, {
type: "capability.checked",
attempt: command.attempt,
supported: info.capabilities?.persistentPty !== false,
}),
(error) => {
ok: (info) => ({
type: "capability.checked",
attempt: command.attempt,
supported: info.capabilities?.persistentPty !== false,
}),
failed: (error) => {
console.error("Failed to read server terminal support", error)
return dispatch(server, {
type: "capability.failed",
attempt: command.attempt,
error: error instanceof Error ? error.message : String(error),
})
return { type: "capability.failed", attempt: command.attempt, error: error.message }
},
)
})
if (command.type === "load-list")
return server.client.experimental.persistentPty.list({ sessionID: command.sessionID }).then(
(items) =>
dispatch(server, { type: "list.loaded", sessionID: command.sessionID, attempt: command.attempt, items }),
(error) => {
if (isServiceUnavailableError(error))
return dispatch(server, {
type: "list.unavailable",
sessionID: command.sessionID,
attempt: command.attempt,
})
console.error("Failed to list terminals", error)
return request(
server,
command.attempt,
(signal) => server.client.experimental.persistentPty.list({ sessionID: command.sessionID }, { signal }),
{
ok: (items) => ({ type: "list.loaded", sessionID: command.sessionID, attempt: command.attempt, items }),
failed: (error) => {
if (isServiceUnavailableError(error))
return { type: "list.unavailable", sessionID: command.sessionID, attempt: command.attempt }
console.error("Failed to list terminals", error)
return dispatch(server, {
type: "list.failed",
sessionID: command.sessionID,
attempt: command.attempt,
error: error instanceof Error ? error.message : String(error),
})
return { type: "list.failed", sessionID: command.sessionID, attempt: command.attempt, error: error.message }
},
},
)
if (command.type === "create-terminal")
return request(server, command.attempt, (signal) => createTerminal(server, command, signal), {
ok: (terminal) => ({
type: "create.succeeded",
sessionID: command.sessionID,
attempt: command.attempt,
terminal,
}),
failed: (error) => ({
type: "create.failed",
sessionID: command.sessionID,
attempt: command.attempt,
kind: outdated(error) ? "unsupported" : "failed",
error: error.message,
}),
})
if (command.type === "close-terminal")
return request(
server,
command.attempt,
(signal) => server.client.experimental.persistentPty.remove({ ptyID: command.ptyID }, { signal }),
{
ok: () => ({
type: "close.succeeded",
sessionID: command.sessionID,
ptyID: command.ptyID,
attempt: command.attempt,
}),
// A terminal that already exited is closed.
failed: (error) =>
isPtyNotFoundError(error)
? {
type: "close.succeeded",
sessionID: command.sessionID,
ptyID: command.ptyID,
attempt: command.attempt,
}
: {
type: "close.failed",
sessionID: command.sessionID,
ptyID: command.ptyID,
attempt: command.attempt,
error: error.message,
},
},
)
if (command.type === "rename-terminal")
return request(
server,
command.attempt,
(signal) =>
server.client.experimental.persistentPty.update({ ptyID: command.ptyID, title: command.title }, { signal }),
{
ok: (terminal) => ({
type: "rename.succeeded",
sessionID: command.sessionID,
ptyID: command.ptyID,
attempt: command.attempt,
terminal,
}),
failed: (error) => ({
type: "rename.failed",
sessionID: command.sessionID,
ptyID: command.ptyID,
attempt: command.attempt,
kind: isPtyFeatureUnsupportedError(error) ? "unsupported" : "failed",
error: error.message,
}),
},
)
if (command.type === "abort") {
command.attempts.forEach((attempt) => requests.get(server.id)?.get(attempt)?.abort())
return
}
if (command.type === "settle-create") return settleCreate(server, command)
if (command.type === "toast-close-failed") {
input.toast.show({ variant: "error", title: input.t("close.failed"), description: command.error })
return
}
if (command.type === "toast-rename-failed") {
input.toast.show(
command.kind === "unsupported"
? { variant: "error", title: input.t("rename.unsupported") }
: { variant: "error", title: input.t("rename.failed"), description: command.error },
)
return
}
const session = sessions.get(`${server.id}\n${command.sessionID}`)
if (!session) return Promise.resolve()
if (!session) return
dropLegacy(session)
// The strip shows only stored tabs, so terminals another client created join it at the end.
const stored = new Set(input.layout.stored(session))
@@ -154,8 +304,103 @@ export function createTerminalModel(input: {
command.items.forEach((info) => {
if (!stored.has(info.id)) input.layout.open(input.tab(info.id), session, { tab: "append" })
})
}
return Promise.resolve()
const createTerminal = async (
server: ServerRef,
command: Extract<Command, { type: "create-terminal" }>,
signal: AbortSignal,
) => {
const target = sessions.get(`${server.id}\n${command.sessionID}`)
if (!target) throw new Error(input.t("create.failed"))
await settled(target, signal)
// The next default title counts the terminals the server already has.
const known =
command.known ??
(await server.client.experimental.persistentPty.list({ sessionID: target.id }, { signal }).catch(() => []))
const used = new Set(known.flatMap((info) => titleNumber(info.title) ?? []))
const number = Array.from({ length: used.size + 1 }, (_, index) => index + 1).find((n) => !used.has(n)) ?? 1
return server.client.experimental.persistentPty.create(
{
sessionID: target.id,
args: [],
// A session that moved keeps its object; the server's record has the directory it runs in now.
cwd: server.data.session.get(target.id)?.location.directory ?? target.location?.directory,
title: defaultTitle(number),
env: {},
},
{ signal },
)
}
/**
* Resolves once the server has created the session: on its `session.created` event, or once `pending` clears. No
* signal tracks `pending`, so it is checked again on an interval.
*/
const settled = (session: SessionRef, signal: AbortSignal) => {
if (!session.pending) return Promise.resolve()
return new Promise<void>((resolve, reject) => {
const end = () => {
clearInterval(poll)
stop()
signal.removeEventListener("abort", abort)
}
const done = () => {
end()
resolve()
}
const abort = () => {
end()
reject(signal.reason)
}
const stop = runWithOwner(owner, () =>
session.server.data.on("session.created", (event) => {
if (event.data.sessionID === session.id) done()
}),
)!
const poll = setInterval(() => {
if (!session.pending) done()
}, SESSION_POLL_MS)
signal.addEventListener("abort", abort, { once: true })
})
}
const settleCreate = (server: ServerRef, command: Extract<Command, { type: "settle-create" }>) => {
const key = `${server.id}\n${command.sessionID}`
const waiting = creates.get(key)
creates.delete(key)
if (command.outcome.ok) {
const id = command.outcome.terminal.id
const target = sessions.get(key)
if (target) selection(target).set({ id })
if (waiting) dispatchFocus({ type: "pending.resolved", request: waiting.request, id })
waiting?.done.resolve(id)
return
}
if (waiting) dispatchFocus({ type: "pending.failed", request: waiting.request })
if (command.outcome.kind === "unsupported")
input.toast.show({ variant: "error", title: input.t("create.unsupported") })
if (command.outcome.kind === "failed")
input.toast.show({ variant: "error", title: input.t("create.failed"), description: command.outcome.error })
waiting?.done.resolve(undefined)
}
const available = (server: ServerRef) => {
@@ -176,30 +421,26 @@ export function createTerminalModel(input: {
// server, so they belong to the model.
const stops = runWithOwner(owner, () => [
// The event stream opens with server.connected, so this runs for the first connection and every reconnect.
data.on("server.connected", () => void dispatch(server, { type: "server.connected" })),
data.on(
"persistent-pty.added",
(event) => void dispatch(server, { type: "session.refresh", sessionID: event.data.sessionID }),
data.on("server.connected", () => dispatch(server, { type: "server.connected" })),
data.on("persistent-pty.added", (event) =>
dispatch(server, { type: "session.refresh", sessionID: event.data.sessionID }),
),
data.on(
"persistent-pty.removed",
(event) => void dispatch(server, { type: "session.refresh", sessionID: event.data.sessionID }),
data.on("persistent-pty.removed", (event) =>
dispatch(server, { type: "session.refresh", sessionID: event.data.sessionID }),
),
data.on(
"persistent-pty.updated",
(event) =>
void dispatch(server, {
type: "terminal.updated",
sessionID: event.data.sessionID,
terminal: event.data.terminal,
}),
data.on("persistent-pty.updated", (event) =>
dispatch(server, {
type: "terminal.updated",
sessionID: event.data.sessionID,
terminal: event.data.terminal,
}),
),
])!
watched.set(server.id, { data, stop: () => stops.forEach((stop) => stop()) })
// Checks now rather than waiting for the stream: it may have connected already, and an answer a previous
// instance was waiting for never arrives here.
void dispatch(server, { type: "server.connected" })
dispatch(server, { type: "server.connected" })
}
const selection = (session: SessionRef) => {
@@ -289,104 +530,33 @@ export function createTerminalModel(input: {
select(id: string) {
selection(session()).set({ id })
},
/** Creates a terminal and selects it; resolves its id, or undefined when the server refuses. */
async new() {
/** A new terminal is being created; another request joins it. */
creating: () => sessionCreating(serverState(initial.server.id), initial.id),
/**
* Creates a terminal and selects it; resolves its id, or undefined when the server refuses. A request while one
* is being created joins it and resolves the same id.
*/
new() {
const target = session()
const request = dispatchFocus({ type: "request.pending", session: key, overlay: overlay() }).request
const done = creates.get(key)?.done ?? Promise.withResolvers<string | undefined>()
await settled(target, owner)
// The create can answer before dispatch returns, so its waiter is registered first.
creates.set(key, { request, done })
dispatch(target.server, { type: "create.requested", sessionID: target.id })
// The next default title counts the terminals the server already has.
const known =
view().type === "ready"
? all()
: await target.server.client.experimental.persistentPty.list({ sessionID: target.id }).catch(() => all())
const used = new Set(known.flatMap((info) => titleNumber(info.title) ?? []))
const number = Array.from({ length: used.size + 1 }, (_, index) => index + 1).find((n) => !used.has(n)) ?? 1
const info = await target.server.client.experimental.persistentPty
.create({
sessionID: target.id,
args: [],
// A session that moved keeps its object; the server's record has the directory it runs in now.
cwd: target.server.data.session.get(target.id)?.location.directory ?? target.location?.directory,
title: defaultTitle(number),
env: {},
})
.catch((error) => {
dispatchFocus({ type: "pending.failed", request })
// A server without the persistent terminal routes answers with a status its API never declares for them.
const outdated =
error instanceof ClientError &&
error.reason === "UnexpectedStatus" &&
Predicate.hasProperty(error.cause, "status") &&
(error.cause.status === 404 || error.cause.status === 405)
input.toast.show(
outdated
? { variant: "error", title: input.t("create.unsupported") }
: {
variant: "error",
title: input.t("create.failed"),
description: error instanceof Error ? error.message : String(error),
},
)
return undefined
})
if (!info) return undefined
void dispatch(target.server, { type: "terminal.created", sessionID: target.id, terminal: info })
await dispatch(target.server, { type: "session.refresh", sessionID: target.id })
selection(target).set({ id: info.id })
dispatchFocus({ type: "pending.resolved", request, id: info.id })
return info.id
return done.promise
},
/** Removes the terminal on the server; its tab leaves once the server lists it no longer. */
async close(id: string) {
const target = session()
await target.server.client.experimental.persistentPty
.remove({ ptyID: id })
.then(() => dispatch(target.server, { type: "session.refresh", sessionID: target.id }))
.catch((error) => console.error("Failed to close terminal", error))
close(id: string) {
dispatch(session().server, { type: "close.requested", sessionID: initial.id, ptyID: id })
},
/**
* Sets the terminal's title on the server; `null` clears it, so the shell's or the default title shows again.
* The current title, or clearing a terminal never renamed, sends nothing.
* The title already shown, or clearing a terminal never renamed, sends nothing.
*/
async rename(id: string, title: string | null) {
const target = session()
const previous = get(id)
if (!previous || title === previous.title || (title === null && previous.userTitle === null)) return
const replace = (terminal: PersistentPtyInfo) =>
void dispatch(target.server, { type: "terminal.replaced", sessionID: target.id, terminal })
// Only a new title is known before the server answers; a cleared one shows whatever the server falls back to.
if (title !== null) replace({ ...previous, title, userTitle: title })
await target.server.client.experimental.persistentPty
.update({ ptyID: id, title })
.then(replace)
.catch((error) => {
// A newer title from the server stays.
if (title !== null && get(id)?.userTitle === title) replace(previous)
input.toast.show(
isPtyFeatureUnsupportedError(error)
? { variant: "error", title: input.t("rename.unsupported") }
: {
variant: "error",
title: input.t("rename.failed"),
description: error instanceof Error ? error.message : String(error),
},
)
})
rename(id: string, title: string | null) {
dispatch(session().server, { type: "rename.requested", sessionID: initial.id, ptyID: id, title })
},
requestFocus(id: string) {
dispatchFocus({ type: "request.target", session: key, id, overlay: overlay() })
@@ -411,7 +581,7 @@ export function createTerminalModel(input: {
load(session: SessionRef) {
sessions.set(session.key, session)
watch(session.server)
void dispatch(session.server, { type: "session.loaded", sessionID: session.id })
dispatch(session.server, { type: "session.loaded", sessionID: session.id })
const existing = handles.get(session.key)
@@ -450,9 +620,11 @@ export function createTerminalModel(input: {
const server = removed[0]?.server
if (server) void dispatch(server, { type: "sessions.removed", sessionIDs: removed.map((session) => session.id) })
if (server) dispatch(server, { type: "sessions.removed", sessionIDs: removed.map((session) => session.id) })
},
dispose() {
// Ends the changes in flight first, so a pending new-terminal request resolves and gives up its focus request.
servers.forEach((entry) => dispatch(entry.ref, { type: "instance.stopped" }))
lifetime.abort()
watched.forEach((entry) => entry.stop())
watched.clear()
@@ -478,21 +650,12 @@ function inOverlay(node: EventTarget | null) {
return node instanceof Element && node.closest(OVERLAYS) !== null
}
/** Resolves once the server has created the session. */
function settled(session: SessionRef, owner: Owner | null) {
if (!session.pending) return Promise.resolve()
return new Promise<void>((resolve) =>
createRoot(
(dispose) =>
createKeyed(
() => !session.pending,
() => {
dispose()
resolve()
},
),
owner,
),
/** A server without the persistent terminal routes answers with a status its API never declares for them. */
function outdated(error: Error) {
return (
error instanceof ClientError &&
error.reason === "UnexpectedStatus" &&
Predicate.hasProperty(error.cause, "status") &&
(error.cause.status === 404 || error.cause.status === 405)
)
}
+3 -2
View File
@@ -207,7 +207,7 @@ export default function TerminalPanel(props: {
<div class="text-13-regular text-text-strong">{plugin.t("list.failed")}</div>
<div class="text-12-regular text-text-weak break-words">{view().error}</div>
</div>
<Button size="small" variant="outline" onClick={() => void terminal().retry()}>
<Button size="small" variant="outline" onClick={() => terminal().retry()}>
{plugin.t("common.retry")}
</Button>
</div>
@@ -242,7 +242,7 @@ export default function TerminalPanel(props: {
<TerminalTab
terminal={info}
session={terminal()}
rename={(title) => void terminal().rename(info.id, title)}
rename={(title) => terminal().rename(info.id, title)}
/>
)}
</For>
@@ -262,6 +262,7 @@ export default function TerminalPanel(props: {
<IconButton
icon={<Icon name="plus-small" size="large" />}
variant="ghost"
disabled={terminal().creating()}
onClick={() => void terminal().new()}
aria-label={plugin.t("command.new")}
/>
@@ -147,7 +147,7 @@ const setup: Plugin.Setup<typeof Terminal> = (ctx) => {
const target = current()
const id = target?.terminal.active()
if (target && id) void target.terminal.close(id)
if (target && id) target.terminal.close(id)
},
}),
)
@@ -215,8 +215,8 @@ const setup: Plugin.Setup<typeof Terminal> = (ctx) => {
label={label()}
title={info()?.title ?? ""}
// A tab shown for a list that failed has no terminal to rename yet.
rename={info() ? (title) => void terminal().rename(id, title) : undefined}
close={() => void terminal().close(id)}
rename={info() ? (title) => terminal().rename(id, title) : undefined}
close={() => terminal().close(id)}
/>
),
}
@@ -274,7 +274,7 @@ const setup: Plugin.Setup<typeof Terminal> = (ctx) => {
tabs.get(input.session.key)?.delete(input.tab.id)
if (terminal.get(input.tab.id)) void terminal.close(input.tab.id)
if (terminal.get(input.tab.id)) terminal.close(input.tab.id)
},
// Before the server lists the session's terminals, a restored tab is selected on trust; `active` honours the
// selection once it is listed.
+1 -1
View File
@@ -22,7 +22,7 @@ export function TerminalTab(props: {
const label = () => terminalTabLabel({ title: props.terminal.title, t: plugin.t })
const close = () => void props.session.close(props.terminal.id)
const close = () => props.session.close(props.terminal.id)
const focus = () => {
if (rename.editing()) return
+16 -2
View File
@@ -1,7 +1,21 @@
import type { UpdaterState } from "@opencode/plugin/gui/updater"
import type { WindowUpdaterState } from "./window-machine"
/** The one update action the settings row and the titlebar pill offer for a state. */
export function updaterAction(state: UpdaterState | undefined) {
/**
* The one update action the settings row and the titlebar pill offer for a state. The window's own check or install
* shows as running at once, before main reports it, so a second click has nothing to start.
*/
export function updaterAction(state: UpdaterState | undefined, windowState?: WindowUpdaterState) {
const action = mainAction(state)
if (windowState?.status === "checking" && action.run === "check") return { label: "action.checking" as const }
if (windowState?.status === "installing" && action.run === "install") return { label: "action.installing" as const }
return action
}
function mainAction(state: UpdaterState | undefined) {
if (!state) return { label: "action.checkNow" as const }
switch (state.status) {
+8 -7
View File
@@ -10,22 +10,23 @@ type Client = IpcClient<typeof Updater.spec>
type Context = SetupContext<typeof definition>
/** Restarts into the staged update. A beta build moving to stable confirms the installer download first. */
/**
* Restarts into the staged update; resolves once main took the install or refused it. A beta build moving to stable
* confirms the installer download first, and resolves once the dialog shows.
*/
export function install(ctx: Context, client: Client) {
const download = () => client.install().catch((cause: unknown) => requestFailed(ctx, cause))
const state = client.state()
if (state?.status !== "download-required") {
void download()
return
}
if (state?.status !== "download-required") return download()
ctx.dialogs.open(
(dialog) => <DialogStableDownload ctx={ctx} dialog={dialog} version={state.version} download={download} />,
{ replace: true },
)
return Promise.resolve()
}
export async function check(ctx: Context, client: Client) {
@@ -55,7 +56,7 @@ export async function check(ctx: Context, client: Client) {
description: ctx.t("dialog.ready.message", { version: state.version }),
persistent: true,
actions: [
{ label: ctx.t("dialog.restart"), variant: "primary", onClick: () => install(ctx, client) },
{ label: ctx.t("dialog.restart"), variant: "primary", onClick: () => void install(ctx, client) },
{ label: ctx.t("dialog.later"), variant: "secondary", onClick: "dismiss" },
],
})
+48 -8
View File
@@ -1,9 +1,16 @@
import { showToast } from "@opencode/ui/toast"
import { lazy, onCleanup, Suspense } from "solid-js"
import { createSignal, lazy, onCleanup, Suspense, untrack } from "solid-js"
import { Command, createKeyed, SettingsPage, TitlebarItem, type SetupContext, type Plugin } from "@opencode/plugin/gui"
import { onIdle } from "@opencode/plugin/gui/builtin"
import { Updater } from "@opencode/plugin/gui/updater"
import { updaterAction } from "./action"
import {
ACTION_CHUNK_TIMEOUT_MS,
initialWindowUpdater,
transitionWindowUpdater,
type WindowUpdaterEffect,
type WindowUpdaterEvent,
} from "./window-machine"
import { webUpdates } from "./web"
import {
initWhatsNew,
@@ -23,14 +30,47 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
const updater = ctx.main(Updater)
// Undefined until main's updater is up and has sent its state, and again while it is gone.
const state = () => updater.state()
const [windowState, setWindowState] = createSignal(initialWindowUpdater)
const act = (name: "check" | "install") => {
if (state()) return void import("./actions").then((module) => module[name](ctx, updater))
const dispatchWindow = (event: WindowUpdaterEvent) => {
const next = transitionWindowUpdater(untrack(windowState), event)
// Not loaded yet, or not active (failed, or not on this platform): nothing can check or install.
showToast({ title: ctx.t("common.requestFailed") })
if (next.state !== untrack(windowState)) setWindowState(next.state)
next.effects.forEach(runWindow)
}
const runWindow = (effect: WindowUpdaterEffect) => {
if (effect.type === "toast-failed")
return void showToast({ title: ctx.t("common.requestFailed"), description: effect.message })
const attempt = effect.attempt
const chunk = Promise.withResolvers<never>()
const timer = setTimeout(() => chunk.reject(new Error()), ACTION_CHUNK_TIMEOUT_MS)
void Promise.race([import("./actions"), chunk.promise])
.finally(() => clearTimeout(timer))
.then(
(module) => {
if (ctx.signal.aborted) return
return Promise.try(() => module[effect.action](ctx, updater)).finally(() =>
dispatchWindow({ type: "action.settled", attempt }),
)
},
(cause: unknown) =>
dispatchWindow({
type: "action.failed",
attempt,
message: cause instanceof Error && cause.message ? cause.message : undefined,
}),
)
}
onCleanup(() => dispatchWindow({ type: "disposed" }))
// Main missing (not loaded yet, failed, or not on this platform) answers that nothing can check or install.
const act = (name: "check" | "install") =>
dispatchWindow({ type: "action.requested", action: name, available: state() !== undefined })
const Section = lazy(() => import("./section"))
// Settings rows are small; load them while idle so settings opens without a blank row.
onCleanup(onIdle(() => void Section.preload()))
@@ -45,7 +85,7 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
return {
id: "update",
label: ctx.t("status.label"),
title: ctx.t(updaterAction(current).label),
title: ctx.t(updaterAction(current, windowState()).label),
busy: installing,
run: () => act("install"),
}
@@ -66,9 +106,9 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
render: () => (
<Suspense>
<Section
action={() => updaterAction(state())}
action={() => updaterAction(state(), windowState())}
run={() => {
const run = updaterAction(state()).run
const run = updaterAction(state(), windowState()).run
if (run) act(run)
}}
+27 -14
View File
@@ -1,4 +1,4 @@
import { initialWebUpdate, transitionWebUpdate } from "./web-machine"
import { CHECK_TIMEOUT_MS, initialWebUpdate, transitionWebUpdate } from "./web-machine"
import type { WebUpdateEffect, WebUpdateEvent, WebUpdateState, WebWorker } from "./web-machine"
/** How often an open, visible window asks the server for a newer build. */
@@ -10,7 +10,12 @@ const RESET_CHANNEL = "opencode.updater.reset"
/** How long `applying` waits for the new worker to take over before reloading anyway. */
const APPLY_TIMEOUT_MS = 10_000
type Current = { state: WebUpdateState; registration?: ServiceWorkerRegistration; disposed: boolean }
type Current = {
state: WebUpdateState
registration?: ServiceWorkerRegistration
/** The deadlines of the check in flight and of the takeover. */
timers: { check?: ReturnType<typeof setTimeout>; apply?: ReturnType<typeof setTimeout> }
}
export type WebUpdateReport =
| { readonly result: "up-to-date" }
@@ -36,7 +41,7 @@ export function startWebUpdater(options: WebUpdaterOptions) {
// The app's windows share one saved copy, so a reset in one tells the others to reload.
const channel = container && "BroadcastChannel" in window ? new BroadcastChannel(RESET_CHANNEL) : undefined
const current: Current = { state: initialWebUpdate, disposed: false }
const current: Current = { state: initialWebUpdate, timers: {} }
const queue: WebUpdateEvent[] = []
@@ -44,7 +49,6 @@ export function startWebUpdater(options: WebUpdaterOptions) {
// An event dispatched while another applies (a listener that fires synchronously) waits its turn.
const dispatch = (event: WebUpdateEvent) => {
if (current.disposed) return
queue.push(event)
if (queue.length > 1) return
@@ -58,7 +62,9 @@ export function startWebUpdater(options: WebUpdaterOptions) {
if (step.state !== current.state) {
current.state = step.state
options.changed(step.state)
// The window that owns the signal is going away.
if (step.state.status !== "disposed") options.changed(step.state)
}
step.effects.forEach(perform)
@@ -80,17 +86,22 @@ export function startWebUpdater(options: WebUpdaterOptions) {
if (effect.type === "update") {
const attempt = effect.attempt
return void registration.update().then(
() => dispatch({ type: "check.settled", attempt, worker: newest(registration) }),
// The browser rejects with a TypeError or DOMException, both Errors.
(error: Error) => dispatch({ type: "check.failed", attempt, message: error.message }),
)
const timer = setTimeout(() => dispatch({ type: "check.timeout", attempt }), CHECK_TIMEOUT_MS)
current.timers.check = timer
return void registration
.update()
.then(
() => dispatch({ type: "check.settled", attempt, worker: newest(registration) }),
// The browser rejects with a TypeError or DOMException, both Errors.
(error: Error) => dispatch({ type: "check.failed", attempt, message: error.message }),
)
.finally(() => clearTimeout(timer))
}
// Workbox's generated worker skips waiting when it receives this message.
registration.waiting?.postMessage({ type: "SKIP_WAITING" })
const timer = setTimeout(() => dispatch({ type: "apply.timeout" }), APPLY_TIMEOUT_MS)
cleanup.push(() => clearTimeout(timer))
current.timers.apply = setTimeout(() => dispatch({ type: "apply.timeout" }), APPLY_TIMEOUT_MS)
}
const check = (manual: boolean) => dispatch({ type: "check.requested", manual, at: Date.now() })
@@ -119,7 +130,7 @@ export function startWebUpdater(options: WebUpdaterOptions) {
})
void container.ready.then((registration) => {
if (current.disposed) return
if (current.state.status === "disposed") return
current.registration = registration
listen(registration, "updatefound", () => {
@@ -158,7 +169,9 @@ export function startWebUpdater(options: WebUpdaterOptions) {
/** Drops the saved copy of the app and loads the server's current build. */
reset: () => dispatch({ type: "reset.requested" }),
dispose: () => {
current.disposed = true
dispatch({ type: "disposed" })
clearTimeout(current.timers.check)
clearTimeout(current.timers.apply)
cleanup.splice(0).forEach((run) => run())
},
}
@@ -156,6 +156,49 @@ describe("web update machine", () => {
expect(result.effects).toEqual([{ type: "announce" }, { type: "reset" }])
})
// A check whose request never settled used to hold `checking` for the tab's life, so every later check, the user's
// included, joined it and Settings › Updates stayed on "Checking…".
test("a check the browser never answers times out, and the next check runs", () => {
const quiet = replay([
{ type: "registered", worker: "none" },
auto(0),
{ type: "check.timeout", attempt: 1 },
manual(1),
])
expect(quiet.state).toMatchObject({ status: "checking", attempt: 2, manual: true })
expect(quiet.effects).toEqual([
{ type: "update", attempt: 1 },
{ type: "update", attempt: 2 },
])
// The user's check, or an automatic one the user joined, says it failed; a late answer changes nothing.
const asked = replay([
{ type: "registered", worker: "none" },
auto(0),
manual(1),
{ type: "check.timeout", attempt: 1 },
{ type: "check.settled", attempt: 1, worker: "none" },
])
expect(asked.state.status).toBe("error")
expect(asked.effects.at(-1)).toEqual({ type: "report", result: "failed", message: "" })
})
test("nothing runs after the updater is disposed", () => {
const result = replay([
{ type: "registered", worker: "none" },
auto(0),
{ type: "disposed" },
{ type: "check.settled", attempt: 1, worker: "waiting" },
{ type: "controller.changed" },
manual(1),
])
expect(result.state).toEqual({ status: "disposed" })
expect(result.effects).toEqual([{ type: "update", attempt: 1 }])
})
test("only the reload action runs while a build is ready; checking again offers it again", () => {
const result = replay([{ type: "registered", worker: "waiting" }, auto(0), manual(0)])
@@ -24,6 +24,7 @@ type Meta = {
* - `applying`: the newer build was asked to take over; every window reloads once it does.
* - `reloading`: this window is reloading into the newer build (or a reset).
* - `error`: the user's last check or download failed.
* - `disposed`: the window let the updater go; nothing runs after it.
*/
export type WebUpdateState =
| { readonly status: "unavailable" }
@@ -35,12 +36,15 @@ export type WebUpdateState =
| (Meta & { readonly status: "applying" })
| { readonly status: "reloading" }
| (Meta & { readonly status: "error"; readonly message: string })
| { readonly status: "disposed" }
export type WebUpdateEvent =
| { readonly type: "registered"; readonly worker: WebWorker }
| { readonly type: "check.requested"; readonly manual: boolean; readonly at: number }
| { readonly type: "check.settled"; readonly attempt: number; readonly worker: WebWorker }
| { readonly type: "check.failed"; readonly attempt: number; readonly message: string }
/** The browser's update request ran past `CHECK_TIMEOUT_MS` without settling. */
| { readonly type: "check.timeout"; readonly attempt: number }
| { readonly type: "worker.installing" }
| { readonly type: "worker.installed" }
| { readonly type: "worker.redundant" }
@@ -50,9 +54,10 @@ export type WebUpdateEvent =
/** Another window dropped the saved app, including the files this window still loads. */
| { readonly type: "reset.elsewhere" }
| { readonly type: "controller.changed" }
| { readonly type: "disposed" }
export type WebUpdateEffect =
/** Ask the server for a newer service worker. */
/** Ask the server for a newer service worker; `check.timeout` answers once `CHECK_TIMEOUT_MS` passes. */
| { readonly type: "update"; readonly attempt: number }
/** Tell the waiting worker to take over now. */
| { readonly type: "skip-waiting" }
@@ -70,9 +75,19 @@ export type WebUpdateTransition = { readonly state: WebUpdateState; readonly eff
/** Automatic checks (focus, network, timer) closer together than this ask once. A user's check always runs. */
export const AUTO_CHECK_SPACING_MS = 60_000
/**
* How long a check waits for the browser's update request, as the desktop updater does. The service worker's request
* has no deadline of its own, and a check in flight holds every later one.
*/
export const CHECK_TIMEOUT_MS = 60_000
export const initialWebUpdate: WebUpdateState = { status: "unavailable" }
export function transitionWebUpdate(state: WebUpdateState, event: WebUpdateEvent): WebUpdateTransition {
if (state.status === "disposed") return unchanged(state)
if (event.type === "disposed") return unchanged({ status: "disposed" })
if (state.status === "reloading") return unchanged(state)
// Another window applied the update or dropped the saved app, or this one applied it: the old build's files are
@@ -98,15 +113,19 @@ export function transitionWebUpdate(state: WebUpdateState, event: WebUpdateEvent
return found(meta, event.worker, state.manual)
case "check.failed":
case "check.timeout": {
if (state.status !== "checking" || event.attempt !== state.attempt) return unchanged(state)
// An automatic check fails whenever the device is offline; only the user's own check says so.
if (!state.manual) return unchanged({ ...meta, status: "idle" })
const message = event.type === "check.failed" ? event.message : ""
return {
state: { ...meta, status: "error", message: event.message },
effects: [{ type: "report", result: "failed", message: event.message }],
state: { ...meta, status: "error", message },
effects: [{ type: "report", result: "failed", message }],
}
}
case "worker.installing":
if (state.status === "downloading" || state.status === "ready" || state.status === "applying")
return unchanged(state)
@@ -153,6 +172,7 @@ export function webUpdateAction(state: WebUpdateState) {
case "reloading":
return { label: "action.reloading" as const }
case "unavailable":
case "disposed":
return { label: "action.checkNow" as const }
default:
return { label: "action.checkNow" as const, run: "check" as const }
@@ -165,7 +185,7 @@ export function webUpdatePending(state: WebUpdateState) {
}
function checkRequested(
state: Exclude<WebUpdateState, { status: "unavailable" | "reloading" }>,
state: Exclude<WebUpdateState, { status: "unavailable" | "reloading" | "disposed" }>,
event: Extract<WebUpdateEvent, { type: "check.requested" }>,
): WebUpdateTransition {
if (state.status === "checking" || state.status === "downloading")
@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test"
import { updaterAction } from "./action"
import {
initialWindowUpdater,
transitionWindowUpdater,
type WindowUpdaterEffect,
type WindowUpdaterEvent,
} from "./window-machine"
function replay(events: readonly WindowUpdaterEvent[]) {
const effects: WindowUpdaterEffect[] = []
const state = events.reduce((current, event) => {
const step = transitionWindowUpdater(current, event)
effects.push(...step.effects)
return step.state
}, initialWindowUpdater)
return { state, effects }
}
const ask = (action: "check" | "install", available = true): WindowUpdaterEvent => ({
type: "action.requested",
action,
available,
})
describe("window updater machine", () => {
// Checking from the menu and then Settings before main answered used to show two "Checking…" toasts and two results.
test("a second check joins the one in flight, and the next check after it runs", () => {
const result = replay([ask("check"), ask("check"), { type: "action.settled", attempt: 1 }, ask("check")])
expect(result.effects).toEqual([
{ type: "run", action: "check", attempt: 1 },
{ type: "run", action: "check", attempt: 2 },
])
expect(result.state).toEqual({ status: "checking", attempt: 2 })
})
test("Settings shows the window's check as running before main reports it", () => {
const checking = replay([ask("check")]).state
expect(updaterAction({ status: "idle" }, checking)).toEqual({ label: "action.checking" })
expect(updaterAction({ status: "idle" }, initialWindowUpdater)).toEqual({ label: "action.checkNow", run: "check" })
})
test("an install asked for during a check takes over, and the check's answer settles nothing", () => {
const result = replay([
ask("check"),
ask("install"),
{ type: "action.settled", attempt: 1 },
ask("install"),
ask("check"),
])
expect(result.effects).toEqual([
{ type: "run", action: "check", attempt: 1 },
{ type: "run", action: "install", attempt: 2 },
])
expect(result.state).toEqual({ status: "installing", attempt: 2 })
})
// A chunk that failed to load used to leave the click unanswered, with an unhandled rejection.
test("an actions chunk that fails or stalls says so and lets the user try again", () => {
const result = replay([ask("check"), { type: "action.failed", attempt: 1, message: "chunk" }, ask("check")])
expect(result.effects).toEqual([
{ type: "run", action: "check", attempt: 1 },
{ type: "toast-failed", message: "chunk" },
{ type: "run", action: "check", attempt: 2 },
])
})
test("without main's updater a request answers that it failed, unless one already runs", () => {
expect(replay([ask("check", false)]).effects).toEqual([{ type: "toast-failed" }])
expect(replay([ask("check"), ask("check", false)]).effects).toEqual([{ type: "run", action: "check", attempt: 1 }])
})
test("nothing runs after the window lets the updater go", () => {
const result = replay([ask("check"), { type: "disposed" }, { type: "action.failed", attempt: 1 }, ask("check")])
expect(result.state).toEqual({ status: "disposed" })
expect(result.effects).toEqual([{ type: "run", action: "check", attempt: 1 }])
})
})
@@ -0,0 +1,73 @@
/** How long loading the actions chunk may take, so a missing or stalled chunk never holds the window's update actions. */
export const ACTION_CHUNK_TIMEOUT_MS = 15_000
/**
* The update action this window asked for. Main's updater state says what the app is doing; this says what the window
* is doing about it, so a second request joins the first instead of showing a second "Checking…" toast and result.
* - `idle`: nothing asked; `issued` is the last attempt handed out.
* - `checking`: a check runs, with its toast, until it reports what it found.
* - `installing`: the install is being handed to main, or its download confirmed.
* - `disposed`: the window let the updater go.
*/
export type WindowUpdaterState =
| { readonly status: "idle"; readonly issued: number }
| { readonly status: "checking"; readonly attempt: number }
| { readonly status: "installing"; readonly attempt: number }
| { readonly status: "disposed" }
export type WindowUpdaterEvent =
/** The user asked from the menu, a command, Settings or the titlebar. `available` is false while main is missing. */
| { readonly type: "action.requested"; readonly action: "check" | "install"; readonly available: boolean }
/** The action finished and answered the user. */
| { readonly type: "action.settled"; readonly attempt: number }
/** The actions chunk failed to load or ran out of time, so the action never started. */
| { readonly type: "action.failed"; readonly attempt: number; readonly message?: string }
| { readonly type: "disposed" }
export type WindowUpdaterEffect =
/** Loads the actions chunk under `ACTION_CHUNK_TIMEOUT_MS` and runs the action. */
| { readonly type: "run"; readonly action: "check" | "install"; readonly attempt: number }
| { readonly type: "toast-failed"; readonly message?: string }
export type WindowUpdaterTransition = {
readonly state: WindowUpdaterState
readonly effects: readonly WindowUpdaterEffect[]
}
export const initialWindowUpdater: WindowUpdaterState = { status: "idle", issued: 0 }
export function transitionWindowUpdater(state: WindowUpdaterState, event: WindowUpdaterEvent): WindowUpdaterTransition {
if (state.status === "disposed") return unchanged(state)
if (event.type === "disposed") return unchanged({ status: "disposed" })
if (event.type === "action.requested") {
if (state.status === "installing") return unchanged(state)
// Not loaded yet, or not active (failed, or not on this platform): nothing can check or install.
if (!event.available)
return state.status === "idle" ? unchanged(state, [{ type: "toast-failed" }]) : unchanged(state)
if (state.status === "checking" && event.action === "check") return unchanged(state)
// An install asked for during a check takes over; the check's answer no longer settles anything.
const attempt = (state.status === "idle" ? state.issued : state.attempt) + 1
return {
state: event.action === "check" ? { status: "checking", attempt } : { status: "installing", attempt },
effects: [{ type: "run", action: event.action, attempt }],
}
}
if (state.status === "idle" || event.attempt !== state.attempt) return unchanged(state)
if (event.type === "action.settled") return unchanged({ status: "idle", issued: state.attempt })
return {
state: { status: "idle", issued: state.attempt },
effects: [{ type: "toast-failed", message: event.message }],
}
}
function unchanged(state: WindowUpdaterState, effects: readonly WindowUpdaterEffect[] = []): WindowUpdaterTransition {
return { state, effects }
}
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import { initialWslAdd, transitionWslAdd, type WslAddEffect, type WslAddEvent, type WslAddState } from "./add-machine"
type Module = { readonly name: string }
const replay = (events: readonly WslAddEvent<Module>[]) =>
events.reduce<{ state: WslAddState; effects: WslAddEffect<Module>[] }>(
(last, event) => {
const next = transitionWslAdd(last.state, event)
return { state: next.state, effects: [...last.effects, ...next.effects] }
},
{ state: initialWslAdd, effects: [] },
)
const module: Module = { name: "dialog" }
describe("wsl add dialog machine", () => {
// A double click used to load the chunk twice and open two dialogs; a failed chunk left the click unanswered.
test("a second click joins the dialog loading, and a failed load says so and lets the next click retry", () => {
const result = replay([
{ type: "add.requested" },
{ type: "add.requested" },
{ type: "add.failed", attempt: 1, error: "chunk" },
{ type: "add.loaded", attempt: 1, module },
{ type: "add.requested" },
{ type: "add.loaded", attempt: 2, module },
])
expect(result.effects).toEqual([
{ type: "load", attempt: 1 },
{ type: "toast-failed", error: "chunk" },
{ type: "load", attempt: 2 },
{ type: "open", module },
])
})
test("a chunk that loads after the plugin went away opens nothing", () => {
const result = replay([{ type: "add.requested" }, { type: "disposed" }, { type: "add.loaded", attempt: 1, module }])
expect(result.state).toEqual({ status: "disposed" })
expect(result.effects).toEqual([{ type: "load", attempt: 1 }])
})
})
@@ -0,0 +1,56 @@
// The "Add WSL server" dialog, whose chunk loads on the first click. Each load takes the next attempt number, so a load
// the plugin's cleanup overtook never opens its dialog.
/** How long the dialog's chunk may take to load before the click fails. */
export const WSL_DIALOG_LOAD_TIMEOUT_MS = 15_000
/**
* - `idle`: no dialog loading; `issued` is the last attempt handed out.
* - `loading`: the chunk loads; a second click joins it.
* - `disposed`: the plugin instance went away.
*/
export type WslAddState =
| { readonly status: "idle"; readonly issued: number }
| { readonly status: "loading"; readonly attempt: number }
| { readonly status: "disposed" }
export type WslAddEvent<M> =
| { readonly type: "add.requested" }
| { readonly type: "add.loaded"; readonly attempt: number; readonly module: M }
/** The chunk failed to load or ran out of time. */
| { readonly type: "add.failed"; readonly attempt: number; readonly error: string }
| { readonly type: "disposed" }
export type WslAddEffect<M> =
| { readonly type: "load"; readonly attempt: number }
| { readonly type: "open"; readonly module: M }
| { readonly type: "toast-failed"; readonly error: string }
export type WslAddTransition<M> = { readonly state: WslAddState; readonly effects: readonly WslAddEffect<M>[] }
export const initialWslAdd: WslAddState = { status: "idle", issued: 0 }
export function transitionWslAdd<M>(state: WslAddState, event: WslAddEvent<M>): WslAddTransition<M> {
if (state.status === "disposed") return unchanged(state)
if (event.type === "disposed") return unchanged({ status: "disposed" })
if (event.type === "add.requested") {
// A second click joins the dialog already loading.
if (state.status === "loading") return unchanged(state)
const attempt = state.issued + 1
return { state: { status: "loading", attempt }, effects: [{ type: "load", attempt }] }
}
if (state.status !== "loading" || event.attempt !== state.attempt) return unchanged(state)
if (event.type === "add.loaded")
return { state: { status: "idle", issued: state.attempt }, effects: [{ type: "open", module: event.module }] }
return { state: { status: "idle", issued: state.attempt }, effects: [{ type: "toast-failed", error: event.error }] }
}
function unchanged<M>(state: WslAddState): WslAddTransition<M> {
return { state, effects: [] }
}
+51 -8
View File
@@ -1,13 +1,22 @@
import { showToast } from "@opencode/ui/toast"
import { lazy, onCleanup, Suspense } from "solid-js"
import { createSignal, lazy, onCleanup, Suspense, untrack } from "solid-js"
import { MenuItem, Server, Style, type ServerEntry, type ServerState, type Plugin } from "@opencode/plugin/gui"
import { onIdle } from "@opencode/plugin/gui/builtin"
import {
initialWslAdd,
transitionWslAdd,
WSL_DIALOG_LOAD_TIMEOUT_MS,
type WslAddEffect,
type WslAddEvent,
} from "./add-machine"
import { Wsl, type WslServerItem } from "./contract"
import { wslRowView } from "./row-view"
import type definition from "./index"
const loadDialog = () => import("./dialog")
type DialogModule = Awaited<ReturnType<typeof loadDialog>>
const setup: Plugin.Setup<typeof definition> = (ctx) => {
if (ctx.app.platform !== "desktop") return
const ipc = ctx.use(Wsl)
@@ -46,14 +55,48 @@ const setup: Plugin.Setup<typeof definition> = (ctx) => {
const byKey = (key: string) => state()?.servers.find((item) => `wsl:${item.config.distro}` === key)
const add = () =>
void loadDialog().then((module) => {
if (ctx.signal.aborted) return
const [addState, setAddState] = createSignal(initialWslAdd)
if (!styled.added) ctx.add(Style, module.css)
styled.added = true
dialog.open(() => <module.DialogAddWslServer api={client()} state={state()} />)
})
const dispatchAdd = (event: WslAddEvent<DialogModule>) => {
const next = transitionWslAdd(untrack(addState), event)
if (next.state !== untrack(addState)) setAddState(next.state)
next.effects.forEach(runAdd)
}
const runAdd = (effect: WslAddEffect<DialogModule>) => {
if (effect.type === "toast-failed")
return void showToast({
variant: "error",
title: ctx.t("common.requestFailed"),
description: effect.error || undefined,
})
if (effect.type === "load") {
const attempt = effect.attempt
const deadline = Promise.withResolvers<never>()
const timer = setTimeout(() => deadline.reject(new Error()), WSL_DIALOG_LOAD_TIMEOUT_MS)
// A chunk that fails or never loads, such as after an update replaced the app's files, answers the click.
return void Promise.race([loadDialog(), deadline.promise])
.finally(() => clearTimeout(timer))
.then(
(module) => dispatchAdd({ type: "add.loaded", attempt, module }),
(cause: unknown) =>
dispatchAdd({ type: "add.failed", attempt, error: cause instanceof Error ? cause.message : String(cause) }),
)
}
const module = effect.module
if (!styled.added) ctx.add(Style, module.css)
styled.added = true
dialog.open(() => <module.DialogAddWslServer api={client()} state={state()} />)
}
onCleanup(() => dispatchAdd({ type: "disposed" }))
const add = () => dispatchAdd({ type: "add.requested" })
const entry = (item: WslServerItem): ServerEntry => {
const runtime = item.runtime