diff --git a/packages/tui/src/context/client.tsx b/packages/tui/src/context/client.tsx index cedd8b92118e..b12fcf8b5231 100644 --- a/packages/tui/src/context/client.tsx +++ b/packages/tui/src/context/client.tsx @@ -43,6 +43,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( }) return { + // Freeze startup identity even if managed-service reconnect changes the transport URL. + server: serverIdentity(props.url ?? "http://localhost", service !== undefined), get api() { return api }, @@ -59,3 +61,14 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext( } }, }) + +export function serverIdentity(url: string, managed = false) { + if (managed) return "local" + const value = new URL(url) + value.username = "" + value.password = "" + value.search = "" + value.hash = "" + value.pathname = value.pathname.replace(/\/+$/, "") || "/" + return value.toString() +} diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index fe816abcf96f..c75bec4f2deb 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -36,8 +36,10 @@ type TabsState = { } type PersistedState = { - global: TabsState - cwd: Record + servers?: Record }> + // The managed local server continues using the legacy fields. + global?: TabsState + cwd?: Record } type ScrollAnchor = { @@ -69,10 +71,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp // Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of // mutating in place, which per-row animations and drag state depend on. const [store, updateStore] = storage.store("tabs", { - initial: { - global: empty(), - cwd: {}, - }, + initial: { servers: {} }, key: "sessionID", }) const [preview, updatePreview] = createStore<{ global?: string; cwd?: string }>({}) @@ -102,8 +101,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }) function state() { - if (config.tabs.scope === "cwd") return store.cwd[paths.cwd] ?? fallback - return store.global + const server = client.server === "local" ? store : store.servers?.[client.server] + if (!server) return fallback + if (config.tabs.scope === "cwd") return server.cwd?.[paths.cwd] ?? fallback + return server.global ?? fallback } const previewID = () => preview[config.tabs.scope] @@ -111,7 +112,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp function update(mutation: (draft: TabsState) => void) { const scope = config.tabs.scope - void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch( + void updateStore((draft) => { + const server = + client.server === "local" + ? draft + : ((draft.servers ??= {})[client.server] ??= { global: empty(), cwd: {} }) + server.cwd ??= {} + mutation(scope === "cwd" ? (server.cwd[paths.cwd] ??= empty()) : (server.global ??= empty())) + }).catch( // Failed writes lose only tab layout, but silence would hide tabs resetting every launch. (error) => console.error("Failed to persist session tabs", error), ) diff --git a/packages/tui/src/context/session-terminals.tsx b/packages/tui/src/context/session-terminals.tsx index bd81afa2005d..d2bee7042241 100644 --- a/packages/tui/src/context/session-terminals.tsx +++ b/packages/tui/src/context/session-terminals.tsx @@ -8,7 +8,9 @@ import { useEvent } from "./event" import { useStorage } from "./storage" type SessionTerminalsState = { - sessions: Record + servers?: Record }> + // The managed local server continues using the legacy fields. + sessions?: Record } export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = createSimpleContext({ @@ -21,21 +23,26 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = const [focus, setFocus] = createSignal() const storage = useStorage() const [store, update] = storage.store("session-terminal-selection", { - initial: { sessions: {} }, - }) - const [terminals, updateTerminals] = storage.memory>("session-terminals", { - initial: {}, + initial: { servers: {} }, }) + const [terminals, updateTerminals] = storage.memory>( + `session-terminals:${client.server}`, + { + initial: {}, + }, + ) + const selected = () => (client.server === "local" ? store.sessions : store.servers?.[client.server]?.sessions) const refresh = async (sessionID: string) => { if (!terminals[sessionID]) updateTerminals((draft) => (draft[sessionID] = [])) const result = await client.api.experimental.persistentPty.list({ sessionID }) updateTerminals((draft) => (draft[sessionID] = result)) - const selected = store.sessions[sessionID] - if (!selected || result.some((terminal) => terminal.id === selected)) return + const current = selected()?.[sessionID] + if (!current || result.some((terminal) => terminal.id === current)) return await update((draft) => { - if (draft.sessions[sessionID] !== selected) return - draft.sessions[sessionID] = null + const sessions = client.server === "local" ? draft.sessions : draft.servers?.[client.server]?.sessions + if (!sessions || sessions[sessionID] !== current) return + sessions[sessionID] = null }) } @@ -43,7 +50,14 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = if (ptyID !== null && !terminals[sessionID]?.some((terminal) => terminal.id === ptyID)) return setFocus(ptyID ?? undefined) await update((draft) => { - draft.sessions[sessionID] = ptyID + if (client.server === "local") { + draft.sessions ??= {} + draft.sessions[sessionID] = ptyID + return + } + draft.servers ??= {} + const server = (draft.servers[client.server] ??= { sessions: {} }) + server.sessions[sessionID] = ptyID }) } @@ -70,7 +84,7 @@ export const { use: useSessionTerminals, provider: SessionTerminalsProvider } = get(sessionID: string) { return { terminals: terminals[sessionID] ?? [], - selectedTerminalID: store.sessions[sessionID] ?? null, + selectedTerminalID: selected()?.[sessionID] ?? null, } }, refresh, diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 97a20cd6807c..9d635b8cff60 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -5,7 +5,7 @@ import { testRender } from "@opentui/solid" import { mkdirSync, watch } from "fs" import path from "path" import { ConfigProvider, useConfig } from "../../src/config" -import { ClientProvider, useClient } from "../../src/context/client" +import { ClientProvider, serverIdentity, useClient } from "../../src/context/client" import { DataProvider, useData } from "../../src/context/data" import { LocationProvider } from "../../src/context/location" import { RouteProvider, useRoute } from "../../src/context/route" @@ -43,6 +43,9 @@ async function renderSessionTabs( tabsEnabled?: boolean viewFailures?: number experimental?: Record + server?: string + managed?: boolean + scope?: "global" | "cwd" }, ) { const temporary = options?.state ? undefined : await tmpdir() @@ -53,11 +56,15 @@ async function renderSessionTabs( await Bun.write( file, JSON.stringify({ - global: { tabs: [], unread: { ses_legacy: "error" } }, - cwd: { - [directory]: { - tabs: options.persisted.map((sessionID) => ({ sessionID })), - unread: { ses_legacy: "activity" }, + servers: { + [serverIdentity(options.server ?? "http://localhost")]: { + global: { tabs: [], unread: { ses_legacy: "error" } }, + cwd: { + [directory]: { + tabs: options.persisted.map((sessionID) => ({ sessionID })), + unread: { ses_legacy: "activity" }, + }, + }, }, }, }), @@ -135,7 +142,7 @@ async function renderSessionTabs( let storage!: ReturnType let config!: ReturnType let configuration = { - tabs: { enabled: options?.tabsEnabled ?? true }, + tabs: { enabled: options?.tabsEnabled ?? true, scope: options?.scope ?? "cwd" }, experimental: options?.experimental, session: { new_location: options?.newLocation ?? "launch" }, } @@ -168,7 +175,18 @@ async function renderSessionTabs( - + ({ api: createApi(calls.fetch) }), + restart: async () => {}, + } + : undefined + } + > @@ -287,6 +305,7 @@ test("loads location metadata when an open session moves", async () => { const setup = await renderSessionTabs("first") try { + await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "first")) await wait(() => setup.locations.includes(directory) && setup.vcsLocations.includes(directory)) setup.emit({ id: "evt_moved", @@ -443,15 +462,179 @@ test("stores preview tab membership without persisting preview identity", async await setup.flush() const stored = await Bun.file(path.join(setup.state, "test", "tui", "tabs.json")).json() - expect(stored.cwd[directory].tabs).toHaveLength(1) - expect(stored.cwd[directory].tabs[0].sessionID).toBe("preview") - expect(stored.cwd[directory].tabs[0]).not.toHaveProperty("preview") + const state = stored.servers[serverIdentity("http://localhost")] + expect(state.cwd[directory].tabs).toHaveLength(1) + expect(state.cwd[directory].tabs[0].sessionID).toBe("preview") + expect(state.cwd[directory].tabs[0]).not.toHaveProperty("preview") expect(await Bun.file(path.join(setup.state, "test", "tui", "session-tab-preview.json")).exists()).toBe(false) } finally { await setup.destroy() } }) +test("normalizes credential-free endpoint identities without conflating ports or paths", () => { + expect(serverIdentity("HTTPS://user:password@EXAMPLE.com:443/base///?token=secret#fragment")).toBe( + "https://example.com/base", + ) + expect(serverIdentity("http://example.com:80/")).toBe(serverIdentity("http://example.com")) + expect(serverIdentity("http://example.com:8080")).not.toBe(serverIdentity("http://example.com:8081")) + expect(serverIdentity("https://example.com/one")).not.toBe(serverIdentity("https://example.com/two")) +}) + +test("keeps explicit loopback endpoints from adopting or changing legacy local tabs", async () => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "tabs.json") + const legacy = { global: { tabs: [{ sessionID: "shared" }], unread: {} }, cwd: {} } + mkdirSync(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify(legacy)) + const setup = await renderSessionTabs("shared", { + state: temporary.path, + home: true, + server: "http://127.0.0.1:4096", + }) + try { + expect(setup.tabs.tabs()).toEqual([]) + await setup.flush() + expect(await Bun.file(file).json()).toEqual(legacy) + setup.route.navigate({ type: "session", sessionID: "shared" }) + await wait(() => setup.tabs.tabs().length === 1) + await setup.flush() + expect(await Bun.file(file).json()).toMatchObject(legacy) + } finally { + await setup.destroy() + } +}) + +test.each(["global", "cwd"] as const)( + "keeps legacy %s tabs readable and writable by managed local only", + async (scope) => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "tabs.json") + const tabs = { tabs: [{ sessionID: "shared" }], unread: {} } + mkdirSync(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify({ global: tabs, cwd: { [directory]: tabs } })) + const setup = await renderSessionTabs("shared", { + state: temporary.path, + home: true, + managed: true, + server: "http://127.0.0.1:54321", + scope, + }) + try { + await wait(() => setup.tabs.tabs().length === 1) + expect(setup.tabs.tabs()[0].sessionID).toBe("shared") + setup.tabs.close("shared") + await setup.flush() + const stored = await Bun.file(file).json() + expect((scope === "global" ? stored.global : stored.cwd[directory]).tabs).toEqual([]) + expect(stored.servers ?? {}).toEqual({}) + } finally { + await setup.destroy() + } + const restored = await renderSessionTabs("shared", { + state: temporary.path, + home: true, + managed: true, + server: "http://127.0.0.1:54322", + scope, + }) + try { + expect(restored.tabs.tabs()).toEqual([]) + } finally { + await restored.destroy() + } + }, +) + +test("uses stable managed local identity without treating explicit loopback as local", () => { + expect(serverIdentity("http://127.0.0.1:54321", true)).toBe("local") + expect(serverIdentity("http://localhost:54322", true)).toBe("local") + expect(serverIdentity("http://127.0.0.1:54321")).not.toBe("local") +}) + +test.each(["global", "cwd"] as const)( + "isolates concurrent server writes and restores %s tabs on remount", + async (scope) => { + await using temporary = await tmpdir() + const first = await renderSessionTabs("shared", { + state: temporary.path, + server: "https://first.example", + scope, + home: true, + }) + const second = await renderSessionTabs("shared", { + state: temporary.path, + server: "https://second.example", + scope, + home: true, + }) + try { + first.route.navigate({ type: "session", sessionID: "shared" }) + second.route.navigate({ type: "session", sessionID: "shared" }) + await wait(() => first.tabs.tabs().length === 1 && second.tabs.tabs().length === 1) + first.tabs.promote("first-only") + second.tabs.promote("second-only") + first.route.navigate({ type: "session", sessionID: "first-only" }) + second.route.navigate({ type: "session", sessionID: "second-only" }) + await wait(() => first.tabs.tabs().length === 2 && second.tabs.tabs().length === 2) + await Promise.all([first.flush(), second.flush()]) + } finally { + await first.destroy() + await second.destroy() + } + for (const name of ["first", "second"]) { + const restored = await renderSessionTabs("shared", { + state: temporary.path, + server: `https://${name}.example`, + scope, + home: true, + }) + try { + expect(restored.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["shared", `${name}-only`]) + expect(restored.tabs.current()).toBeUndefined() + restored.tabs.reopen() + expect(restored.tabs.current()).toBeUndefined() + } finally { + await restored.destroy() + } + } + }, +) + +test("keeps identical session IDs distinct across servers", async () => { + await using temporary = await tmpdir() + const first = await renderSessionTabs("shared", { + state: temporary.path, + server: "HTTPS://FIRST.example:443/", + persisted: ["shared"], + }) + try { + await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "shared")) + first.tabs.promote("only-first") + first.route.navigate({ type: "session", sessionID: "only-first" }) + await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "only-first")) + await first.flush() + } finally { + await first.destroy() + } + + const second = await renderSessionTabs("shared", { state: temporary.path, server: "https://second.example" }) + try { + await wait(() => second.tabs.tabs().some((tab) => tab.sessionID === "shared")) + expect(second.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["shared"]) + await second.flush() + const stored = await Bun.file(path.join(temporary.path, "test", "tui", "tabs.json")).json() + expect( + stored.servers["https://first.example/"].cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID), + ).toEqual(["shared", "only-first"]) + expect( + stored.servers["https://second.example/"].cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID), + ).toEqual(["shared"]) + } finally { + await second.destroy() + } +}) + test("unrelated user admissions do not pre-promote an unopened local session", async () => { const setup = await renderSessionTabs("remote", { home: true }) @@ -574,13 +757,16 @@ test("stores session tabs for the current working directory by default", async ( await wait(async () => { if (!(await Bun.file(file).exists())) return false const stored = await Bun.file(file).json() - return stored.cwd[directory]?.tabs.some((tab: { sessionID: string }) => tab.sessionID === "first") + return stored.servers[serverIdentity("http://localhost")].cwd[directory]?.tabs.some( + (tab: { sessionID: string }) => tab.sessionID === "first", + ) }) const stored = await Bun.file(file).json() - expect(stored.global).toEqual({ tabs: [], unread: {} }) - expect(Object.keys(stored.cwd)).toEqual([directory]) - expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"]) - expect(stored.cwd[directory].unread).toEqual({}) + const state = stored.servers[serverIdentity("http://localhost")] + expect(state.global).toEqual({ tabs: [], unread: {} }) + expect(Object.keys(state.cwd)).toEqual([directory]) + expect(state.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"]) + expect(state.cwd[directory].unread).toEqual({}) } finally { await setup.destroy() } @@ -690,7 +876,7 @@ test("empties legacy persisted unread records for rollback compatibility", async // Normalize rewrites the active scope; legacy values must not survive, but older clients require the field. await wait(async () => { const stored = await Bun.file(file).json() - return Object.keys(stored.cwd[directory].unread).length === 0 + return Object.keys(stored.servers[serverIdentity("http://localhost")].cwd[directory].unread).length === 0 }) } finally { await setup.destroy() @@ -879,7 +1065,10 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session await titled.data.session.sync("shared") await wait(async () => { if (!(await Bun.file(file).exists())) return false - return (await Bun.file(file).json()).cwd[directory]?.tabs[0]?.title === "Generated title" + return ( + (await Bun.file(file).json()).servers[serverIdentity("http://localhost")].cwd[directory]?.tabs[0]?.title === + "Generated title" + ) }) const observed = ["Generated title"] const pending = new Set>() @@ -888,7 +1077,7 @@ test("concurrent TUIs do not alternate shared tab titles from divergent session const read = Bun.file(file) .json() .then((value) => { - const title = value.cwd[directory]?.tabs[0]?.title + const title = value.servers[serverIdentity("http://localhost")].cwd[directory]?.tabs[0]?.title if (title && observed.at(-1) !== title) observed.push(title) }) .catch(() => undefined) @@ -933,7 +1122,7 @@ test("closing a tab is not undone by another TUI viewing the same session", asyn await second.flush() const stored = await Bun.file(path.join(temporary.path, "test", "tui", "tabs.json")).json() - expect(stored.cwd[directory].tabs).toEqual([]) + expect(stored.servers[serverIdentity("http://localhost")].cwd[directory].tabs).toEqual([]) second.route.navigate({ type: "home" }) await wait(() => second.route.data.type === "home", 2_000, "second client to navigate home") diff --git a/packages/tui/test/context/session-terminals.test.tsx b/packages/tui/test/context/session-terminals.test.tsx new file mode 100644 index 000000000000..85359f7679e2 --- /dev/null +++ b/packages/tui/test/context/session-terminals.test.tsx @@ -0,0 +1,184 @@ +/** @jsxImportSource @opentui/solid */ +import { expect, test } from "bun:test" +import { testRender } from "@opentui/solid" +import type { PersistentPtyInfo } from "@opencode/client" +import { mkdirSync } from "fs" +import path from "path" +import { ConfigProvider } from "../../src/config" +import { ClientProvider } from "../../src/context/client" +import { DataProvider } from "../../src/context/data" +import { SessionTerminalsProvider, useSessionTerminals } from "../../src/context/session-terminals" +import { StorageProvider, useStorage } from "../../src/context/storage" +import { TuiAppProvider } from "../../src/context/runtime" +import { createApi, createFetch, directory, json } from "../fixture/tui-client" +import { TestTuiContexts } from "../fixture/tui-environment" +import { tmpdir } from "../fixture/fixture" +import { createTuiResolvedConfig } from "../fixture/tui-runtime" + +test("explicit endpoints cannot adopt or change legacy local selections", async () => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "session-terminal-selection.json") + mkdirSync(path.dirname(file), { recursive: true }) + const legacy = { sessions: { shared: "pty_shared", untouched: "pty_legacy" } } + await Bun.write(file, JSON.stringify(legacy)) + await using app = await mounted(temporary.path, ["https://first.example", "https://second.example"]) + for (const server of app.servers) { + expect(server.terminals.get("shared").selectedTerminalID).toBeNull() + await server.terminals.refresh("shared") + } + await app.storage.flush() + expect(await Bun.file(file).json()).toEqual(legacy) + + await app.servers[0].terminals.selectTerminal("shared", "pty_shared") + expect(await Bun.file(file).json()).toEqual({ + ...legacy, + servers: { "https://first.example/": { sessions: { shared: "pty_shared" } } }, + }) + expect(app.servers[1].terminals.get("shared").selectedTerminalID).toBeNull() + expect(app.servers[0].terminals.get("untouched").selectedTerminalID).toBeNull() +}) + +test("managed local reads and writes legacy selections across endpoint changes", async () => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "session-terminal-selection.json") + mkdirSync(path.dirname(file), { recursive: true }) + await Bun.write(file, JSON.stringify({ sessions: { shared: "pty_shared", untouched: "pty_legacy" } })) + { + await using local = await mounted(temporary.path, ["http://127.0.0.1:54321"], true) + await using remote = await mounted(temporary.path, ["http://127.0.0.1:54321"]) + expect(local.servers[0].terminals.get("shared").selectedTerminalID).toBe("pty_shared") + expect(remote.servers[0].terminals.get("shared").selectedTerminalID).toBeNull() + await local.servers[0].terminals.selectTerminal("shared", null) + expect((await Bun.file(file).json()).sessions).toEqual({ shared: null, untouched: "pty_legacy" }) + await local.servers[0].terminals.refresh("shared") + await local.servers[0].terminals.selectTerminal("shared", "pty_shared") + } + await using restored = await mounted(temporary.path, ["http://127.0.0.1:54322"], true) + expect(restored.servers[0].terminals.get("shared").selectedTerminalID).toBe("pty_shared") + restored.servers[0].items.length = 0 + await restored.servers[0].terminals.refresh("shared") + expect((await Bun.file(file).json()).sessions).toEqual({ shared: null, untouched: "pty_legacy" }) +}) + +test("isolates terminal lists and writes selection, null, and missing-terminal clearing through to disk", async () => { + await using temporary = await tmpdir() + const file = path.join(temporary.path, "test", "tui", "session-terminal-selection.json") + await using app = await mounted(temporary.path, ["https://first.example", "https://second.example"]) + const first = app.servers[0] + const second = app.servers[1] + await first.terminals.refresh("shared") + expect(second.terminals.get("shared").terminals).toEqual([]) + await second.terminals.refresh("shared") + expect(first.terminals.get("shared").terminals[0].title).toBe("https://first.example") + expect(second.terminals.get("shared").terminals[0].title).toBe("https://second.example") + + await first.terminals.selectTerminal("shared", "pty_shared") + expect(first.terminals.get("shared").selectedTerminalID).toBe("pty_shared") + expect(second.terminals.get("shared").selectedTerminalID).toBeNull() + expect((await Bun.file(file).json()).servers).toEqual({ + "https://first.example/": { sessions: { shared: "pty_shared" } }, + }) + await second.terminals.selectTerminal("shared", "pty_shared") + await first.terminals.selectTerminal("shared", null) + expect((await Bun.file(file).json()).servers).toEqual({ + "https://first.example/": { sessions: { shared: null } }, + "https://second.example/": { sessions: { shared: "pty_shared" } }, + }) + await first.terminals.selectTerminal("shared", "pty_shared") + first.items.length = 0 + await first.terminals.refresh("shared") + expect(first.terminals.get("shared").selectedTerminalID).toBeNull() + expect(second.terminals.get("shared").selectedTerminalID).toBe("pty_shared") + expect(second.terminals.get("shared").terminals).toHaveLength(1) + expect((await Bun.file(file).json()).servers).toEqual({ + "https://first.example/": { sessions: { shared: null } }, + "https://second.example/": { sessions: { shared: "pty_shared" } }, + }) +}) + +test("merges concurrent writes from independent storage providers and restores them after remount", async () => { + await using temporary = await tmpdir() + { + await using first = await mounted(temporary.path, ["https://first.example"]) + await using second = await mounted(temporary.path, ["https://second.example"]) + await Promise.all([first.servers[0].terminals.refresh("shared"), second.servers[0].terminals.refresh("shared")]) + await Promise.all([ + first.servers[0].terminals.selectTerminal("shared", "pty_shared"), + second.servers[0].terminals.selectTerminal("shared", "pty_shared"), + ]) + } + await using restored = await mounted(temporary.path, ["HTTPS://FIRST.example:443/", "https://second.example"]) + for (const server of restored.servers) { + expect(server.terminals.get("shared").selectedTerminalID).toBe("pty_shared") + expect(server.terminals.get("shared").terminals).toEqual([]) + } +}) + +async function mounted(state: string, urls: string[], managed = false) { + const servers = urls.map((url) => { + const items: PersistentPtyInfo[] = [ + { + id: "pty_shared", + title: url, + command: "sh", + args: [], + cwd: directory, + status: "running", + pid: 123, + sessionID: "shared", + foregroundProcess: null, + size: { cols: 80, rows: 24 }, + output: { head: 0, tail: 0 }, + }, + ] + const calls = createFetch(async (request) => { + if (request.pathname === "/api/experimental/session/shared/terminal") return json({ data: items }) + return undefined + }) + return { url, items, api: createApi(calls.fetch), terminals: undefined! as ReturnType } + }) + let storage!: ReturnType + function Probe(props: { server: (typeof servers)[number] }) { + props.server.terminals = useSessionTerminals() + storage = useStorage() + return + } + const app = await testRender(() => ( + + + + + {servers.map((server) => ( + ({ api: server.api }), + restart: async () => {}, + } + : undefined + } + > + + + + + + + ))} + + + + + )) + return { + servers, + storage, + async [Symbol.asyncDispose]() { + app.renderer.destroy() + await storage.flush() + }, + } +}