summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/utils
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/utils
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/utils')
-rw-r--r--packages/app/src/utils/path-key.ts24
-rw-r--r--packages/app/src/utils/persist.test.ts52
-rw-r--r--packages/app/src/utils/persist.ts246
3 files changed, 264 insertions, 58 deletions
diff --git a/packages/app/src/utils/path-key.ts b/packages/app/src/utils/path-key.ts
new file mode 100644
index 000000000..68d53e91d
--- /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 673acd224..12e970eea 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<string, string>()
@@ -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 024552727..8f3e08073 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> | string | null
type PersistedWithReady<T> = [
@@ -14,6 +15,7 @@ type PersistedWithReady<T> = [
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<string>()
+ 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<T>(
@@ -363,39 +521,27 @@ export function persisted<T>(
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<T>(
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)