From d7b7be1909d614a4022b345bdbeef0c1ec32e159 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:39:19 +1000 Subject: [PATCH] fix(desktop): Path mismatches cause sessions missing + strong ID + existing data fix (#25013) --- packages/app/src/context/global-sync.tsx | 63 +++-- .../src/context/global-sync/child-store.ts | 100 ++++--- .../app/src/context/global-sync/queue.test.ts | 46 ++++ packages/app/src/context/global-sync/queue.ts | 15 +- .../app/src/context/global-sync/utils.test.ts | 19 +- packages/app/src/context/global-sync/utils.ts | 1 + packages/app/src/pages/layout.tsx | 76 +++--- packages/app/src/pages/layout/helpers.test.ts | 16 +- packages/app/src/pages/layout/helpers.ts | 17 +- .../src/pages/layout/sidebar-workspace.tsx | 5 +- packages/app/src/utils/path-key.ts | 24 ++ packages/app/src/utils/persist.test.ts | 52 ++++ packages/app/src/utils/persist.ts | 246 +++++++++++++----- packages/opencode/test/cli/tui/thread.test.ts | 7 +- 14 files changed, 485 insertions(+), 202 deletions(-) create mode 100644 packages/app/src/context/global-sync/queue.test.ts create mode 100644 packages/app/src/utils/path-key.ts diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 2c80f31b19..ba9f6d52ab 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -33,6 +33,7 @@ import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" +import { directoryKey } from "./global-sync/utils" type GlobalStore = { ready: boolean @@ -169,18 +170,20 @@ function createGlobalSync() { const queue = createRefreshQueue({ paused, + key: directoryKey, bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), bootstrapInstance, }) const sdkFor = (directory: string) => { - const cached = sdkCache.get(directory) + const key = directoryKey(directory) + const cached = sdkCache.get(key) if (cached) return cached const sdk = globalSDK.createClient({ directory, throwOnError: true, }) - sdkCache.set(directory, sdk) + sdkCache.set(key, sdk) return sdk } @@ -192,23 +195,25 @@ function createGlobalSync() { void bootstrapInstance(directory) }, onDispose: (directory) => { - queue.clear(directory) - sessionMeta.delete(directory) - sdkCache.delete(directory) - clearProviderRev(directory) - clearSessionPrefetchDirectory(directory) + const key = directoryKey(directory) + queue.clear(key) + sessionMeta.delete(key) + sdkCache.delete(key) + clearProviderRev(key) + clearSessionPrefetchDirectory(key) }, translate: language.t, getSdk: sdkFor, }) async function loadSessions(directory: string) { - const pending = sessionLoads.get(directory) + const key = directoryKey(directory) + const pending = sessionLoads.get(key) if (pending) return pending - children.pin(directory) + children.pin(key) const [store, setStore] = children.child(directory, { bootstrap: false }) - const meta = sessionMeta.get(directory) + const meta = sessionMeta.get(key) if (meta && meta.limit >= store.limit) { const next = trimSessions(store.session, { limit: store.limit, @@ -218,14 +223,14 @@ function createGlobalSync() { setStore("session", reconcile(next, { key: "id" })) cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo) } - children.unpin(directory) + children.unpin(key) return } const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const promise = queryClient .fetchQuery({ - ...loadSessionsQuery(directory), + ...loadSessionsQuery(key), queryFn: () => loadRootSessionsWithFallback({ directory, @@ -255,7 +260,7 @@ function createGlobalSync() { setStore("session", reconcile(sessions, { key: "id" })) cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) }) - sessionMeta.set(directory, { limit }) + sessionMeta.set(key, { limit }) }) .catch((err) => { console.error("Failed to load sessions", err) @@ -270,23 +275,24 @@ function createGlobalSync() { }) .then(() => {}) - sessionLoads.set(directory, promise) + sessionLoads.set(key, promise) void promise.finally(() => { - sessionLoads.delete(directory) - children.unpin(directory) + sessionLoads.delete(key) + children.unpin(key) }) return promise } async function bootstrapInstance(directory: string) { - if (!directory) return - const pending = booting.get(directory) + const key = directoryKey(directory) + if (!key) return + const pending = booting.get(key) if (pending) return pending - children.pin(directory) + children.pin(key) const promise = Promise.resolve().then(async () => { const child = children.ensureChild(directory) - const cache = children.vcsCache.get(directory) + const cache = children.vcsCache.get(key) if (!cache) return const sdk = sdkFor(directory) await bootstrapDirectory({ @@ -307,16 +313,17 @@ function createGlobalSync() { }) }) - booting.set(directory, promise) + booting.set(key, promise) void promise.finally(() => { - booting.delete(directory) - children.unpin(directory) + booting.delete(key) + children.unpin(key) }) return promise } const unsub = globalSDK.event.listen((e) => { const directory = e.name + const key = directoryKey(directory) const event = e.details const recent = bootingRoot || Date.now() - bootedAt < 1500 @@ -339,9 +346,9 @@ function createGlobalSync() { return } - const existing = children.children[directory] + const existing = children.children[key] if (!existing) return - children.mark(directory) + children.mark(key) const [store, setStore] = existing applyDirectoryEvent({ event, @@ -350,9 +357,9 @@ function createGlobalSync() { setStore, push: queue.push, setSessionTodo, - vcsCache: children.vcsCache.get(directory), + vcsCache: children.vcsCache.get(key), loadLsp: () => { - void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))) + void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory))) }, }) }) @@ -363,7 +370,7 @@ function createGlobalSync() { }) onCleanup(() => { for (const directory of Object.keys(children.children)) { - children.disposeDirectory(directory) + children.disposeDirectory(directoryKey(directory)) } }) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index d3b82894a4..4c3c677a75 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -17,6 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" import { useQueries } from "@tanstack/solid-query" import { loadPathQuery, loadProvidersQuery } from "./bootstrap" import { loadLspQuery, loadMcpQuery } from "../global-sync" +import { directoryKey, type DirectoryKey } from "./utils" export function createChildStoreManager(input: { owner: Owner @@ -36,30 +37,37 @@ export function createChildStoreManager(input: { const ownerPins = new WeakMap>() const disposers = new Map void>() + const markKey = (key: DirectoryKey) => { + if (!key) return + lifecycle.set(key, { lastAccessAt: Date.now() }) + runEviction(key) + } + const mark = (directory: string) => { - if (!directory) return - lifecycle.set(directory, { lastAccessAt: Date.now() }) - runEviction(directory) + const key = directoryKey(directory) + markKey(key) } const pin = (directory: string) => { - if (!directory) return - pins.set(directory, (pins.get(directory) ?? 0) + 1) - mark(directory) + const key = directoryKey(directory) + if (!key) return + pins.set(key, (pins.get(key) ?? 0) + 1) + markKey(key) } const unpin = (directory: string) => { - if (!directory) return - const next = (pins.get(directory) ?? 0) - 1 + const key = directoryKey(directory) + if (!key) return + const next = (pins.get(key) ?? 0) - 1 if (next > 0) { - pins.set(directory, next) + pins.set(key, next) return } - pins.delete(directory) + pins.delete(key) runEviction() } - const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0 + const pinned = (directory: string) => (pins.get(directoryKey(directory)) ?? 0) > 0 const pinForOwner = (directory: string) => { const current = getOwner() @@ -81,30 +89,31 @@ export function createChildStoreManager(input: { }) } - function disposeDirectory(directory: string) { + function disposeDirectory(directory: DirectoryKey) { + const key = directory if ( !canDisposeDirectory({ - directory, - hasStore: !!children[directory], - pinned: pinned(directory), - booting: input.isBooting(directory), - loadingSessions: input.isLoadingSessions(directory), + directory: key, + hasStore: !!children[key], + pinned: pinned(key), + booting: input.isBooting(key), + loadingSessions: input.isLoadingSessions(key), }) ) { return false } - vcsCache.delete(directory) - metaCache.delete(directory) - iconCache.delete(directory) - lifecycle.delete(directory) - const dispose = disposers.get(directory) + vcsCache.delete(key) + metaCache.delete(key) + iconCache.delete(key) + lifecycle.delete(key) + const dispose = disposers.get(key) if (dispose) { dispose() - disposers.delete(directory) + disposers.delete(key) } - delete children[directory] - input.onDispose(directory) + delete children[key] + input.onDispose(key) return true } @@ -121,13 +130,14 @@ export function createChildStoreManager(input: { }).filter((directory) => directory !== skip) if (list.length === 0) return for (const directory of list) { - if (!disposeDirectory(directory)) continue + if (!disposeDirectory(directoryKey(directory))) continue } } function ensureChild(directory: string) { - if (!directory) console.error("No directory provided") - if (!children[directory]) { + const key = directoryKey(directory) + if (!key) console.error("No directory provided") + if (!children[key]) { const vcs = runWithOwner(input.owner, () => persisted( Persist.workspace(directory, "vcs", ["vcs.v1"]), @@ -136,7 +146,7 @@ export function createChildStoreManager(input: { ) if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) const vcsStore = vcs[0] - vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) + vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) const meta = runWithOwner(input.owner, () => persisted( @@ -145,7 +155,7 @@ export function createChildStoreManager(input: { ), ) if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) - metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) + metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] }) const icon = runWithOwner(input.owner, () => persisted( @@ -154,7 +164,7 @@ export function createChildStoreManager(input: { ), ) if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) - iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) + iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] }) const init = () => createRoot((dispose) => { @@ -165,10 +175,10 @@ export function createChildStoreManager(input: { const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ queries: [ - loadPathQuery(directory, sdk), - loadMcpQuery(directory, sdk), - loadLspQuery(directory, sdk), - loadProvidersQuery(directory, sdk), + loadPathQuery(key, sdk), + loadMcpQuery(key, sdk), + loadLspQuery(key, sdk), + loadProvidersQuery(key, sdk), ], })) @@ -213,13 +223,13 @@ export function createChildStoreManager(input: { message: {}, part: {}, }) - children[directory] = child - disposers.set(directory, dispose) + children[key] = child + disposers.set(key, dispose) const onPersistedInit = (init: Promise | string | null, run: () => void) => { if (!(init instanceof Promise)) return void init.then(() => { - if (children[directory] !== child) return + if (children[key] !== child) return run() }) } @@ -243,15 +253,16 @@ export function createChildStoreManager(input: { runWithOwner(input.owner, init) } - mark(directory) - const childStore = children[directory] + markKey(key) + const childStore = children[key] if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) return childStore } function child(directory: string, options: ChildOptions = {}) { + const key = directoryKey(directory) const childStore = ensureChild(directory) - pinForOwner(directory) + pinForOwner(key) const shouldBootstrap = options.bootstrap ?? true if (shouldBootstrap && childStore[0].status === "loading") { input.onBootstrap(directory) @@ -260,6 +271,7 @@ export function createChildStoreManager(input: { } function peek(directory: string, options: ChildOptions = {}) { + const key = directoryKey(directory) const childStore = ensureChild(directory) const shouldBootstrap = options.bootstrap ?? true if (shouldBootstrap && childStore[0].status === "loading") { @@ -269,8 +281,9 @@ export function createChildStoreManager(input: { } function projectMeta(directory: string, patch: ProjectMeta) { + const key = directoryKey(directory) const [store, setStore] = ensureChild(directory) - const cached = metaCache.get(directory) + const cached = metaCache.get(key) if (!cached) return const previous = store.projectMeta ?? {} const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon @@ -286,8 +299,9 @@ export function createChildStoreManager(input: { } function projectIcon(directory: string, value: string | undefined) { + const key = directoryKey(directory) const [store, setStore] = ensureChild(directory) - const cached = iconCache.get(directory) + const cached = iconCache.get(key) if (!cached) return if (store.icon === value) return cached.setStore("value", value) diff --git a/packages/app/src/context/global-sync/queue.test.ts b/packages/app/src/context/global-sync/queue.test.ts new file mode 100644 index 0000000000..c9919855eb --- /dev/null +++ b/packages/app/src/context/global-sync/queue.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { createRefreshQueue } from "./queue" +import { directoryKey } from "./utils" + +const tick = () => new Promise((resolve) => setTimeout(resolve, 10)) + +describe("createRefreshQueue", () => { + test("clears queued directories by normalized key", async () => { + const calls: string[] = [] + const queue = createRefreshQueue({ + paused: () => false, + key: directoryKey, + bootstrap: async () => {}, + bootstrapInstance: (directory) => { + calls.push(directory) + }, + }) + + queue.push("C:\\tmp\\demo") + queue.clear("C:/tmp/demo") + + await tick() + + expect(calls).toEqual([]) + queue.dispose() + }) + + test("passes the original directory to bootstrapInstance", async () => { + const calls: string[] = [] + const queue = createRefreshQueue({ + paused: () => false, + key: directoryKey, + bootstrap: async () => {}, + bootstrapInstance: (directory) => { + calls.push(directory) + }, + }) + + queue.push("C:\\tmp\\demo") + + await tick() + + expect(calls).toEqual(["C:\\tmp\\demo"]) + queue.dispose() + }) +}) diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts index 5c228dac04..947e31ac90 100644 --- a/packages/app/src/context/global-sync/queue.ts +++ b/packages/app/src/context/global-sync/queue.ts @@ -2,22 +2,25 @@ type QueueInput = { paused: () => boolean bootstrap: () => Promise bootstrapInstance: (directory: string) => Promise | void + key?: (directory: string) => string } export function createRefreshQueue(input: QueueInput) { - const queued = new Set() + const queued = new Map() let root = false let running = false let timer: ReturnType | undefined + const key = input.key ?? ((directory: string) => directory) + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) const take = (count: number) => { if (queued.size === 0) return [] as string[] const items: string[] = [] - for (const item of queued) { - queued.delete(item) - items.push(item) + for (const [id, directory] of queued) { + queued.delete(id) + items.push(directory) if (items.length >= count) break } return items @@ -33,7 +36,7 @@ export function createRefreshQueue(input: QueueInput) { const push = (directory: string) => { if (!directory) return - queued.add(directory) + queued.set(key(directory), directory) if (input.paused()) return schedule() } @@ -73,7 +76,7 @@ export function createRefreshQueue(input: QueueInput) { push, refresh, clear(directory: string) { - queued.delete(directory) + queued.delete(key(directory)) }, dispose() { if (!timer) return diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index 6d44ac9a89..406c0f124e 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Agent } from "@opencode-ai/sdk/v2/client" -import { normalizeAgentList } from "./utils" +import { directoryKey, normalizeAgentList } from "./utils" const agent = (name = "build") => ({ @@ -33,3 +33,20 @@ describe("normalizeAgentList", () => { expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) }) }) + +describe("directoryKey", () => { + test("normalizes slashes", () => { + expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode") + expect(String(directoryKey("C:/Repos/sst/opencode"))).toBe("C:/Repos/sst/opencode") + }) + + test("preserves backslashes in posix paths", () => { + expect(String(directoryKey("/tmp/foo\\bar"))).toBe("/tmp/foo\\bar") + }) + + test("trims trailing slashes without breaking roots", () => { + expect(String(directoryKey("C:/Repos/sst/opencode/"))).toBe("C:/Repos/sst/opencode") + expect(String(directoryKey("C:/"))).toBe("C:/") + expect(String(directoryKey("/"))).toBe("/") + }) +}) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index cac58f3174..b982990884 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,4 +1,5 @@ import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index d9ce87a02e..27eae67c02 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -64,14 +64,8 @@ import { DebugBar } from "@/components/debug-bar" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" -import { - displayName, - effectiveWorkspaceOrder, - errorMessage, - latestRootSession, - sortedRootSessions, - workspaceKey, -} from "./layout/helpers" +import { pathKey } from "@/utils/path-key" +import { displayName, effectiveWorkspaceOrder, errorMessage, latestRootSession, sortedRootSessions } from "./layout/helpers" import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks, @@ -164,7 +158,7 @@ export default function Layout(props: ParentProps) { const editor = createInlineEditorController() const setBusy = (directory: string, value: boolean) => { - const key = workspaceKey(directory) + const key = pathKey(directory) if (value) { setState("busyWorkspaces", key, true) return @@ -176,7 +170,7 @@ export default function Layout(props: ParentProps) { }), ) } - const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)] + const isBusy = (directory: string) => !!state.busyWorkspaces[pathKey(directory)] const navLeave = { current: undefined as number | undefined } const sortNow = () => state.sortNow let sizet: number | undefined @@ -497,8 +491,8 @@ export default function Layout(props: ParentProps) { } const currentSession = params.id - if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return - if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return + if (pathKey(directory) === pathKey(currentDir()) && props.sessionID === currentSession) return + if (pathKey(directory) === pathKey(currentDir()) && session?.parentID === currentSession) return dismissSessionAlert(sessionKey) @@ -556,14 +550,14 @@ export default function Layout(props: ParentProps) { const currentProject = createMemo(() => { const directory = currentDir() if (!directory) return - const key = workspaceKey(directory) + const key = pathKey(directory) const projects = layout.projects.list() - const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key)) + const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key)) if (sandbox) return sandbox - const direct = projects.find((p) => workspaceKey(p.worktree) === key) + const direct = projects.find((p) => pathKey(p.worktree) === key) if (direct) return direct const [child] = globalSync.child(directory, { bootstrap: false }) @@ -596,7 +590,7 @@ export default function Layout(props: ParentProps) { }) const workspaceName = (directory: string, projectId?: string, branch?: string) => { - const key = workspaceKey(directory) + const key = pathKey(directory) const direct = store.workspaceName[key] ?? store.workspaceName[directory] if (direct) return direct if (!projectId) return @@ -605,7 +599,7 @@ export default function Layout(props: ParentProps) { } const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { - const key = workspaceKey(directory) + const key = pathKey(directory) setStore("workspaceName", key, next) if (!projectId) return if (!branch) return @@ -633,7 +627,7 @@ export default function Layout(props: ParentProps) { const activeDir = currentDir() return workspaceIds(project).filter((directory) => { const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree - const active = workspaceKey(directory) === workspaceKey(activeDir) + const active = pathKey(directory) === pathKey(activeDir) return expanded || active }) }) @@ -644,10 +638,10 @@ export default function Layout(props: ParentProps) { const projects = layout.projects.list() for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { if (!expanded) continue - const key = workspaceKey(directory) + const key = pathKey(directory) const project = projects.find( (item) => - workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), ) if (!project) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue @@ -700,7 +694,7 @@ export default function Layout(props: ParentProps) { seen: lru, keep: sessionID, limit: PREFETCH_MAX_SESSIONS_PER_DIR, - preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined, + preserve: params.id && pathKey(directory) === pathKey(currentDir()) ? [params.id] : undefined, }) } @@ -1221,17 +1215,17 @@ export default function Layout(props: ParentProps) { } function projectRoot(directory: string) { - const key = workspaceKey(directory) + const key = pathKey(directory) const project = layout.projects .list() .find( (item) => - workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key), + pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), ) if (project) return project.worktree const known = Object.entries(store.workspaceOrder).find( - ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key), + ([root, dirs]) => pathKey(root) === key || dirs.some((item) => pathKey(item) === key), ) if (known) return known[0] @@ -1283,7 +1277,7 @@ export default function Layout(props: ParentProps) { : [root] const canOpen = (value: string | undefined) => { if (!value) return false - return dirs.some((item) => workspaceKey(item) === workspaceKey(value)) + return dirs.some((item) => pathKey(item) === pathKey(value)) } const refreshDirs = async (target?: string) => { if (!target || target === root || canOpen(target)) return canOpen(target) @@ -1409,9 +1403,9 @@ export default function Layout(props: ParentProps) { function closeProject(directory: string) { const list = layout.projects.list() - const key = workspaceKey(directory) - const index = list.findIndex((x) => workspaceKey(x.worktree) === key) - const active = workspaceKey(currentProject()?.worktree ?? "") === key + const key = pathKey(directory) + const index = list.findIndex((x) => pathKey(x.worktree) === key) + const active = pathKey(currentProject()?.worktree ?? "") === key if (index === -1) return const next = list[index + 1] @@ -1485,8 +1479,8 @@ export default function Layout(props: ParentProps) { if (directory === root) return const current = currentDir() - const currentKey = workspaceKey(current) - const deletedKey = workspaceKey(directory) + const currentKey = pathKey(current) + const deletedKey = pathKey(directory) const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey) if (!leaveDeletedWorkspace && shouldLeave) { navigateWithSidebarReset(`/${base64Encode(root)}/session`) @@ -1509,7 +1503,7 @@ export default function Layout(props: ParentProps) { if (!result) return - if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) { + if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) { clearLastProjectSession(root) } @@ -1529,12 +1523,12 @@ export default function Layout(props: ParentProps) { if (shouldLeave) return const nextCurrent = currentDir() - const nextKey = workspaceKey(nextCurrent) + const nextKey = pathKey(nextCurrent) const project = layout.projects.list().find((item) => item.worktree === root) const dirs = project ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) : [root] - const valid = dirs.some((item) => workspaceKey(item) === nextKey) + const valid = dirs.some((item) => pathKey(item) === nextKey) if (params.dir && projectRoot(nextCurrent) === root && !valid) { navigateWithSidebarReset(`/${base64Encode(root)}/session`) @@ -1640,7 +1634,7 @@ export default function Layout(props: ParentProps) { }) const handleDelete = () => { - const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory) + const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory) if (leaveDeletedWorkspace) { navigateWithSidebarReset(`/${base64Encode(props.root)}/session`) } @@ -1867,11 +1861,11 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined + const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined const extra = directory && - workspaceKey(directory) !== workspaceKey(local) && - !dirs.some((item) => workspaceKey(item) === workspaceKey(directory)) + pathKey(directory) !== pathKey(local) && + !dirs.some((item) => pathKey(item) === pathKey(directory)) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false @@ -1916,7 +1910,7 @@ export default function Layout(props: ParentProps) { setStore( "workspaceOrder", project.worktree, - result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)), + result.filter((directory) => pathKey(directory) !== pathKey(project.worktree)), ) } @@ -1942,8 +1936,8 @@ export default function Layout(props: ParentProps) { setWorkspaceName(created.directory, created.branch, project.id, created.branch) const local = project.worktree - const key = workspaceKey(created.directory) - const root = workspaceKey(local) + const key = pathKey(created.directory) + const root = pathKey(local) setBusy(created.directory, true) WorktreeState.pending(created.directory) @@ -1954,7 +1948,7 @@ export default function Layout(props: ParentProps) { setStore("workspaceOrder", project.worktree, (prev) => { const existing = prev ?? [] const next = existing.filter((item) => { - const id = workspaceKey(item) + const id = pathKey(item) return id !== root && id !== key }) return [created.directory, ...next] diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 988332ab7c..9cf302482b 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -14,8 +14,8 @@ import { errorMessage, hasProjectPermissions, latestRootSession, - workspaceKey, } from "./helpers" +import { pathKey } from "@/utils/path-key" const session = (input: Partial & Pick) => ({ @@ -104,16 +104,16 @@ describe("layout deep links", () => { describe("layout workspace helpers", () => { test("normalizes trailing slash in workspace key", () => { - expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") - expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo") + expect(String(pathKey("/tmp/demo///"))).toBe("/tmp/demo") + expect(String(pathKey("C:\\tmp\\demo\\\\"))).toBe("C:/tmp/demo") }) test("preserves posix and drive roots in workspace key", () => { - expect(workspaceKey("/")).toBe("/") - expect(workspaceKey("///")).toBe("/") - expect(workspaceKey("C:\\")).toBe("C:/") - expect(workspaceKey("C://")).toBe("C:/") - expect(workspaceKey("C:///")).toBe("C:/") + expect(String(pathKey("/"))).toBe("/") + expect(String(pathKey("///"))).toBe("/") + expect(String(pathKey("C:\\"))).toBe("C:/") + expect(String(pathKey("C://"))).toBe("C:/") + expect(String(pathKey("C:///"))).toBe("C:/") }) test("keeps local first while preserving known order", () => { diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 4bc5254d95..d53381e404 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,19 +1,12 @@ import { getFilename } from "@opencode-ai/core/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" +import { pathKey } from "@/utils/path-key" type SessionStore = { session?: Session[] path: { directory: string } } -export const workspaceKey = (directory: string) => { - const value = directory.replaceAll("\\", "/") - const drive = value.match(/^([A-Za-z]:)\/+$/) - if (drive) return `${drive[1]}/` - if (/^\/+$/i.test(value)) return "/" - return value.replace(/\/+$/, "") -} - function sortSessions(now: number) { const oneMinuteAgo = now - 60 * 1000 return (a: Session, b: Session) => { @@ -29,7 +22,7 @@ function sortSessions(now: number) { } const isRootVisibleSession = (session: Session, directory: string) => - workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived export const roots = (store: SessionStore) => (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) @@ -72,11 +65,11 @@ export const errorMessage = (err: unknown, fallback: string) => { } export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => { - const root = workspaceKey(local) + const root = pathKey(local) const live = new Map() for (const dir of dirs) { - const key = workspaceKey(dir) + const key = pathKey(dir) if (key === root) continue if (!live.has(key)) live.set(key, dir) } @@ -85,7 +78,7 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted const result = [local] for (const dir of persisted) { - const key = workspaceKey(dir) + const key = pathKey(dir) if (key === root) continue const match = live.get(key) if (!match) continue diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 0a3fc7f41d..d2e887b444 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -16,8 +16,9 @@ import { type Session } from "@opencode-ai/sdk/v2/client" import { type LocalProject } from "@/context/layout" import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import { pathKey } from "@/utils/path-key" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" -import { sortedRootSessions, workspaceKey } from "./helpers" +import { sortedRootSessions } from "./helpers" import { useQuery } from "@tanstack/solid-query" type InlineEditorComponent = (props: { @@ -309,7 +310,7 @@ export const SortableWorkspace = (props: { const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const local = createMemo(() => props.directory === props.project.worktree) - const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) + const active = createMemo(() => pathKey(props.ctx.currentDir()) === pathKey(props.directory)) const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) diff --git a/packages/app/src/utils/path-key.ts b/packages/app/src/utils/path-key.ts new file mode 100644 index 0000000000..68d53e91d8 --- /dev/null +++ b/packages/app/src/utils/path-key.ts @@ -0,0 +1,24 @@ +export type PathKey = string & { _brand: "PathKey" } + +const isDrive = (value: string) => { + if (value.length !== 2) return false + const code = value.charCodeAt(0) + return value[1] === ":" && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122)) +} + +const trimTrailingSlashes = (value: string) => { + for (let i = value.length - 1; i >= 0; i--) { + if (value[i] !== "/") return value.slice(0, i + 1) + } + return "" +} + +const isWindowsPath = (value: string) => value[1] === ":" || value.startsWith("\\\\") + +export const pathKey = (path: string) => { + const value = isWindowsPath(path) ? path.replaceAll("\\", "/") : path + const trimmed = trimTrailingSlashes(value) + if (!trimmed && value.startsWith("/")) return "/" as PathKey + if (isDrive(trimmed)) return `${trimmed}/` as PathKey + return trimmed as PathKey +} diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 673acd224d..12e970eea0 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -1,6 +1,8 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" type PersistTestingType = typeof import("./persist").PersistTesting +type PersistType = typeof import("./persist").Persist +type RemovePersistedType = typeof import("./persist").removePersisted class MemoryStorage implements Storage { private values = new Map() @@ -45,6 +47,8 @@ class MemoryStorage implements Storage { const storage = new MemoryStorage() let persistTesting: PersistTestingType +let Persist: PersistType +let removePersisted: RemovePersistedType beforeAll(async () => { mock.module("@/context/platform", () => ({ @@ -53,6 +57,8 @@ beforeAll(async () => { const mod = await import("./persist") persistTesting = mod.PersistTesting + Persist = mod.Persist + removePersisted = mod.removePersisted }) beforeEach(() => { @@ -112,4 +118,50 @@ describe("persist localStorage resilience", () => { expect(result.endsWith(".dat")).toBeTrue() expect(/[:\\/]/.test(result)).toBeFalse() }) + + test("workspace target keeps raw path storage as legacy fallback", () => { + const target = Persist.workspace("C:\\Users\\foo", "vcs") + + expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) + expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) + }) + + test("workspace target keeps backslash storage as fallback for normalized Windows paths", () => { + const target = Persist.workspace("C:/Users/foo", "vcs") + + expect(target.storage).toBe(persistTesting.workspaceStorage("C:/Users/foo")) + expect(target.legacyStorageNames).toEqual([persistTesting.workspaceStorage("C:\\Users\\foo")]) + }) + + test("migrates direct legacy keys into scoped storage", () => { + storage.setItem("legacy.workspace", '{"value":2}') + const target = Persist.workspace("C:/Users/foo", "demo", ["legacy.workspace"]) + const current = persistTesting.localStorageWithPrefix(target.storage!) + const legacyStore = persistTesting.localStorageDirect() + + const result = persistTesting.migrateLegacy({ + current, + legacyStore, + stores: [], + keys: target.legacy!, + key: target.key, + defaults: { value: 1 }, + }) + + expect(result).toBe('{"value":2}') + expect(storage.getItem(`${target.storage}:${target.key}`)).toBe('{"value":2}') + expect(legacyStore.getItem("legacy.workspace")).toBeNull() + expect(storage.getItem("legacy.workspace")).toBeNull() + }) + + test("removes legacy workspace storage when removing persisted target", () => { + const target = Persist.workspace("C:\\Users\\foo", "terminal") + storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') + storage.setItem(`${target.legacyStorageNames![0]}:${target.key}`, '{"value":2}') + + removePersisted(target) + + expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() + expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull() + }) }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 0245527274..8f3e080738 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -3,6 +3,7 @@ import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primi import { checksum } from "@opencode-ai/core/util/encode" import { createResource, type Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" +import { pathKey } from "@/utils/path-key" type InitType = Promise | string | null type PersistedWithReady = [ @@ -14,6 +15,7 @@ type PersistedWithReady = [ type PersistTarget = { storage?: string + legacyStorageNames?: string[] key: string legacy?: string[] migrate?: (value: unknown) => unknown @@ -208,12 +210,153 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => return JSON.stringify(merged) } +function readCurrent(input: { + storage: SyncStorage + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + const raw = input.storage.getItem(input.key) + if (raw === null) return + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + input.storage.removeItem(input.key) + return null + } + if (raw !== next) input.storage.setItem(input.key, next) + return next +} + +function migrateLegacy(input: { + current: SyncStorage + legacyStore?: SyncStorage + stores: SyncStorage[] + keys: string[] + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + for (const store of input.stores) { + const raw = store.getItem(input.key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + store.removeItem(input.key) + continue + } + input.current.setItem(input.key, next) + store.removeItem(input.key) + return next + } + + if (!input.legacyStore) return null + + for (const key of input.keys) { + const raw = input.legacyStore.getItem(key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + input.legacyStore.removeItem(key) + continue + } + input.current.setItem(input.key, next) + input.legacyStore.removeItem(key) + return next + } + + return null +} + +async function readCurrentAsync(input: { + storage: AsyncStorage + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + const raw = await input.storage.getItem(input.key) + if (raw === null) return + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + await input.storage.removeItem(input.key).catch(() => undefined) + return null + } + if (raw !== next) await input.storage.setItem(input.key, next) + return next +} + +async function removeAsync(storage: AsyncStorage, key: string) { + try { + await storage.removeItem(key) + } catch {} +} + +async function migrateLegacyAsync(input: { + current: AsyncStorage + legacyStore?: AsyncStorage + stores: AsyncStorage[] + keys: string[] + key: string + defaults: unknown + migrate?: (value: unknown) => unknown +}) { + for (const store of input.stores) { + const raw = await store.getItem(input.key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + await removeAsync(store, input.key) + continue + } + await input.current.setItem(input.key, next) + await store.removeItem(input.key) + return next + } + + if (!input.legacyStore) return null + + for (const key of input.keys) { + const raw = await input.legacyStore.getItem(key) + if (raw === null) continue + + const next = normalize(input.defaults, raw, input.migrate) + if (next === undefined) { + await removeAsync(input.legacyStore, key) + continue + } + await input.current.setItem(input.key, next) + await input.legacyStore.removeItem(key) + return next + } + + return null +} + function workspaceStorage(dir: string) { const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") const sum = checksum(dir) ?? "0" return `opencode.workspace.${head}.${sum}.dat` } +function legacyWorkspaceStorage(dir: string) { + const storage = workspaceStorage(pathKey(dir)) + const result = new Set() + const raw = workspaceStorage(dir) + if (raw !== storage) result.add(raw) + + const key = pathKey(dir) + const drive = key.length >= 3 && key[1] === ":" && key[2] === "/" + if (drive) { + const backslash = workspaceStorage(key.replaceAll("/", "\\")) + if (backslash !== storage) result.add(backslash) + } + + if (result.size === 0) return + return [...result] +} + function localStorageWithPrefix(prefix: string): SyncStorage { const base = `${prefix}:` const scope = `prefix:${prefix}` @@ -304,6 +447,7 @@ function localStorageDirect(): SyncStorage { export const PersistTesting = { localStorageDirect, localStorageWithPrefix, + migrateLegacy, normalize, workspaceStorage, } @@ -313,10 +457,17 @@ export const Persist = { return { storage: GLOBAL_STORAGE, key, legacy } }, workspace(dir: string, key: string, legacy?: string[]): PersistTarget { - return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy } + const storage = workspaceStorage(pathKey(dir)) + return { storage, legacyStorageNames: legacyWorkspaceStorage(dir), key: `workspace:${key}`, legacy } }, session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget { - return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy } + const storage = workspaceStorage(pathKey(dir)) + return { + storage, + legacyStorageNames: legacyWorkspaceStorage(dir), + key: `session:${session}:${key}`, + legacy, + } }, scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget { if (session) return Persist.session(dir, session, key, legacy) @@ -324,11 +475,15 @@ export const Persist = { }, } -export function removePersisted(target: { storage?: string; key: string }, platform?: Platform) { +export function removePersisted(target: { storage?: string; legacyStorageNames?: string[]; key: string }, platform?: Platform) { const isDesktop = platform?.platform === "desktop" && !!platform.storage if (isDesktop) { - return platform.storage?.(target.storage)?.removeItem(target.key) + void platform.storage?.(target.storage)?.removeItem(target.key) + for (const storage of target.legacyStorageNames ?? []) { + void platform.storage?.(storage)?.removeItem(target.key) + } + return } if (!target.storage) { @@ -337,6 +492,9 @@ export function removePersisted(target: { storage?: string; key: string }, platf } localStorageWithPrefix(target.storage).removeItem(target.key) + for (const storage of target.legacyStorageNames ?? []) { + localStorageWithPrefix(storage).removeItem(target.key) + } } export function persisted( @@ -363,39 +521,27 @@ export function persisted( return platform.storage?.(LEGACY_STORAGE) })() + const legacyStorageNames = config.legacyStorageNames ?? [] + const storage = (() => { if (!isDesktop) { const current = currentStorage as SyncStorage const legacyStore = legacyStorage as SyncStorage + const legacyStores = legacyStorageNames.map(localStorageWithPrefix) const api: SyncStorage = { getItem: (key) => { - const raw = current.getItem(key) - if (raw !== null) { - const next = normalize(defaults, raw, config.migrate) - if (next === undefined) { - current.removeItem(key) - return null - } - if (raw !== next) current.setItem(key, next) - return next - } - - for (const legacyKey of legacy) { - const legacyRaw = legacyStore.getItem(legacyKey) - if (legacyRaw === null) continue - - const next = normalize(defaults, legacyRaw, config.migrate) - if (next === undefined) { - legacyStore.removeItem(legacyKey) - continue - } - current.setItem(key, next) - legacyStore.removeItem(legacyKey) - return next - } - - return null + const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate }) + if (value !== undefined) return value + return migrateLegacy({ + current, + legacyStore, + stores: legacyStores, + keys: legacy, + key, + defaults, + migrate: config.migrate, + }) }, setItem: (key, value) => { current.setItem(key, value) @@ -410,37 +556,21 @@ export function persisted( const current = currentStorage as AsyncStorage const legacyStore = legacyStorage as AsyncStorage | undefined + const legacyStores = legacyStorageNames.map((name) => platform.storage?.(name) as AsyncStorage | undefined).filter((x) => !!x) const api: AsyncStorage = { getItem: async (key) => { - const raw = await current.getItem(key) - if (raw !== null) { - const next = normalize(defaults, raw, config.migrate) - if (next === undefined) { - await current.removeItem(key).catch(() => undefined) - return null - } - if (raw !== next) await current.setItem(key, next) - return next - } - - if (!legacyStore) return null - - for (const legacyKey of legacy) { - const legacyRaw = await legacyStore.getItem(legacyKey) - if (legacyRaw === null) continue - - const next = normalize(defaults, legacyRaw, config.migrate) - if (next === undefined) { - await legacyStore.removeItem(legacyKey).catch(() => undefined) - continue - } - await current.setItem(key, next) - await legacyStore.removeItem(legacyKey) - return next - } - - return null + const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate }) + if (value !== undefined) return value + return migrateLegacyAsync({ + current, + legacyStore, + stores: legacyStores, + keys: legacy, + key, + defaults, + migrate: config.migrate, + }) }, setItem: async (key, value) => { await current.setItem(key, value) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index bca0d87507..e2bd9d7bcc 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -71,11 +71,11 @@ describe("tui thread", () => { async function check(project?: string) { setup() - await using tmp = await tmpdir({ git: true }) const cwd = process.cwd() const pwd = process.env.PWD const worker = globalThis.Worker const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") + await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") const type = process.platform === "win32" ? "junction" : "dir" seen.tui.length = 0 @@ -109,11 +109,12 @@ describe("tui thread", () => { } } - test("uses the real cwd when PWD points at a symlink", async () => { + // serial because both modify real env vars + test.serial("uses the real cwd when PWD points at a symlink", async () => { await check() }) - test("uses the real cwd after resolving a relative project from PWD", async () => { + test.serial("uses the real cwd after resolving a relative project from PWD", async () => { await check(".") }) })