diff options
| author | Luke Parker <[email protected]> | 2026-04-30 08:39:19 +1000 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-04-29 22:39:19 +0000 |
| commit | d7b7be1909d614a4022b345bdbeef0c1ec32e159 (patch) | |
| tree | b25640c571a0333b62cf7f06a0ee7ea24b8d3c68 /packages/app/src/utils | |
| parent | a740d2c66782ef3371146cd55d70920ae9b94daf (diff) | |
| download | opencode-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.ts | 24 | ||||
| -rw-r--r-- | packages/app/src/utils/persist.test.ts | 52 | ||||
| -rw-r--r-- | packages/app/src/utils/persist.ts | 246 |
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) |
