feat(app): improve desktop multi-server support (#30678)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Luke Parker 2026-06-05 16:30:02 +10:00 committed by GitHub
commit 7f33576f46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 1523 additions and 841 deletions

View file

@ -3,6 +3,8 @@ import { createStore, reconcile, type SetStoreFunction, type Store } from "solid
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
import { createScopedCache } from "@/utils/scoped-cache"
import { uuid } from "@/utils/uuid"
import type { SelectedLineRange } from "@/context/file"
@ -166,11 +168,11 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
return createCommentSessionState(store, setStore)
}
function createCommentSession(dir: string, id: string | undefined) {
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "comments", [legacy]),
Persist.serverScoped(scope, dir, id, "comments", [legacy]),
createStore<CommentStore>({
comments: {},
}),
@ -200,11 +202,12 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false,
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
const cache = createScopedCache(
(key) => {
const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({
value: createCommentSession(decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
value: createCommentSession(serverSDK.scope, decoded.dir, decoded.id === WORKSPACE_KEY ? undefined : decoded.id),
dispose,
}))
},

View file

@ -276,7 +276,7 @@ export const createDirSyncContext = (
const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => {
if (sessionIDs.length === 0) return
clearSessionPrefetch(directory, sessionIDs)
clearSessionPrefetch(serverSDK.scope, directory, sessionIDs)
for (const sessionID of sessionIDs) {
serverSync.todo.set(sessionID, undefined)
}
@ -348,6 +348,7 @@ export const createDirSyncContext = (
setMeta("cursor", key, next.cursor)
setMeta("complete", key, next.complete)
setSessionPrefetch({
scope: serverSDK.scope,
directory: input.directory,
sessionID: input.sessionID,
limit: message.length,
@ -438,7 +439,7 @@ export const createDirSyncContext = (
touch(directory, setStore, sessionID)
const seeded = getSessionPrefetch(directory, sessionID)
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)
@ -449,10 +450,10 @@ export const createDirSyncContext = (
}
return runInflight(inflight, key, async () => {
const pending = getSessionPrefetchPromise(directory, sessionID)
const pending = getSessionPrefetchPromise(serverSDK.scope, directory, sessionID)
if (pending) {
await pending
const seeded = getSessionPrefetch(directory, sessionID)
const seeded = getSessionPrefetch(serverSDK.scope, directory, sessionID)
if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) {
batch(() => {
setMeta("limit", key, seeded.limit)

View file

@ -21,6 +21,8 @@ import {
touchFileContent,
} from "./file/content-cache"
import { createFileViewCache } from "./file/view-cache"
import { useServerSDK } from "./server-sdk"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { createFileTreeStore } from "./file/tree-store"
import { invalidateFromWatcher } from "./file/watcher"
import {
@ -56,12 +58,13 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const sdk = useSDK()
useSync()
const params = useParams()
const serverSDK = useServerSDK()
const language = useLanguage()
const layout = useLayout()
const scope = createMemo(() => sdk.directory)
const path = createPathHelpers(scope)
const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = layout.tabs(() => SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(params.dir, params.id)))
const inflight = new Map<string, Promise<void>>()
const [store, setStore] = createStore<{
@ -107,7 +110,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
const viewCache = createFileViewCache()
const viewCache = createFileViewCache(serverSDK.scope)
const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => {

View file

@ -3,6 +3,7 @@ import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { createScopedCache } from "@/utils/scoped-cache"
import type { FileViewState, SelectedLineRange } from "./types"
import type { ServerScope } from "@/utils/server-scope"
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
@ -33,11 +34,11 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
)
}
function createViewSession(dir: string, id: string | undefined) {
function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]),
createStore<{
file: Record<string, FileViewState>
}>({
@ -119,14 +120,14 @@ function createViewSession(dir: string, id: string | undefined) {
}
}
export function createFileViewCache() {
export function createFileViewCache(scope: ServerScope) {
const cache = createScopedCache(
(key) => {
const split = key.lastIndexOf("\n")
const dir = split >= 0 ? key.slice(0, split) : key
const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
return createRoot((dispose) => ({
value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
value: createViewSession(scope, dir, id === WORKSPACE_KEY ? undefined : id),
dispose,
}))
},

View file

@ -3,8 +3,9 @@ import { createStore } from "solid-js/store"
import { QueryClient } from "@tanstack/solid-query"
import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { bootstrapDirectory } from "./bootstrap"
import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap"
import type { State, VcsCache } from "./types"
import { ServerScope } from "@/utils/server-scope"
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@ -45,6 +46,7 @@ describe("bootstrapDirectory", () => {
await bootstrapDirectory({
directory: "/project",
scope: ServerScope.local,
mcp: false,
global: {
config: {} satisfies Config,
@ -89,3 +91,14 @@ describe("bootstrapDirectory", () => {
expect(mcpReads).toEqual([])
})
})
describe("query keys", () => {
test("partitions identical directories by server scope", () => {
const client = {} as OpencodeClient
const remote = "https://debian.example" as typeof ServerScope.local
expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"])
expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual(["https://debian.example", null, "providers"])
})
})

View file

@ -20,6 +20,7 @@ import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
import { loadMcpQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
type GlobalStore = {
ready: boolean
@ -59,8 +60,8 @@ function errors(list: PromiseSettledResult<unknown>[]) {
const providerRev = new Map<string, number>()
export function clearProviderRev(directory: string) {
providerRev.delete(directory)
export function clearProviderRev(scope: ServerScope, directory: string) {
providerRev.delete(ScopedKey.from(scope, directory))
}
function runAll(list: Array<() => Promise<unknown>>) {
@ -83,15 +84,15 @@ function showErrors(input: {
})
}
export const loadGlobalConfigQuery = (sdk: OpencodeClient) =>
export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({
queryKey: ["config"],
queryKey: [scope, "config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)),
})
export const loadProjectsQuery = (sdk: OpencodeClient) =>
export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) =>
queryOptions({
queryKey: ["project"],
queryKey: [scope, "project"],
queryFn: () =>
retry(() =>
sdk.project.list().then((x) => {
@ -106,6 +107,7 @@ export const loadProjectsQuery = (sdk: OpencodeClient) =>
export async function bootstrapGlobal(input: {
serverSDK: OpencodeClient
scope: ServerScope
requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
@ -113,12 +115,12 @@ export async function bootstrapGlobal(input: {
queryClient: QueryClient
}) {
const slow = [
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)),
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)),
() =>
input.queryClient
.fetchQuery(loadProjectsQuery(input.serverSDK))
.fetchQuery(loadProjectsQuery(input.scope, input.serverSDK))
.then((data) => input.setGlobalStore("project", data)),
]
await runAll(slow)
@ -178,26 +180,27 @@ function warmSessions(input: {
).then(() => undefined)
}
export const loadProvidersQuery = (directory: string | null, sdk: OpencodeClient) =>
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions({
queryKey: [directory, "providers"],
queryKey: [scope, directory, "providers"],
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))),
})
export const loadAgentsQuery = (directory: string | null, sdk: OpencodeClient) =>
export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions({
queryKey: [directory, "agents"],
queryKey: [scope, directory, "agents"],
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))),
})
export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) =>
export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) =>
queryOptions<Path>({
queryKey: [directory, "path"],
queryKey: [scope, directory, "path"],
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)),
})
export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
mcp: boolean
sdk: OpencodeClient
store: Store<State>
@ -223,14 +226,15 @@ export async function bootstrapDirectory(input: {
}
if (loading) input.setStore("status", "partial")
const rev = (providerRev.get(input.directory) ?? 0) + 1
providerRev.set(input.directory, rev)
const revKey = ScopedKey.from(input.scope, input.directory)
const rev = (providerRev.get(revKey) ?? 0) + 1
providerRev.set(revKey, rev)
;(async () => {
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
input.queryClient
.ensureQueryData(loadAgentsQuery(input.directory, input.sdk))
.ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk))
.then((data) => input.setStore("agent", data)),
() =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
@ -239,7 +243,7 @@ export async function bootstrapDirectory(input: {
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
!seededPath &&
(() =>
input.queryClient.ensureQueryData(loadPathQuery(input.directory, input.sdk)).then((data) => {
input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => {
const next = projectID(data.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
})),
@ -305,9 +309,9 @@ export async function bootstrapDirectory(input: {
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.directory, input.sdk))),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.directory, input.sdk)).catch((err) => {
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory)
showToast({
variant: "error",

View file

@ -4,9 +4,16 @@ import { createStore } from "solid-js/store"
import type { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { State } from "./types"
import type { QueryOptionsApi } from "../server-sync"
import { ServerScope } from "@/utils/server-scope"
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
store[0],
store[1],
null,
Object.assign(() => true, { promise: undefined }),
]
const child = () => createStore({} as State)
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
@ -42,12 +49,6 @@ function createOwner(callback: (owner: Owner) => void) {
}
beforeAll(async () => {
mock.module("@/utils/persist", () => ({
Persist: {
workspace: (...parts: string[]) => parts.join(":"),
},
persisted: (_target: string, store: unknown[]) => [store[0], store[1], null, () => true],
}))
mock.module("@tanstack/solid-query", () => ({
useQuery: (options: () => { queryKey?: unknown[]; enabled?: boolean }) => {
querySingles.push(options)
@ -80,6 +81,8 @@ describe("createChildStoreManager", () => {
const manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
@ -109,6 +112,8 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap(directory) {
@ -140,6 +145,8 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},
@ -171,6 +178,8 @@ describe("createChildStoreManager", () => {
const dispose = createOwner((owner) => {
manager = createChildStoreManager({
owner,
scope: ServerScope.local,
persist,
isBooting: () => false,
isLoadingSessions: () => false,
onBootstrap() {},

View file

@ -18,9 +18,12 @@ import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
import { directoryKey, type DirectoryKey } from "./utils"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import type { ServerScope } from "@/utils/server-scope"
export function createChildStoreManager(input: {
owner: Owner
scope: ServerScope
persist: typeof persisted
isBooting: (directory: string) => boolean
isLoadingSessions: (directory: string) => boolean
onBootstrap: (directory: string) => void
@ -147,8 +150,8 @@ export function createChildStoreManager(input: {
if (!key) console.error("No directory provided")
if (!children[key]) {
const vcs = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
input.persist(
Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
)
@ -157,8 +160,8 @@ export function createChildStoreManager(input: {
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "project", ["project.v1"]),
input.persist(
Persist.serverWorkspace(input.scope, directory, "project", ["project.v1"]),
createStore({ value: undefined as ProjectMeta | undefined }),
),
)
@ -166,8 +169,8 @@ export function createChildStoreManager(input: {
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () =>
persisted(
Persist.workspace(directory, "icon", ["icon.v1"]),
input.persist(
Persist.serverWorkspace(input.scope, directory, "icon", ["icon.v1"]),
createStore({ value: undefined as string | undefined }),
),
)

View file

@ -7,14 +7,18 @@ import {
setSessionPrefetch,
shouldSkipSessionPrefetch,
} from "./session-prefetch"
import { ServerScope } from "@/utils/server-scope"
const scope = ServerScope.local
describe("session prefetch", () => {
test("stores and clears message metadata by directory", () => {
clearSessionPrefetch("/tmp/a", ["ses_1"])
clearSessionPrefetch("/tmp/b", ["ses_1"])
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
clearSessionPrefetch(scope, "/tmp/b", ["ses_1"])
setSessionPrefetch({
directory: "/tmp/a",
scope,
sessionID: "ses_1",
limit: 200,
cursor: "abc",
@ -22,21 +26,22 @@ describe("session prefetch", () => {
at: 123,
})
expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toEqual({ limit: 200, cursor: "abc", complete: false, at: 123 })
expect(getSessionPrefetch(scope, "/tmp/b", "ses_1")).toBeUndefined()
clearSessionPrefetch("/tmp/a", ["ses_1"])
clearSessionPrefetch(scope, "/tmp/a", ["ses_1"])
expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/a", "ses_1")).toBeUndefined()
})
test("dedupes inflight work", async () => {
clearSessionPrefetch("/tmp/c", ["ses_2"])
clearSessionPrefetch(scope, "/tmp/c", ["ses_2"])
let calls = 0
const run = () =>
runSessionPrefetch({
directory: "/tmp/c",
scope,
sessionID: "ses_2",
task: async () => {
calls += 1
@ -52,15 +57,24 @@ describe("session prefetch", () => {
})
test("clears a whole directory", () => {
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
setSessionPrefetch({ scope, directory: "/tmp/d", sessionID: "ses_1", limit: 10, cursor: "a", complete: true, at: 1 })
setSessionPrefetch({ scope, directory: "/tmp/d", sessionID: "ses_2", limit: 20, cursor: "b", complete: false, at: 2 })
setSessionPrefetch({ scope, directory: "/tmp/e", sessionID: "ses_1", limit: 30, cursor: "c", complete: true, at: 3 })
clearSessionPrefetchDirectory("/tmp/d")
clearSessionPrefetchDirectory(scope, "/tmp/d")
expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
expect(getSessionPrefetch(scope, "/tmp/d", "ses_1")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/d", "ses_2")).toBeUndefined()
expect(getSessionPrefetch(scope, "/tmp/e", "ses_1")).toEqual({ limit: 30, cursor: "c", complete: true, at: 3 })
})
test("isolates identical directories and sessions by server scope", () => {
const remote = "https://debian.example" as ServerScope
setSessionPrefetch({ scope, directory: "/repo", sessionID: "ses_1", limit: 10, complete: true, at: 1 })
setSessionPrefetch({ scope: remote, directory: "/repo", sessionID: "ses_1", limit: 20, complete: true, at: 2 })
expect(getSessionPrefetch(scope, "/repo", "ses_1")?.limit).toBe(10)
expect(getSessionPrefetch(remote, "/repo", "ses_1")?.limit).toBe(20)
})
test("refreshes stale first-page prefetched history", () => {

View file

@ -1,4 +1,6 @@
const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}`
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
const key = (scope: ServerScope, directory: string, sessionID: string) => ScopedKey.from(scope, directory, sessionID)
export const SESSION_PREFETCH_TTL = 15_000
@ -27,28 +29,32 @@ const rev = new Map<string, number>()
const version = (id: string) => rev.get(id) ?? 0
export function getSessionPrefetch(directory: string, sessionID: string) {
return cache.get(key(directory, sessionID))
export function getSessionPrefetch(scope: ServerScope, directory: string, sessionID: string) {
return cache.get(key(scope, directory, sessionID))
}
export function getSessionPrefetchPromise(directory: string, sessionID: string) {
return inflight.get(key(directory, sessionID))
export function getSessionPrefetchPromise(scope: ServerScope, directory: string, sessionID: string) {
return inflight.get(key(scope, directory, sessionID))
}
export function clearSessionPrefetchInflight() {
inflight.clear()
export function clearSessionPrefetchInflight(scope: ServerScope) {
const prefix = ScopedKey.prefix(scope)
for (const id of inflight.keys()) {
if (id.startsWith(prefix)) inflight.delete(id)
}
}
export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) {
return version(key(directory, sessionID)) === value
export function isSessionPrefetchCurrent(scope: ServerScope, directory: string, sessionID: string, value: number) {
return version(key(scope, directory, sessionID)) === value
}
export function runSessionPrefetch(input: {
directory: string
scope: ServerScope
sessionID: string
task: (value: number) => Promise<Meta | undefined>
}) {
const id = key(input.directory, input.sessionID)
const id = key(input.scope, input.directory, input.sessionID)
const pending = inflight.get(id)
if (pending) return pending
@ -64,13 +70,14 @@ export function runSessionPrefetch(input: {
export function setSessionPrefetch(input: {
directory: string
scope: ServerScope
sessionID: string
limit: number
cursor?: string
complete: boolean
at?: number
}) {
cache.set(key(input.directory, input.sessionID), {
cache.set(key(input.scope, input.directory, input.sessionID), {
limit: input.limit,
cursor: input.cursor,
complete: input.complete,
@ -78,18 +85,18 @@ export function setSessionPrefetch(input: {
})
}
export function clearSessionPrefetch(directory: string, sessionIDs: Iterable<string>) {
export function clearSessionPrefetch(scope: ServerScope, directory: string, sessionIDs: Iterable<string>) {
for (const sessionID of sessionIDs) {
if (!sessionID) continue
const id = key(directory, sessionID)
const id = key(scope, directory, sessionID)
rev.set(id, version(id) + 1)
cache.delete(id)
inflight.delete(id)
}
}
export function clearSessionPrefetchDirectory(directory: string) {
const prefix = `${directory}\n`
export function clearSessionPrefetchDirectory(scope: ServerScope, directory: string) {
const prefix = ScopedKey.prefix(scope, directory)
const keys = new Set([...cache.keys(), ...inflight.keys()])
for (const id of keys) {
if (!id.startsWith(prefix)) continue

View file

@ -1,17 +1,17 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { ServerConnection, useServer } from "./server"
import { createServerProjects, ServerConnection, useServer } from "./server"
import { useServerHealth } from "@/utils/server-health"
import { QueryClient } from "@tanstack/solid-query"
import { createServerSdkContext } from "./server-sdk"
import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { Persist, persisted } from "@/utils/persist"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
init: () => {
const server = useServer()
const serverHealth = useServerHealth(
() => server.list,
@ -23,8 +23,6 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
const serversAndProjects = createServersAndProjectStore()
const settingsServer = createMemo(() => {
const list = server.list
return list.find((conn) => ServerConnection.key(conn) === store.settings.serverKey) ?? list[0]
@ -43,18 +41,25 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
const owner = getOwner()
const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
const existing = serverCtxs.get(key)
if (existing) return existing.serverCtx
const root = createRoot((dispose) => {
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
return { dispose, serverCtx }
}, owner as any)
serverCtxs.set(key, root)
return root.serverCtx
}
createMemo(() => {
for (const conn of server.list) {
const key = ServerConnection.key(conn)
if (!serverCtxs.has(key)) {
const root = createRoot((dispose) => {
const serverCtx = createServerCtx(conn, serversAndProjects)
return { dispose, serverCtx }
}, owner as any)
serverCtxs.set(key, root)
}
ensureServerCtx(conn)
}
})
createEffect(() => {
for (const [key] of serverCtxs) {
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
const { dispose } = serverCtxs.get(key)!
@ -64,16 +69,10 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
}
})
const allServers = createMemo(
(): Array<ServerConnection.Any> =>
resolveServerList({ stored: serversAndProjects.store.list, props: props.servers }),
)
return {
servers: {
list: allServers,
list: () => server.list,
health: serverHealth,
default: () => allServers().find((s) => ServerConnection.key(s) === props.defaultServer) ?? allServers()[0]!,
},
settings: {
server: {
@ -87,33 +86,16 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
},
createServerCtx(conn: ServerConnection.Any) {
const key = ServerConnection.key(conn)
const ctx = serverCtxs.get(key)
if (!ctx) return createServerCtx(conn, serversAndProjects)
return ctx.serverCtx
return ensureServerCtx(conn)
},
}
},
})
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
const createServersAndProjectStore = () => {
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
createStore({
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
}),
)
return { store, setStore, ready }
}
function createServerCtx(
conn: ServerConnection.Any,
{ store, setStore }: ReturnType<typeof createServersAndProjectStore>,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
) {
const queryClient = new QueryClient({
defaultOptions: {
@ -124,13 +106,9 @@ function createServerCtx(
},
},
})
const sdk = createServerSdkContext(conn)
const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk)
const key = ServerConnection.key(conn)
const storeKey = projectsKey(key)
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
const projectID = childStore.project
@ -148,7 +126,7 @@ function createServerCtx(
return base
}
const projectsList = createMemo(() => (store.projects[storeKey] ?? []).map(enrich))
const projectsList = createMemo(() => projects.list().map(enrich))
const isLocal =
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
@ -159,45 +137,8 @@ function createServerCtx(
sync,
isLocal,
projects: {
...projects,
list: projectsList,
open(directory: string) {
const current = store.projects[storeKey] ?? []
if (current.find((x) => x.worktree === directory)) return
setStore("projects", storeKey, [{ worktree: directory, expanded: true }, ...current])
},
close(directory: string) {
const current = store.projects[storeKey] ?? []
setStore(
"projects",
storeKey,
current.filter((x) => x.worktree !== directory),
)
},
expand(directory: string) {
const current = store.projects[storeKey] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", storeKey, index, "expanded", true)
},
collapse(directory: string) {
const current = store.projects[storeKey] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", storeKey, index, "expanded", false)
},
move(directory: string, toIndex: number) {
const current = store.projects[storeKey] ?? []
const fromIndex = current.findIndex((x) => x.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const result = [...current]
const [item] = result.splice(fromIndex, 1)
result.splice(toIndex, 0, item)
setStore("projects", storeKey, result)
},
last() {
return store.lastProject[storeKey]
},
touch(directory: string) {
setStore("lastProject", storeKey, directory)
},
},
}
}
@ -208,42 +149,3 @@ function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
}
function projectsKey(key: ServerConnection.Key) {
if (key === "sidecar") return "local"
if (isLocalHost(key)) return "local"
return key
}
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
)
for (const value of input.stored) {
const conn: ServerConnection.Http =
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: "http" in value
? value
: { type: "http", http: value }
const key = ServerConnection.key(conn)
const existing = deduped.get(key)
if (existing)
deduped.set(key, {
...existing,
...conn,
http: { ...existing.http, ...conn.http },
})
else deduped.set(key, conn)
}
return [...deduped.values()]
}

View file

@ -198,6 +198,7 @@ if (warm !== "en") void loadDict(warm)
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language",
gate: false,
init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted(

View file

@ -14,6 +14,7 @@ import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { createPathHelpers } from "./file/path"
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@ -64,6 +65,7 @@ type SessionView = {
}
type TabHandoff = {
scope: ServerScope
dir: string
id: string
at: number
@ -87,7 +89,7 @@ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string):
}
const sessionPath = (key: string) => {
const dir = key.split("/")[0]
const dir = SessionStateKey.route(key).split("/")[0]
if (!dir) return
const root = decode64(dir)
if (!root) return
@ -135,13 +137,18 @@ const currentRoute = (pathname: string): LayoutRoute => {
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
gate: false,
init: () => {
const globalSdk = useServerSDK()
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const server = useServer()
const platform = usePlatform()
const location = useLocation()
const route = createMemo(() => currentRoute(location.pathname))
const route = createMemo(() => {
const value = currentRoute(location.pathname)
if (value.type === "home") return value
return { ...value, server: server.key }
})
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
@ -186,7 +193,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})()
const sessionTabs = value.sessionTabs
const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs)
const sessionView = migrateLegacySessionStateKeys(value.sessionView)
const migratedSessionTabs = (() => {
if (!isRecord(sessionTabs)) return sessionTabs
@ -215,7 +223,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
migratedSidebar === sidebar &&
migratedReview === review &&
migratedFileTree === fileTree &&
migratedSessionTabs === sessionTabs
migratedSessionTabs === value.sessionTabs &&
sessionView === value.sessionView
) {
return value
}
@ -226,10 +235,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
review: migratedReview,
fileTree: migratedFileTree,
sessionTabs: migratedSessionTabs,
sessionView,
}
}
const target = Persist.global("layout", ["layout.v6"])
const target = Persist.serverGlobal(serverSdk.scope, "layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
{ ...target, migrate },
createStore({
@ -282,15 +292,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const dropSessionState = (keys: string[]) => {
for (const key of keys) {
const parts = key.split("/")
const scope = SessionStateKey.scope(key)
const parts = SessionStateKey.route(key).split("/")
const dir = parts[0]
const session = parts[1]
if (!dir) continue
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
const target = session
? Persist.serverSession(scope, dir, session, entry.key)
: Persist.serverWorkspace(scope, dir, entry.key)
void removePersisted(target, platform)
if (scope !== ServerScope.local) continue
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey }, platform)
}
@ -515,7 +529,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
continue
}
void globalSdk.client.project
void serverSdk.client.project
.update({ projectID: project.id, directory: worktree, icon: { color } })
.catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
@ -551,7 +565,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { dir, id, at: Date.now() })
setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return

View file

@ -9,6 +9,8 @@ import { Persist, persisted } from "@/utils/persist"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useServerSDK } from "./server-sdk"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
export type ModelKey = { providerID: string; modelID: string; variant?: string }
@ -25,7 +27,7 @@ type Saved = {
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoffKey = (dir: string, id: string) => `${dir}\n${id}`
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
@ -57,6 +59,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const params = useParams()
const sdk = useSDK()
const sync = useSync()
const serverSDK = useServerSDK()
const providers = useProviders()
const models = useModels()
@ -66,7 +69,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [saved, setSaved] = persisted(
{
...Persist.workspace(sdk.directory, "model-selection", ["model-selection.v1"]),
...Persist.serverWorkspace(serverSDK.scope, sdk.directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
@ -121,14 +124,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft
return saved.session[session] ?? handoff.get(handoffKey(sdk.directory, session))
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk.directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(sdk.directory, session)
const key = handoffKey(serverSDK.scope, sdk.directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
@ -377,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return
}
handoff.set(handoffKey(dir, session), next)
handoff.set(handoffKey(serverSDK.scope, dir, session), next)
setStore("draft", undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
@ -385,7 +388,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(sdk.directory, session))) return
if (handoff.has(handoffKey(serverSDK.scope, sdk.directory, session))) return
setSaved("session", session, {
agent: msg.agent,

View file

@ -24,6 +24,7 @@ function modelKey(model: ModelKey) {
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
gate: false,
init: () => {
const providers = useProviders()

View file

@ -107,6 +107,7 @@ function buildNotificationIndex(list: Notification[]) {
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
gate: false,
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
@ -124,7 +125,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const currentSession = createMemo(() => params.id)
const [store, setStore, _, ready] = persisted(
Persist.global("notification", ["notification.v1"]),
Persist.serverGlobal(serverSDK.scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),

View file

@ -46,6 +46,7 @@ function hasPermissionPromptRules(permission: unknown) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
gate: false,
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
@ -60,7 +61,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const [store, setStore, _, ready] = persisted(
{
...Persist.global("permission", ["permission.v3"]),
...Persist.serverGlobal(serverSDK.scope, "permission", ["permission.v3"]),
migrate(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) return value

View file

@ -5,6 +5,8 @@ import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { useServerSDK } from "./server-sdk"
import type { ServerScope } from "@/utils/server-scope"
interface PartBase {
content: string
@ -161,11 +163,11 @@ type PromptCacheEntry = {
dispose: VoidFunction
}
function createPromptSession(dir: string, id: string | undefined) {
function createPromptSession(scope: ServerScope, dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "prompt", [legacy]),
Persist.serverScoped(scope, dir, id, "prompt", [legacy]),
createStore<{
prompt: Prompt
cursor?: number
@ -229,6 +231,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
gate: false,
init: () => {
const params = useParams()
const serverSDK = useServerSDK()
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
@ -262,7 +265,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(dir, id),
value: createPromptSession(serverSDK.scope, dir, id),
dispose,
}),
owner,

View file

@ -0,0 +1,14 @@
import { describe, expect, test } from "bun:test"
import { resumeStreamAfterPageShow } from "./server-sdk"
describe("resumeStreamAfterPageShow", () => {
test("restarts a stream only after a back-forward cache restore", () => {
let starts = 0
const start = () => starts++
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
expect(starts).toBe(1)
})
})

View file

@ -9,11 +9,19 @@ import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
export function createServerSdkContext(server: ServerConnection.Any) {
const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
if (!event.persisted) return
start()
}
export function createServerSdkContext(server: ServerConnection.Any, scope: ServerScope) {
const platform = usePlatform()
const abort = new AbortController()
@ -96,11 +104,10 @@ export function createServerSdkContext(server: ServerConnection.Any) {
let streamErrorLogged = false
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
const aborted = isAbortError
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
let generation = 0
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
@ -120,9 +127,12 @@ export function createServerSdkContext(server: ServerConnection.Any) {
const start = () => {
if (started) return run
started = true
run = (async () => {
const active = ++generation
const previous = run
const current = (async () => {
if (previous) await previous
// oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit
while (!abort.signal.aborted && started) {
while (!abort.signal.aborted && started && generation === active) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
@ -133,7 +143,7 @@ export function createServerSdkContext(server: ServerConnection.Any) {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (isStreamClosed(error, attempt?.signal)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
@ -176,7 +186,7 @@ export function createServerSdkContext(server: ServerConnection.Any) {
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
if (!isStreamClosed(error, attempt?.signal) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: server.http.url,
@ -190,23 +200,28 @@ export function createServerSdkContext(server: ServerConnection.Any) {
clearHeartbeat()
}
if (abort.signal.aborted || !started) return
if (abort.signal.aborted || !started || generation !== active) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
if (run !== current) return
run = undefined
flush()
})
run = current
return run
}
const stop = () => {
started = false
generation++
attempt?.abort()
clearHeartbeat()
}
onMount(() => {
makeEventListener(window, "pagehide", stop)
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
@ -228,6 +243,7 @@ export function createServerSdkContext(server: ServerConnection.Any) {
})
return {
scope,
url: server.http.url,
client: sdk,
event: {
@ -282,6 +298,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDK) {
onCleanup(unsub)
return {
scope: serverSDK.scope,
directory,
client,
event: emitter,

View file

@ -34,6 +34,8 @@ import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
import type { ServerScope } from "@/utils/server-scope"
import { persisted } from "@/utils/persist"
type GlobalStore = {
ready: boolean
@ -49,29 +51,30 @@ type GlobalStore = {
reload: undefined | "pending" | "complete"
}
export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: [directory, "mcp"] as const,
queryKey: [scope, directory, "mcp"] as const,
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
})
export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: [directory, "lsp"] as const,
queryKey: [scope, directory, "lsp"] as const,
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []),
})
function makeQueryOptionsApi(serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
function makeQueryOptionsApi(scope: ServerScope, serverSDK: () => OpencodeClient, sdkFor: (dir: PathKey) => OpencodeClient) {
return {
globalConfig: () => loadGlobalConfigQuery(serverSDK()),
projects: () => loadProjectsQuery(serverSDK()),
globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()),
projects: () => loadProjectsQuery(scope, serverSDK()),
providers: (directory: PathKey | null) =>
loadProvidersQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) => loadPathQuery(directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [directory, "loadSessions"] as const }),
loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
path: (directory: PathKey | null) =>
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
}
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
@ -99,7 +102,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
return sdk
}
const queryOptionsApi = makeQueryOptionsApi(() => serverSDK.client, sdkFor)
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor)
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)],
@ -156,10 +159,11 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
}) as typeof setGlobalStore
const bootstrap = useQuery(() => ({
queryKey: ["bootstrap"],
queryKey: [serverSDK.scope, "bootstrap"],
queryFn: async () => {
await bootstrapGlobal({
serverSDK: serverSDK.client,
scope: serverSDK.scope,
requestFailedTitle: language.t("common.requestFailed"),
translate: language.t,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
@ -198,12 +202,14 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const queue = createRefreshQueue({
paused,
key: directoryKey,
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
bootstrap: () => queryClient.fetchQuery({ queryKey: [serverSDK.scope, "bootstrap"] }),
bootstrapInstance,
})
const children = createChildStoreManager({
owner,
scope: serverSDK.scope,
persist: persisted,
isBooting: (directory) => booting.has(directory),
isLoadingSessions: (directory) => sessionLoads.has(directory),
onBootstrap: (directory) => {
@ -227,8 +233,8 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
queue.clear(key)
sessionMeta.delete(key)
sdkCache.delete(key)
clearProviderRev(key)
clearSessionPrefetchDirectory(key)
clearProviderRev(serverSDK.scope, key)
clearSessionPrefetchDirectory(serverSDK.scope, key)
},
translate: language.t,
queryOptions: queryOptionsApi,
@ -328,6 +334,7 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
const sdk = sdkFor(directory)
await bootstrapDirectory({
directory,
scope: serverSDK.scope,
mcp: children.mcp(key),
global: {
config: globalStore.config,
@ -439,8 +446,8 @@ export function createServerSyncContextInner(_serverSDK?: ServerSDK) {
bootstrap.refetch()
// Invalidate all provider queries so newly configured custom providers
// appear immediately in the available provider list across all directories.
queryClient.invalidateQueries({ queryKey: [null, "providers"] })
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[1] === "providers" })
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers" })
},
}))
@ -479,6 +486,7 @@ export function createServerSyncContext(_serverSDK?: ServerSDK) {
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync",
gate: false,
init: (props: { server?: ServerConnection.Any }) => {
const global = useGlobal()
const language = useLanguage()

View file

@ -1,5 +1,8 @@
import { describe, expect, test } from "bun:test"
import { resolveServerList, ServerConnection } from "./server"
import { createRoot, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, migrateCanonicalLocalServerState, resolveServerList, ServerConnection } from "./server"
import { ServerScope } from "@/utils/server-scope"
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
@ -51,3 +54,70 @@ describe("resolveServerList", () => {
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
})
})
describe("createServerProjects", () => {
test("keeps active and explicit server buckets in one reactive store", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {} })
const active = createServerProjects({ scope, store, setStore })
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
remote.open("/repo")
expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }])
expect(active.list()).toEqual([])
const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }])
adopted.close("/repo")
expect(remote.list()).toEqual([])
dispose()
})
})
})
describe("migrateCanonicalLocalServerState", () => {
test("moves an existing canonical web bucket into local scope", () => {
expect(
migrateCanonicalLocalServerState(
{
list: [],
projects: { "https://opencode.example.com": [{ worktree: "/remote", expanded: true }] },
lastProject: { "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
list: [],
projects: { local: [{ worktree: "/remote", expanded: true }] },
lastProject: { local: "/remote" },
})
})
test("preserves existing local state while merging a canonical web bucket", () => {
expect(
migrateCanonicalLocalServerState(
{
projects: {
local: [{ worktree: "/local", expanded: false }],
"https://opencode.example.com": [
{ worktree: "/local", expanded: true },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local", "https://opencode.example.com": "/remote" },
},
ServerConnection.Key.make("https://opencode.example.com"),
),
).toEqual({
projects: {
local: [
{ worktree: "/local", expanded: false },
{ worktree: "/remote", expanded: true },
],
},
lastProject: { local: "/local" },
})
})
})

View file

@ -1,10 +1,12 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { ServerScope } from "@/utils/server-scope"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = { projects: Record<string, StoredProject[]>; lastProject: Record<string, string> }
const HEALTH_POLL_INTERVAL_MS = 10_000
export function normalizeServerUrl(input: string) {
@ -20,18 +22,91 @@ export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = fals
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
function projectsKey(key: ServerConnection.Key) {
if (!key) return ""
if (key === "sidecar") return "local"
if (isLocalHost(key)) return "local"
return key
}
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
if (!isRecord(value)) return value
const projects = isRecord(value.projects) ? value.projects : undefined
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
const previousProjects = projects?.[canonicalLocalServer]
const previousLastProject = lastProject?.[canonicalLocalServer]
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
const next = { ...value }
if (projects && Array.isArray(previousProjects)) {
const local = Array.isArray(projects.local) ? projects.local : []
const worktrees = new Set(
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
)
const migrated = previousProjects.filter((project) => {
if (!isRecord(project) || typeof project.worktree !== "string") return true
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
})
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
delete nextProjects[canonicalLocalServer]
next.projects = nextProjects
}
if (lastProject && typeof previousLastProject === "string") {
const nextLastProject = { ...lastProject }
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
delete nextLastProject[canonicalLocalServer]
next.lastProject = nextLastProject
}
return next
}
export function createServerProjects<T extends ServerProjectState>(input: {
scope: Accessor<ServerScope>
store: Store<T>
setStore: SetStoreFunction<T>
}) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const current = () => input.store.projects[input.scope()] ?? []
return {
list: current,
open(directory: string) {
const scope = input.scope()
if (current().some((project) => project.worktree === directory)) return
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
},
close(directory: string) {
setStore("projects", input.scope(), current().filter((project) => project.worktree !== directory))
},
expand(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
},
collapse(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
},
move(directory: string, toIndex: number) {
const fromIndex = current().findIndex((project) => project.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const next = [...current()]
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
setStore("projects", input.scope(), next)
},
last() {
return input.store.lastProject[input.scope()]
},
touch(directory: string) {
setStore("lastProject", input.scope(), directory)
},
}
}
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
@ -127,9 +202,17 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
gate: true,
init: (props: {
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
{
...Persist.global("server", ["server.v3"]),
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
},
createStore({
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
@ -180,8 +263,16 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
const origin = createMemo(() => projectsKey(state.active))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
const projects = createServerProjects({ scope, store, setStore })
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
const projectsForServer = (key: ServerConnection.Key) => {
const existing = projectStores.get(key)
if (existing) return existing
const next = createServerProjects({ scope: () => scope(key), store, setStore })
projectStores.set(key, next)
return next
}
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
@ -208,60 +299,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
setActive,
add,
remove,
scope,
projects: {
list: projectsList,
open(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
if (current.find((x) => x.worktree === directory)) return
setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
},
close(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
setStore(
"projects",
key,
current.filter((x) => x.worktree !== directory),
)
},
expand(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", key, index, "expanded", true)
},
collapse(directory: string) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const index = current.findIndex((x) => x.worktree === directory)
if (index !== -1) setStore("projects", key, index, "expanded", false)
},
move(directory: string, toIndex: number) {
const key = origin()
if (!key) return
const current = store.projects[key] ?? []
const fromIndex = current.findIndex((x) => x.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const result = [...current]
const [item] = result.splice(fromIndex, 1)
result.splice(toIndex, 0, item)
setStore("projects", key, result)
},
last() {
const key = origin()
if (!key) return
return store.lastProject[key]
},
touch(directory: string) {
const key = origin()
if (!key) return
setStore("lastProject", key, directory)
},
...projects,
forServer: projectsForServer,
},
}
},

View file

@ -156,6 +156,7 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings",
gate: false,
init: () => {
const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))

View file

@ -0,0 +1,140 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
export type SessionTab = {
type: "session"
server: ServerConnection.Key
dirBase64: string
sessionId: string
}
export type Tab = SessionTab
export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
init: () => {
const server = useServer()
const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
...Persist.global("tabs"),
migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
if (!tab || typeof tab !== "object" || "server" in tab) return tab
return { ...tab, server: fallback }
})
},
},
createStore<Tab[]>([]),
)
const params = useParams()
const navigate = useNavigate()
const closing = new Set<string>()
createEffect(() => {
if (!ready()) return
const servers = new Set(server.list.map(ServerConnection.key))
if (store.every((tab) => servers.has(tab.server))) return
setStore((tabs) => tabs.filter((tab) => servers.has(tab.server)))
})
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
if (tab.server === server.key) {
navigate(href)
return
}
void startTransition(() => {
server.setActive(tab.server)
navigate(href)
})
}
const actions = {
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
if (closing.has(tabKey(next))) return
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
},
removeTab: (index: number) => {
const tab = store[index]
if (!tab) return
const key = tabKey(tab)
const nextTab = store[index + 1] ?? store[index - 1]
closing.add(key)
void startTransition(() => {
setStore(
produce((tabs) => {
tabs.splice(index, 1)
}),
)
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
},
removeServer(key: ServerConnection.Key) {
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref =
params.dir && params.id
? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })
: undefined
const currentIndex = currentHref
? tabs.findIndex(
(tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
)
: -1
const currentTab = tabs[currentIndex]
const removedCurrent =
currentTab?.type === "session" &&
currentTab.server === server.key &&
atob(currentTab.dirBase64) === input.directory &&
sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue
if (atob(tab.dirBase64) !== input.directory) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
if (!removedCurrent) return
const nextTab =
tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
if (nextTab) navigateTab(nextTab)
else navigate("/")
}),
)
})
},
}
return { ...actions, store, ready }
},
})

View file

@ -1,9 +1,7 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
import { ServerScope } from "@/utils/server-scope"
type ServerKey = Parameters<typeof import("./terminal").getTerminalServerScope>[1]
let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
let getWorkspaceTerminalCacheKey: typeof import("./terminal").getWorkspaceTerminalCacheKey
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
@ -20,53 +18,19 @@ beforeAll(async () => {
}))
const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getTerminalServerScope = mod.getTerminalServerScope
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState
})
describe("getWorkspaceTerminalCacheKey", () => {
test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
expect(String(getWorkspaceTerminalCacheKey("/repo"))).toBe("local\u0000/repo\u0000__workspace__")
})
test("can include a server scope", () => {
expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__")
})
})
describe("getTerminalServerScope", () => {
test("preserves local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } },
"sidecar" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope(
{ type: "http", http: { url: "http://localhost:4096" } },
"http://localhost:4096" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey),
).toBeUndefined()
})
test("scopes non-local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } },
"wsl:Debian" as ServerKey,
),
).toBe("wsl:Debian" as ServerKey)
expect(
getTerminalServerScope(
{ type: "http", http: { url: "https://example.com" } },
"https://example.com" as ServerKey,
),
).toBe("https://example.com" as ServerKey)
expect(String(getWorkspaceTerminalCacheKey("/repo", "ssh:debian" as ServerScope))).toBe(
"ssh:debian\u0000/repo\u0000__workspace__",
)
})
})

View file

@ -4,9 +4,10 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import type { Platform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { useServer } from "./server"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
export type LocalPTY = {
id: string
@ -83,29 +84,8 @@ export function migrateTerminalState(value: unknown) {
}
}
export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) {
if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
return `${dir}:${WORKSPACE_KEY}`
}
export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) {
if (!conn) return
if (conn.type === "sidecar" && conn.variant === "base") return
if (conn.type === "http") {
try {
const url = new URL(conn.http.url)
if (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "::1" ||
url.hostname === "[::1]"
)
return
} catch {
return key
}
}
return key
export function getWorkspaceTerminalCacheKey(dir: string, scope: ServerScopeValue = ServerScope.local) {
return ScopedKey.from(scope, dir, WORKSPACE_KEY)
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
@ -132,16 +112,25 @@ const trimTerminal = (pty: LocalPTY) => {
}
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) {
function terminalPersistTarget(scope: ServerScopeValue, dir: string, legacy?: string[]) {
return Persist.serverWorkspace(scope, dir, "terminal", legacy)
}
export function clearWorkspaceTerminals(
dir: string,
sessionIDs?: string[],
platform?: Platform,
scope: ServerScopeValue = ServerScope.local,
) {
const key = getWorkspaceTerminalCacheKey(dir, scope)
for (const cache of caches) {
const entry = cache.get(key)
entry?.value.clear()
}
void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform)
void removePersisted(terminalPersistTarget(scope, dir), platform)
if (scope) return
if (scope !== ServerScope.local) return
const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) {
for (const key of getLegacyTerminalStorageKeys(dir, id)) {
@ -156,14 +145,14 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
dir: string,
scope: ServerScopeValue,
legacySessionID?: string,
scope?: string,
) {
const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : []
const [store, setStore, _, ready] = persisted(
{
...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy),
...terminalPersistTarget(scope, dir, legacy),
migrate: migrateTerminalState,
},
createStore<{
@ -388,9 +377,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
const server = useServer()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
const scope = createMemo(() => {
return getTerminalServerScope(server.current, server.key)
})
const scope = server.scope()
caches.add(cache)
onCleanup(() => caches.delete(cache))
@ -414,7 +401,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => {
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: ServerScopeValue) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir, serverScope)
const existing = cache.get(key)
@ -425,7 +412,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope),
value: createWorkspaceTerminalSession(sdk, dir, serverScope, legacySessionID),
dispose,
}))
@ -434,11 +421,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope()))
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope))
createEffect(
on(
() => ({ dir: params.dir, id: params.id, scope: scope() }),
() => ({ dir: params.dir, id: params.id, scope }),
(next, prev) => {
if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return