summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context/global-sync
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-04-30 08:39:19 +1000
committerGitHub <[email protected]>2026-04-29 22:39:19 +0000
commitd7b7be1909d614a4022b345bdbeef0c1ec32e159 (patch)
treeb25640c571a0333b62cf7f06a0ee7ea24b8d3c68 /packages/app/src/context/global-sync
parenta740d2c66782ef3371146cd55d70920ae9b94daf (diff)
downloadopencode-d7b7be1909d614a4022b345bdbeef0c1ec32e159.tar.gz
opencode-d7b7be1909d614a4022b345bdbeef0c1ec32e159.zip
fix(desktop): Path mismatches cause sessions missing + strong ID + existing data fix (#25013)
Diffstat (limited to 'packages/app/src/context/global-sync')
-rw-r--r--packages/app/src/context/global-sync/child-store.ts100
-rw-r--r--packages/app/src/context/global-sync/queue.test.ts46
-rw-r--r--packages/app/src/context/global-sync/queue.ts15
-rw-r--r--packages/app/src/context/global-sync/utils.test.ts19
-rw-r--r--packages/app/src/context/global-sync/utils.ts1
5 files changed, 131 insertions, 50 deletions
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index d3b82894a..4c3c677a7 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<object, Set<string>>()
const disposers = new Map<string, () => 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> | 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 000000000..c9919855e
--- /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 5c228dac0..947e31ac9 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<void>
bootstrapInstance: (directory: string) => Promise<void> | void
+ key?: (directory: string) => string
}
export function createRefreshQueue(input: QueueInput) {
- const queued = new Set<string>()
+ const queued = new Map<string, string>()
let root = false
let running = false
let timer: ReturnType<typeof setTimeout> | undefined
+ const key = input.key ?? ((directory: string) => directory)
+
const tick = () => new Promise<void>((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 6d44ac9a8..406c0f124 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 cac58f317..b98299088 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)