fix(desktop): Path mismatches cause sessions missing + strong ID + existing data fix (#25013)

This commit is contained in:
Luke Parker
2026-04-29 22:39:19 +00:00
committed by GitHub
parent a740d2c667
commit d7b7be1909
14 changed files with 485 additions and 202 deletions
+35 -28
View File
@@ -33,6 +33,7 @@ import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue" import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -169,18 +170,20 @@ function createGlobalSync() {
const queue = createRefreshQueue({ const queue = createRefreshQueue({
paused, paused,
key: directoryKey,
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
bootstrapInstance, bootstrapInstance,
}) })
const sdkFor = (directory: string) => { const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory) const key = directoryKey(directory)
const cached = sdkCache.get(key)
if (cached) return cached if (cached) return cached
const sdk = globalSDK.createClient({ const sdk = globalSDK.createClient({
directory, directory,
throwOnError: true, throwOnError: true,
}) })
sdkCache.set(directory, sdk) sdkCache.set(key, sdk)
return sdk return sdk
} }
@@ -192,23 +195,25 @@ function createGlobalSync() {
void bootstrapInstance(directory) void bootstrapInstance(directory)
}, },
onDispose: (directory) => { onDispose: (directory) => {
queue.clear(directory) const key = directoryKey(directory)
sessionMeta.delete(directory) queue.clear(key)
sdkCache.delete(directory) sessionMeta.delete(key)
clearProviderRev(directory) sdkCache.delete(key)
clearSessionPrefetchDirectory(directory) clearProviderRev(key)
clearSessionPrefetchDirectory(key)
}, },
translate: language.t, translate: language.t,
getSdk: sdkFor, getSdk: sdkFor,
}) })
async function loadSessions(directory: string) { async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory) const key = directoryKey(directory)
const pending = sessionLoads.get(key)
if (pending) return pending if (pending) return pending
children.pin(directory) children.pin(key)
const [store, setStore] = children.child(directory, { bootstrap: false }) const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory) const meta = sessionMeta.get(key)
if (meta && meta.limit >= store.limit) { if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { const next = trimSessions(store.session, {
limit: store.limit, limit: store.limit,
@@ -218,14 +223,14 @@ function createGlobalSync() {
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo) cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
} }
children.unpin(directory) children.unpin(key)
return return
} }
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient const promise = queryClient
.fetchQuery({ .fetchQuery({
...loadSessionsQuery(directory), ...loadSessionsQuery(key),
queryFn: () => queryFn: () =>
loadRootSessionsWithFallback({ loadRootSessionsWithFallback({
directory, directory,
@@ -255,7 +260,7 @@ function createGlobalSync() {
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
}) })
sessionMeta.set(directory, { limit }) sessionMeta.set(key, { limit })
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to load sessions", err) console.error("Failed to load sessions", err)
@@ -270,23 +275,24 @@ function createGlobalSync() {
}) })
.then(() => {}) .then(() => {})
sessionLoads.set(directory, promise) sessionLoads.set(key, promise)
void promise.finally(() => { void promise.finally(() => {
sessionLoads.delete(directory) sessionLoads.delete(key)
children.unpin(directory) children.unpin(key)
}) })
return promise return promise
} }
async function bootstrapInstance(directory: string) { async function bootstrapInstance(directory: string) {
if (!directory) return const key = directoryKey(directory)
const pending = booting.get(directory) if (!key) return
const pending = booting.get(key)
if (pending) return pending if (pending) return pending
children.pin(directory) children.pin(key)
const promise = Promise.resolve().then(async () => { const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory) const child = children.ensureChild(directory)
const cache = children.vcsCache.get(directory) const cache = children.vcsCache.get(key)
if (!cache) return if (!cache) return
const sdk = sdkFor(directory) const sdk = sdkFor(directory)
await bootstrapDirectory({ await bootstrapDirectory({
@@ -307,16 +313,17 @@ function createGlobalSync() {
}) })
}) })
booting.set(directory, promise) booting.set(key, promise)
void promise.finally(() => { void promise.finally(() => {
booting.delete(directory) booting.delete(key)
children.unpin(directory) children.unpin(key)
}) })
return promise return promise
} }
const unsub = globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const key = directoryKey(directory)
const event = e.details const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500 const recent = bootingRoot || Date.now() - bootedAt < 1500
@@ -339,9 +346,9 @@ function createGlobalSync() {
return return
} }
const existing = children.children[directory] const existing = children.children[key]
if (!existing) return if (!existing) return
children.mark(directory) children.mark(key)
const [store, setStore] = existing const [store, setStore] = existing
applyDirectoryEvent({ applyDirectoryEvent({
event, event,
@@ -350,9 +357,9 @@ function createGlobalSync() {
setStore, setStore,
push: queue.push, push: queue.push,
setSessionTodo, setSessionTodo,
vcsCache: children.vcsCache.get(directory), vcsCache: children.vcsCache.get(key),
loadLsp: () => { loadLsp: () => {
void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory))) void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory)))
}, },
}) })
}) })
@@ -363,7 +370,7 @@ function createGlobalSync() {
}) })
onCleanup(() => { onCleanup(() => {
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
children.disposeDirectory(directory) children.disposeDirectory(directoryKey(directory))
} }
}) })
@@ -17,6 +17,7 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query" import { useQueries } from "@tanstack/solid-query"
import { loadPathQuery, loadProvidersQuery } from "./bootstrap" import { loadPathQuery, loadProvidersQuery } from "./bootstrap"
import { loadLspQuery, loadMcpQuery } from "../global-sync" import { loadLspQuery, loadMcpQuery } from "../global-sync"
import { directoryKey, type DirectoryKey } from "./utils"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
@@ -36,30 +37,37 @@ export function createChildStoreManager(input: {
const ownerPins = new WeakMap<object, Set<string>>() const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>() 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) => { const mark = (directory: string) => {
if (!directory) return const key = directoryKey(directory)
lifecycle.set(directory, { lastAccessAt: Date.now() }) markKey(key)
runEviction(directory)
} }
const pin = (directory: string) => { const pin = (directory: string) => {
if (!directory) return const key = directoryKey(directory)
pins.set(directory, (pins.get(directory) ?? 0) + 1) if (!key) return
mark(directory) pins.set(key, (pins.get(key) ?? 0) + 1)
markKey(key)
} }
const unpin = (directory: string) => { const unpin = (directory: string) => {
if (!directory) return const key = directoryKey(directory)
const next = (pins.get(directory) ?? 0) - 1 if (!key) return
const next = (pins.get(key) ?? 0) - 1
if (next > 0) { if (next > 0) {
pins.set(directory, next) pins.set(key, next)
return return
} }
pins.delete(directory) pins.delete(key)
runEviction() 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 pinForOwner = (directory: string) => {
const current = getOwner() const current = getOwner()
@@ -81,30 +89,31 @@ export function createChildStoreManager(input: {
}) })
} }
function disposeDirectory(directory: string) { function disposeDirectory(directory: DirectoryKey) {
const key = directory
if ( if (
!canDisposeDirectory({ !canDisposeDirectory({
directory, directory: key,
hasStore: !!children[directory], hasStore: !!children[key],
pinned: pinned(directory), pinned: pinned(key),
booting: input.isBooting(directory), booting: input.isBooting(key),
loadingSessions: input.isLoadingSessions(directory), loadingSessions: input.isLoadingSessions(key),
}) })
) { ) {
return false return false
} }
vcsCache.delete(directory) vcsCache.delete(key)
metaCache.delete(directory) metaCache.delete(key)
iconCache.delete(directory) iconCache.delete(key)
lifecycle.delete(directory) lifecycle.delete(key)
const dispose = disposers.get(directory) const dispose = disposers.get(key)
if (dispose) { if (dispose) {
dispose() dispose()
disposers.delete(directory) disposers.delete(key)
} }
delete children[directory] delete children[key]
input.onDispose(directory) input.onDispose(key)
return true return true
} }
@@ -121,13 +130,14 @@ export function createChildStoreManager(input: {
}).filter((directory) => directory !== skip) }).filter((directory) => directory !== skip)
if (list.length === 0) return if (list.length === 0) return
for (const directory of list) { for (const directory of list) {
if (!disposeDirectory(directory)) continue if (!disposeDirectory(directoryKey(directory))) continue
} }
} }
function ensureChild(directory: string) { function ensureChild(directory: string) {
if (!directory) console.error("No directory provided") const key = directoryKey(directory)
if (!children[directory]) { if (!key) console.error("No directory provided")
if (!children[key]) {
const vcs = runWithOwner(input.owner, () => const vcs = runWithOwner(input.owner, () =>
persisted( persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]), Persist.workspace(directory, "vcs", ["vcs.v1"]),
@@ -136,7 +146,7 @@ export function createChildStoreManager(input: {
) )
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0] 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, () => const meta = runWithOwner(input.owner, () =>
persisted( persisted(
@@ -145,7 +155,7 @@ export function createChildStoreManager(input: {
), ),
) )
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) 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, () => const icon = runWithOwner(input.owner, () =>
persisted( persisted(
@@ -154,7 +164,7 @@ export function createChildStoreManager(input: {
), ),
) )
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) 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 = () => const init = () =>
createRoot((dispose) => { createRoot((dispose) => {
@@ -165,10 +175,10 @@ export function createChildStoreManager(input: {
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [ queries: [
loadPathQuery(directory, sdk), loadPathQuery(key, sdk),
loadMcpQuery(directory, sdk), loadMcpQuery(key, sdk),
loadLspQuery(directory, sdk), loadLspQuery(key, sdk),
loadProvidersQuery(directory, sdk), loadProvidersQuery(key, sdk),
], ],
})) }))
@@ -213,13 +223,13 @@ export function createChildStoreManager(input: {
message: {}, message: {},
part: {}, part: {},
}) })
children[directory] = child children[key] = child
disposers.set(directory, dispose) disposers.set(key, dispose)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => { const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return if (!(init instanceof Promise)) return
void init.then(() => { void init.then(() => {
if (children[directory] !== child) return if (children[key] !== child) return
run() run()
}) })
} }
@@ -243,15 +253,16 @@ export function createChildStoreManager(input: {
runWithOwner(input.owner, init) runWithOwner(input.owner, init)
} }
mark(directory) markKey(key)
const childStore = children[directory] const childStore = children[key]
if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
return childStore return childStore
} }
function child(directory: string, options: ChildOptions = {}) { function child(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory) const childStore = ensureChild(directory)
pinForOwner(directory) pinForOwner(key)
const shouldBootstrap = options.bootstrap ?? true const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") { if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory) input.onBootstrap(directory)
@@ -260,6 +271,7 @@ export function createChildStoreManager(input: {
} }
function peek(directory: string, options: ChildOptions = {}) { function peek(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory) const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") { if (shouldBootstrap && childStore[0].status === "loading") {
@@ -269,8 +281,9 @@ export function createChildStoreManager(input: {
} }
function projectMeta(directory: string, patch: ProjectMeta) { function projectMeta(directory: string, patch: ProjectMeta) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory) const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(directory) const cached = metaCache.get(key)
if (!cached) return if (!cached) return
const previous = store.projectMeta ?? {} const previous = store.projectMeta ?? {}
const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon 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) { function projectIcon(directory: string, value: string | undefined) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory) const [store, setStore] = ensureChild(directory)
const cached = iconCache.get(directory) const cached = iconCache.get(key)
if (!cached) return if (!cached) return
if (store.icon === value) return if (store.icon === value) return
cached.setStore("value", value) cached.setStore("value", value)
@@ -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()
})
})
@@ -2,22 +2,25 @@ type QueueInput = {
paused: () => boolean paused: () => boolean
bootstrap: () => Promise<void> bootstrap: () => Promise<void>
bootstrapInstance: (directory: string) => Promise<void> | void bootstrapInstance: (directory: string) => Promise<void> | void
key?: (directory: string) => string
} }
export function createRefreshQueue(input: QueueInput) { export function createRefreshQueue(input: QueueInput) {
const queued = new Set<string>() const queued = new Map<string, string>()
let root = false let root = false
let running = false let running = false
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
const key = input.key ?? ((directory: string) => directory)
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
const take = (count: number) => { const take = (count: number) => {
if (queued.size === 0) return [] as string[] if (queued.size === 0) return [] as string[]
const items: string[] = [] const items: string[] = []
for (const item of queued) { for (const [id, directory] of queued) {
queued.delete(item) queued.delete(id)
items.push(item) items.push(directory)
if (items.length >= count) break if (items.length >= count) break
} }
return items return items
@@ -33,7 +36,7 @@ export function createRefreshQueue(input: QueueInput) {
const push = (directory: string) => { const push = (directory: string) => {
if (!directory) return if (!directory) return
queued.add(directory) queued.set(key(directory), directory)
if (input.paused()) return if (input.paused()) return
schedule() schedule()
} }
@@ -73,7 +76,7 @@ export function createRefreshQueue(input: QueueInput) {
push, push,
refresh, refresh,
clear(directory: string) { clear(directory: string) {
queued.delete(directory) queued.delete(key(directory))
}, },
dispose() { dispose() {
if (!timer) return if (!timer) return
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client" import type { Agent } from "@opencode-ai/sdk/v2/client"
import { normalizeAgentList } from "./utils" import { directoryKey, normalizeAgentList } from "./utils"
const agent = (name = "build") => const agent = (name = "build") =>
({ ({
@@ -33,3 +33,20 @@ describe("normalizeAgentList", () => {
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) 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("/")
})
})
@@ -1,4 +1,5 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" 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) export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+35 -41
View File
@@ -64,14 +64,8 @@ import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar" import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language" import { useLanguage, type Locale } from "@/context/language"
import { import { pathKey } from "@/utils/path-key"
displayName, import { displayName, effectiveWorkspaceOrder, errorMessage, latestRootSession, sortedRootSessions } from "./layout/helpers"
effectiveWorkspaceOrder,
errorMessage,
latestRootSession,
sortedRootSessions,
workspaceKey,
} from "./layout/helpers"
import { import {
collectNewSessionDeepLinks, collectNewSessionDeepLinks,
collectOpenProjectDeepLinks, collectOpenProjectDeepLinks,
@@ -164,7 +158,7 @@ export default function Layout(props: ParentProps) {
const editor = createInlineEditorController() const editor = createInlineEditorController()
const setBusy = (directory: string, value: boolean) => { const setBusy = (directory: string, value: boolean) => {
const key = workspaceKey(directory) const key = pathKey(directory)
if (value) { if (value) {
setState("busyWorkspaces", key, true) setState("busyWorkspaces", key, true)
return 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 navLeave = { current: undefined as number | undefined }
const sortNow = () => state.sortNow const sortNow = () => state.sortNow
let sizet: number | undefined let sizet: number | undefined
@@ -497,8 +491,8 @@ export default function Layout(props: ParentProps) {
} }
const currentSession = params.id const currentSession = params.id
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return if (pathKey(directory) === pathKey(currentDir()) && props.sessionID === currentSession) return
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return if (pathKey(directory) === pathKey(currentDir()) && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey) dismissSessionAlert(sessionKey)
@@ -556,14 +550,14 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => { const currentProject = createMemo(() => {
const directory = currentDir() const directory = currentDir()
if (!directory) return if (!directory) return
const key = workspaceKey(directory) const key = pathKey(directory)
const projects = layout.projects.list() 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 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 if (direct) return direct
const [child] = globalSync.child(directory, { bootstrap: false }) 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 workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = workspaceKey(directory) const key = pathKey(directory)
const direct = store.workspaceName[key] ?? store.workspaceName[directory] const direct = store.workspaceName[key] ?? store.workspaceName[directory]
if (direct) return direct if (direct) return direct
if (!projectId) return if (!projectId) return
@@ -605,7 +599,7 @@ export default function Layout(props: ParentProps) {
} }
const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
const key = workspaceKey(directory) const key = pathKey(directory)
setStore("workspaceName", key, next) setStore("workspaceName", key, next)
if (!projectId) return if (!projectId) return
if (!branch) return if (!branch) return
@@ -633,7 +627,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir() const activeDir = currentDir()
return workspaceIds(project).filter((directory) => { return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = workspaceKey(directory) === workspaceKey(activeDir) const active = pathKey(directory) === pathKey(activeDir)
return expanded || active return expanded || active
}) })
}) })
@@ -644,10 +638,10 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list() const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (!expanded) continue if (!expanded) continue
const key = workspaceKey(directory) const key = pathKey(directory)
const project = projects.find( const project = projects.find(
(item) => (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) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
@@ -700,7 +694,7 @@ export default function Layout(props: ParentProps) {
seen: lru, seen: lru,
keep: sessionID, keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR, 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) { function projectRoot(directory: string) {
const key = workspaceKey(directory) const key = pathKey(directory)
const project = layout.projects const project = layout.projects
.list() .list()
.find( .find(
(item) => (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 if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find( 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] if (known) return known[0]
@@ -1283,7 +1277,7 @@ export default function Layout(props: ParentProps) {
: [root] : [root]
const canOpen = (value: string | undefined) => { const canOpen = (value: string | undefined) => {
if (!value) return false 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) => { const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target) if (!target || target === root || canOpen(target)) return canOpen(target)
@@ -1409,9 +1403,9 @@ export default function Layout(props: ParentProps) {
function closeProject(directory: string) { function closeProject(directory: string) {
const list = layout.projects.list() const list = layout.projects.list()
const key = workspaceKey(directory) const key = pathKey(directory)
const index = list.findIndex((x) => workspaceKey(x.worktree) === key) const index = list.findIndex((x) => pathKey(x.worktree) === key)
const active = workspaceKey(currentProject()?.worktree ?? "") === key const active = pathKey(currentProject()?.worktree ?? "") === key
if (index === -1) return if (index === -1) return
const next = list[index + 1] const next = list[index + 1]
@@ -1485,8 +1479,8 @@ export default function Layout(props: ParentProps) {
if (directory === root) return if (directory === root) return
const current = currentDir() const current = currentDir()
const currentKey = workspaceKey(current) const currentKey = pathKey(current)
const deletedKey = workspaceKey(directory) const deletedKey = pathKey(directory)
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey) const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
if (!leaveDeletedWorkspace && shouldLeave) { if (!leaveDeletedWorkspace && shouldLeave) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`) navigateWithSidebarReset(`/${base64Encode(root)}/session`)
@@ -1509,7 +1503,7 @@ export default function Layout(props: ParentProps) {
if (!result) return if (!result) return
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) { if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) {
clearLastProjectSession(root) clearLastProjectSession(root)
} }
@@ -1529,12 +1523,12 @@ export default function Layout(props: ParentProps) {
if (shouldLeave) return if (shouldLeave) return
const nextCurrent = currentDir() const nextCurrent = currentDir()
const nextKey = workspaceKey(nextCurrent) const nextKey = pathKey(nextCurrent)
const project = layout.projects.list().find((item) => item.worktree === root) const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = project const dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
: [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) { if (params.dir && projectRoot(nextCurrent) === root && !valid) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`) navigateWithSidebarReset(`/${base64Encode(root)}/session`)
@@ -1640,7 +1634,7 @@ export default function Layout(props: ParentProps) {
}) })
const handleDelete = () => { const handleDelete = () => {
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory) const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory)
if (leaveDeletedWorkspace) { if (leaveDeletedWorkspace) {
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`) navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
} }
@@ -1867,11 +1861,11 @@ export default function Layout(props: ParentProps) {
const local = project.worktree const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])] const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject() 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 = const extra =
directory && directory &&
workspaceKey(directory) !== workspaceKey(local) && pathKey(directory) !== pathKey(local) &&
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory)) !dirs.some((item) => pathKey(item) === pathKey(directory))
? directory ? directory
: undefined : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
@@ -1916,7 +1910,7 @@ export default function Layout(props: ParentProps) {
setStore( setStore(
"workspaceOrder", "workspaceOrder",
project.worktree, 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) setWorkspaceName(created.directory, created.branch, project.id, created.branch)
const local = project.worktree const local = project.worktree
const key = workspaceKey(created.directory) const key = pathKey(created.directory)
const root = workspaceKey(local) const root = pathKey(local)
setBusy(created.directory, true) setBusy(created.directory, true)
WorktreeState.pending(created.directory) WorktreeState.pending(created.directory)
@@ -1954,7 +1948,7 @@ export default function Layout(props: ParentProps) {
setStore("workspaceOrder", project.worktree, (prev) => { setStore("workspaceOrder", project.worktree, (prev) => {
const existing = prev ?? [] const existing = prev ?? []
const next = existing.filter((item) => { const next = existing.filter((item) => {
const id = workspaceKey(item) const id = pathKey(item)
return id !== root && id !== key return id !== root && id !== key
}) })
return [created.directory, ...next] return [created.directory, ...next]
@@ -14,8 +14,8 @@ import {
errorMessage, errorMessage,
hasProjectPermissions, hasProjectPermissions,
latestRootSession, latestRootSession,
workspaceKey,
} from "./helpers" } from "./helpers"
import { pathKey } from "@/utils/path-key"
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) => const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({ ({
@@ -104,16 +104,16 @@ describe("layout deep links", () => {
describe("layout workspace helpers", () => { describe("layout workspace helpers", () => {
test("normalizes trailing slash in workspace key", () => { test("normalizes trailing slash in workspace key", () => {
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") expect(String(pathKey("/tmp/demo///"))).toBe("/tmp/demo")
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo") expect(String(pathKey("C:\\tmp\\demo\\\\"))).toBe("C:/tmp/demo")
}) })
test("preserves posix and drive roots in workspace key", () => { test("preserves posix and drive roots in workspace key", () => {
expect(workspaceKey("/")).toBe("/") expect(String(pathKey("/"))).toBe("/")
expect(workspaceKey("///")).toBe("/") expect(String(pathKey("///"))).toBe("/")
expect(workspaceKey("C:\\")).toBe("C:/") expect(String(pathKey("C:\\"))).toBe("C:/")
expect(workspaceKey("C://")).toBe("C:/") expect(String(pathKey("C://"))).toBe("C:/")
expect(workspaceKey("C:///")).toBe("C:/") expect(String(pathKey("C:///"))).toBe("C:/")
}) })
test("keeps local first while preserving known order", () => { test("keeps local first while preserving known order", () => {
+5 -12
View File
@@ -1,19 +1,12 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { pathKey } from "@/utils/path-key"
type SessionStore = { type SessionStore = {
session?: Session[] session?: Session[]
path: { directory: string } 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) { function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000 const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => { return (a: Session, b: Session) => {
@@ -29,7 +22,7 @@ function sortSessions(now: number) {
} }
const isRootVisibleSession = (session: Session, directory: string) => 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) => export const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) (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[]) => { export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
const root = workspaceKey(local) const root = pathKey(local)
const live = new Map<string, string>() const live = new Map<string, string>()
for (const dir of dirs) { for (const dir of dirs) {
const key = workspaceKey(dir) const key = pathKey(dir)
if (key === root) continue if (key === root) continue
if (!live.has(key)) live.set(key, dir) if (!live.has(key)) live.set(key, dir)
} }
@@ -85,7 +78,7 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
const result = [local] const result = [local]
for (const dir of persisted) { for (const dir of persisted) {
const key = workspaceKey(dir) const key = pathKey(dir)
if (key === root) continue if (key === root) continue
const match = live.get(key) const match = live.get(key)
if (!match) continue if (!match) continue
@@ -16,8 +16,9 @@ import { type Session } from "@opencode-ai/sdk/v2/client"
import { type LocalProject } from "@/context/layout" import { type LocalProject } from "@/context/layout"
import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync" import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items" import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
import { sortedRootSessions, workspaceKey } from "./helpers" import { sortedRootSessions } from "./helpers"
import { useQuery } from "@tanstack/solid-query" import { useQuery } from "@tanstack/solid-query"
type InlineEditorComponent = (props: { type InlineEditorComponent = (props: {
@@ -309,7 +310,7 @@ export const SortableWorkspace = (props: {
const slug = createMemo(() => base64Encode(props.directory)) const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow())) const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
const local = createMemo(() => props.directory === props.project.worktree) 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 workspaceValue = createMemo(() => {
const branch = workspaceStore.vcs?.branch const branch = workspaceStore.vcs?.branch
const name = branch ?? getFilename(props.directory) const name = branch ?? getFilename(props.directory)
+24
View File
@@ -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
}
+52
View File
@@ -1,6 +1,8 @@
import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"
type PersistTestingType = typeof import("./persist").PersistTesting type PersistTestingType = typeof import("./persist").PersistTesting
type PersistType = typeof import("./persist").Persist
type RemovePersistedType = typeof import("./persist").removePersisted
class MemoryStorage implements Storage { class MemoryStorage implements Storage {
private values = new Map<string, string>() private values = new Map<string, string>()
@@ -45,6 +47,8 @@ class MemoryStorage implements Storage {
const storage = new MemoryStorage() const storage = new MemoryStorage()
let persistTesting: PersistTestingType let persistTesting: PersistTestingType
let Persist: PersistType
let removePersisted: RemovePersistedType
beforeAll(async () => { beforeAll(async () => {
mock.module("@/context/platform", () => ({ mock.module("@/context/platform", () => ({
@@ -53,6 +57,8 @@ beforeAll(async () => {
const mod = await import("./persist") const mod = await import("./persist")
persistTesting = mod.PersistTesting persistTesting = mod.PersistTesting
Persist = mod.Persist
removePersisted = mod.removePersisted
}) })
beforeEach(() => { beforeEach(() => {
@@ -112,4 +118,50 @@ describe("persist localStorage resilience", () => {
expect(result.endsWith(".dat")).toBeTrue() expect(result.endsWith(".dat")).toBeTrue()
expect(/[:\\/]/.test(result)).toBeFalse() 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()
})
}) })
+188 -58
View File
@@ -3,6 +3,7 @@ import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primi
import { checksum } from "@opencode-ai/core/util/encode" import { checksum } from "@opencode-ai/core/util/encode"
import { createResource, type Accessor } from "solid-js" import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store" import type { SetStoreFunction, Store } from "solid-js/store"
import { pathKey } from "@/utils/path-key"
type InitType = Promise<string> | string | null type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [ type PersistedWithReady<T> = [
@@ -14,6 +15,7 @@ type PersistedWithReady<T> = [
type PersistTarget = { type PersistTarget = {
storage?: string storage?: string
legacyStorageNames?: string[]
key: string key: string
legacy?: string[] legacy?: string[]
migrate?: (value: unknown) => unknown migrate?: (value: unknown) => unknown
@@ -208,12 +210,153 @@ function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) =>
return JSON.stringify(merged) 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) { function workspaceStorage(dir: string) {
const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-") const head = (dir.slice(0, 12) || "workspace").replace(/[^a-zA-Z0-9._-]/g, "-")
const sum = checksum(dir) ?? "0" const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat` 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 { function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:` const base = `${prefix}:`
const scope = `prefix:${prefix}` const scope = `prefix:${prefix}`
@@ -304,6 +447,7 @@ function localStorageDirect(): SyncStorage {
export const PersistTesting = { export const PersistTesting = {
localStorageDirect, localStorageDirect,
localStorageWithPrefix, localStorageWithPrefix,
migrateLegacy,
normalize, normalize,
workspaceStorage, workspaceStorage,
} }
@@ -313,10 +457,17 @@ export const Persist = {
return { storage: GLOBAL_STORAGE, key, legacy } return { storage: GLOBAL_STORAGE, key, legacy }
}, },
workspace(dir: string, key: string, legacy?: string[]): PersistTarget { 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 { 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 { scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy) 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 const isDesktop = platform?.platform === "desktop" && !!platform.storage
if (isDesktop) { 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) { if (!target.storage) {
@@ -337,6 +492,9 @@ export function removePersisted(target: { storage?: string; key: string }, platf
} }
localStorageWithPrefix(target.storage).removeItem(target.key) localStorageWithPrefix(target.storage).removeItem(target.key)
for (const storage of target.legacyStorageNames ?? []) {
localStorageWithPrefix(storage).removeItem(target.key)
}
} }
export function persisted<T>( export function persisted<T>(
@@ -363,39 +521,27 @@ export function persisted<T>(
return platform.storage?.(LEGACY_STORAGE) return platform.storage?.(LEGACY_STORAGE)
})() })()
const legacyStorageNames = config.legacyStorageNames ?? []
const storage = (() => { const storage = (() => {
if (!isDesktop) { if (!isDesktop) {
const current = currentStorage as SyncStorage const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage const legacyStore = legacyStorage as SyncStorage
const legacyStores = legacyStorageNames.map(localStorageWithPrefix)
const api: SyncStorage = { const api: SyncStorage = {
getItem: (key) => { getItem: (key) => {
const raw = current.getItem(key) const value = readCurrent({ storage: current, key, defaults, migrate: config.migrate })
if (raw !== null) { if (value !== undefined) return value
const next = normalize(defaults, raw, config.migrate) return migrateLegacy({
if (next === undefined) { current,
current.removeItem(key) legacyStore,
return null stores: legacyStores,
} keys: legacy,
if (raw !== next) current.setItem(key, next) key,
return next defaults,
} migrate: config.migrate,
})
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
}, },
setItem: (key, value) => { setItem: (key, value) => {
current.setItem(key, value) current.setItem(key, value)
@@ -410,37 +556,21 @@ export function persisted<T>(
const current = currentStorage as AsyncStorage const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined const legacyStore = legacyStorage as AsyncStorage | undefined
const legacyStores = legacyStorageNames.map((name) => platform.storage?.(name) as AsyncStorage | undefined).filter((x) => !!x)
const api: AsyncStorage = { const api: AsyncStorage = {
getItem: async (key) => { getItem: async (key) => {
const raw = await current.getItem(key) const value = await readCurrentAsync({ storage: current, key, defaults, migrate: config.migrate })
if (raw !== null) { if (value !== undefined) return value
const next = normalize(defaults, raw, config.migrate) return migrateLegacyAsync({
if (next === undefined) { current,
await current.removeItem(key).catch(() => undefined) legacyStore,
return null stores: legacyStores,
} keys: legacy,
if (raw !== next) await current.setItem(key, next) key,
return next defaults,
} migrate: config.migrate,
})
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
}, },
setItem: async (key, value) => { setItem: async (key, value) => {
await current.setItem(key, value) await current.setItem(key, value)
@@ -71,11 +71,11 @@ describe("tui thread", () => {
async function check(project?: string) { async function check(project?: string) {
setup() setup()
await using tmp = await tmpdir({ git: true })
const cwd = process.cwd() const cwd = process.cwd()
const pwd = process.env.PWD const pwd = process.env.PWD
const worker = globalThis.Worker const worker = globalThis.Worker
const tty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY") 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 link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link")
const type = process.platform === "win32" ? "junction" : "dir" const type = process.platform === "win32" ? "junction" : "dir"
seen.tui.length = 0 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() 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(".") await check(".")
}) })
}) })