From 4406df377aba9cdb7abcd7643fddb432580b9183 Mon Sep 17 00:00:00 2001 From: "chenyiy@133.cn" Date: Wed, 8 Apr 2026 17:41:51 +0800 Subject: [PATCH] fix(web): normalize workspace paths to resolve Windows session list failures Closes #21441 - Client: use workspaceKey() for all child store internal map keys (children, vcsCache, metaCache, iconCache, lifecycle, pins, disposers) to prevent duplicate stores from mixed path formats (D:/foo vs D:\foo) - Client: add canonicalDir map to preserve original directory for API calls that require server-side exact match (isBooting, isLoadingSessions, onDispose) - Client: deduplicate loadSessions requests via sessionLoads keyed by workspaceKey() - Client: sortedRootSessions accepts optional dir fallback for bootstrap: false - Server: add normalizeDirectory() (mirrors workspaceKey() logic) for Session createNext (write) and Session.list/listGlobal (query) - Server: fromRow normalizes directory in API response for consistency --- packages/app/src/context/global-sync.tsx | 19 ++--- .../src/context/global-sync/child-store.ts | 71 +++++++++++-------- packages/app/src/pages/layout/helpers.ts | 9 +-- .../app/src/pages/layout/sidebar-project.tsx | 4 +- .../src/pages/layout/sidebar-workspace.tsx | 4 +- packages/opencode/src/session/index.ts | 17 +++-- 6 files changed, 75 insertions(+), 49 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0cf3570a8b3d..4e1ef09e3dc5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -26,6 +26,7 @@ import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { sanitizeProject } from "./global-sync/utils" import { formatServerError } from "@/utils/server-errors" +import { workspaceKey } from "@/pages/layout/helpers" type GlobalStore = { ready: boolean @@ -152,7 +153,7 @@ function createGlobalSync() { const children = createChildStoreManager({ owner, isBooting: (directory) => booting.has(directory), - isLoadingSessions: (directory) => sessionLoads.has(directory), + isLoadingSessions: (directory) => sessionLoads.has(workspaceKey(directory)), onBootstrap: (directory) => { void bootstrapInstance(directory) }, @@ -178,7 +179,8 @@ function createGlobalSync() { } async function loadSessions(directory: string) { - const pending = sessionLoads.get(directory) + const key = workspaceKey(directory) + const pending = sessionLoads.get(key) if (pending) return pending children.pin(directory) @@ -236,9 +238,9 @@ function createGlobalSync() { }) }) - sessionLoads.set(directory, promise) + sessionLoads.set(key, promise) promise.finally(() => { - sessionLoads.delete(directory) + sessionLoads.delete(key) children.unpin(directory) }) return promise @@ -252,7 +254,7 @@ function createGlobalSync() { children.pin(directory) const promise = (async () => { const child = children.ensureChild(directory) - const cache = children.vcsCache.get(directory) + const cache = children.vcsCache.get(workspaceKey(directory)) if (!cache) return const sdk = sdkFor(directory) await bootstrapDirectory({ @@ -297,14 +299,15 @@ function createGlobalSync() { }) if (event.type === "server.connected" || event.type === "global.disposed") { if (recent) return - for (const directory of Object.keys(children.children)) { + for (const directory of children.canonicalDir.values()) { queue.push(directory) } } return } - const existing = children.children[directory] + const key = workspaceKey(directory) + const existing = children.children[key] if (!existing) return children.mark(directory) const [store, setStore] = existing @@ -315,7 +318,7 @@ function createGlobalSync() { setStore, push: queue.push, setSessionTodo, - vcsCache: children.vcsCache.get(directory), + vcsCache: children.vcsCache.get(workspaceKey(directory)), loadLsp: () => { sdkFor(directory) .lsp.status() diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 5678491f8973..a147c1658bb7 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -14,6 +14,7 @@ import { type VcsCache, } from "./types" import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction" +import { workspaceKey } from "@/pages/layout/helpers" export function createChildStoreManager(input: { owner: Owner @@ -31,31 +32,35 @@ export function createChildStoreManager(input: { const pins = new Map() const ownerPins = new WeakMap>() const disposers = new Map void>() + const canonicalDir = new Map() const mark = (directory: string) => { if (!directory) return - lifecycle.set(directory, { lastAccessAt: Date.now() }) - runEviction(directory) + const k = workspaceKey(directory) + lifecycle.set(k, { lastAccessAt: Date.now() }) + runEviction(k) } const pin = (directory: string) => { if (!directory) return - pins.set(directory, (pins.get(directory) ?? 0) + 1) + const k = workspaceKey(directory) + pins.set(k, (pins.get(k) ?? 0) + 1) mark(directory) } const unpin = (directory: string) => { if (!directory) return - const next = (pins.get(directory) ?? 0) - 1 + const k = workspaceKey(directory) + const next = (pins.get(k) ?? 0) - 1 if (next > 0) { - pins.set(directory, next) + pins.set(k, next) return } - pins.delete(directory) + pins.delete(k) runEviction() } - const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0 + const pinned = (directory: string) => (pins.get(workspaceKey(directory)) ?? 0) > 0 const pinForOwner = (directory: string) => { const current = getOwner() @@ -78,29 +83,32 @@ export function createChildStoreManager(input: { } function disposeDirectory(directory: string) { + const key = workspaceKey(directory) + const orig = canonicalDir.get(key) ?? directory if ( !canDisposeDirectory({ - directory, - hasStore: !!children[directory], + directory: orig, + hasStore: !!children[key], pinned: pinned(directory), - booting: input.isBooting(directory), - loadingSessions: input.isLoadingSessions(directory), + booting: input.isBooting(orig), + loadingSessions: input.isLoadingSessions(orig), }) ) { 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) + canonicalDir.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(orig) return true } @@ -123,7 +131,11 @@ export function createChildStoreManager(input: { function ensureChild(directory: string) { if (!directory) console.error("No directory provided") - if (!children[directory]) { + const key = workspaceKey(directory) + // Prefer backslash format on Windows for API calls that need server-side exact match. + // If backslash path is available, always use it as the canonical form. + if (!canonicalDir.has(key) || directory.includes("\\")) canonicalDir.set(key, directory) + if (!children[key]) { const vcs = runWithOwner(input.owner, () => persisted( Persist.workspace(directory, "vcs", ["vcs.v1"]), @@ -132,7 +144,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( @@ -141,7 +153,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( @@ -150,7 +162,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) => { @@ -183,13 +195,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() }) } @@ -214,7 +226,7 @@ export function createChildStoreManager(input: { runWithOwner(input.owner, init) } mark(directory) - const childStore = children[directory] + const childStore = children[key] if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) return childStore } @@ -240,7 +252,7 @@ export function createChildStoreManager(input: { function projectMeta(directory: string, patch: ProjectMeta) { const [store, setStore] = ensureChild(directory) - const cached = metaCache.get(directory) + const cached = metaCache.get(workspaceKey(directory)) if (!cached) return const previous = store.projectMeta ?? {} const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon @@ -257,7 +269,7 @@ export function createChildStoreManager(input: { function projectIcon(directory: string, value: string | undefined) { const [store, setStore] = ensureChild(directory) - const cached = iconCache.get(directory) + const cached = iconCache.get(workspaceKey(directory)) if (!cached) return if (store.icon === value) return cached.setStore("value", value) @@ -266,6 +278,7 @@ export function createChildStoreManager(input: { return { children, + canonicalDir, ensureChild, child, peek, diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 48158debba1d..8548c9f868ed 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -31,13 +31,14 @@ function sortSessions(now: number) { const isRootVisibleSession = (session: Session, directory: string) => workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived -const roots = (store: SessionStore) => - (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) +const roots = (store: SessionStore, dir?: string) => + (store.session ?? []).filter((session) => isRootVisibleSession(session, dir ?? store.path.directory)) -export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now)) +export const sortedRootSessions = (store: SessionStore, now: number, dir?: string) => + roots(store, dir).sort(sortSessions(now)) export const latestRootSession = (stores: SessionStore[], now: number) => - stores.flatMap(roots).sort(sortSessions(now))[0] + stores.flatMap((s) => roots(s)).sort(sortSessions(now))[0] export function hasProjectPermissions( request: Record | undefined, diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 7c9ae1aafba6..f847788e7981 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -301,10 +301,10 @@ export const SortableProject = (props: { } const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) - const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow(), props.project.worktree)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - return sortedRootSessions(data, props.sortNow()) + return sortedRootSessions(data, props.sortNow(), directory) } const tile = () => ( base64Encode(props.directory)) - const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow(), props.directory)) const local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => workspaceKey(props.ctx.currentDir()) === workspaceKey(props.directory)) const workspaceValue = createMemo(() => { @@ -451,7 +451,7 @@ export const LocalWorkspace = (props: { return { store, setStore } }) const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow())) + const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow(), props.project.worktree)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const count = createMemo(() => sessions()?.length ?? 0) const loading = createMemo(() => !booted() && count() === 0) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 65032de96252..17e994aced04 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -42,6 +42,15 @@ export namespace Session { const parentTitlePrefix = "New session - " const childTitlePrefix = "Child session - " + // NOTE: normalizeDirectory must stay in sync with workspaceKey() in packages/app/src/pages/layout/helpers.ts + const normalizeDirectory = (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 createDefaultTitle(isChild = false) { return (isChild ? childTitlePrefix : parentTitlePrefix) + new Date().toISOString() } @@ -71,7 +80,7 @@ export namespace Session { slug: row.slug, projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, - directory: row.directory, + directory: normalizeDirectory(row.directory), parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -389,7 +398,7 @@ export namespace Session { slug: Slug.create(), version: Installation.VERSION, projectID: ctx.project.id, - directory: input.directory, + directory: normalizeDirectory(input.directory), workspaceID: input.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), @@ -751,7 +760,7 @@ export namespace Session { conditions.push(eq(SessionTable.workspace_id, input.workspaceID)) } if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, normalizeDirectory(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -791,7 +800,7 @@ export namespace Session { const conditions: SQL[] = [] if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, normalizeDirectory(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id))