mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-06 08:21:50 +00:00
fix(desktop): Path mismatches cause sessions missing + strong ID + existing data fix (#25013)
This commit is contained in:
parent
a740d2c667
commit
d7b7be1909
14 changed files with 485 additions and 202 deletions
|
|
@ -33,6 +33,7 @@ import { SESSION_RECENT_LIMIT } from "./global-sync/types"
|
|||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
|
|
@ -169,18 +170,20 @@ function createGlobalSync() {
|
|||
|
||||
const queue = createRefreshQueue({
|
||||
paused,
|
||||
key: directoryKey,
|
||||
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
|
||||
bootstrapInstance,
|
||||
})
|
||||
|
||||
const sdkFor = (directory: string) => {
|
||||
const cached = sdkCache.get(directory)
|
||||
const key = directoryKey(directory)
|
||||
const cached = sdkCache.get(key)
|
||||
if (cached) return cached
|
||||
const sdk = globalSDK.createClient({
|
||||
directory,
|
||||
throwOnError: true,
|
||||
})
|
||||
sdkCache.set(directory, sdk)
|
||||
sdkCache.set(key, sdk)
|
||||
return sdk
|
||||
}
|
||||
|
||||
|
|
@ -192,23 +195,25 @@ function createGlobalSync() {
|
|||
void bootstrapInstance(directory)
|
||||
},
|
||||
onDispose: (directory) => {
|
||||
queue.clear(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
clearProviderRev(directory)
|
||||
clearSessionPrefetchDirectory(directory)
|
||||
const key = directoryKey(directory)
|
||||
queue.clear(key)
|
||||
sessionMeta.delete(key)
|
||||
sdkCache.delete(key)
|
||||
clearProviderRev(key)
|
||||
clearSessionPrefetchDirectory(key)
|
||||
},
|
||||
translate: language.t,
|
||||
getSdk: sdkFor,
|
||||
})
|
||||
|
||||
async function loadSessions(directory: string) {
|
||||
const pending = sessionLoads.get(directory)
|
||||
const key = directoryKey(directory)
|
||||
const pending = sessionLoads.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
children.pin(directory)
|
||||
children.pin(key)
|
||||
const [store, setStore] = children.child(directory, { bootstrap: false })
|
||||
const meta = sessionMeta.get(directory)
|
||||
const meta = sessionMeta.get(key)
|
||||
if (meta && meta.limit >= store.limit) {
|
||||
const next = trimSessions(store.session, {
|
||||
limit: store.limit,
|
||||
|
|
@ -218,14 +223,14 @@ function createGlobalSync() {
|
|||
setStore("session", reconcile(next, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
|
||||
}
|
||||
children.unpin(directory)
|
||||
children.unpin(key)
|
||||
return
|
||||
}
|
||||
|
||||
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
|
||||
const promise = queryClient
|
||||
.fetchQuery({
|
||||
...loadSessionsQuery(directory),
|
||||
...loadSessionsQuery(key),
|
||||
queryFn: () =>
|
||||
loadRootSessionsWithFallback({
|
||||
directory,
|
||||
|
|
@ -255,7 +260,7 @@ function createGlobalSync() {
|
|||
setStore("session", reconcile(sessions, { key: "id" }))
|
||||
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
|
||||
})
|
||||
sessionMeta.set(directory, { limit })
|
||||
sessionMeta.set(key, { limit })
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load sessions", err)
|
||||
|
|
@ -270,23 +275,24 @@ function createGlobalSync() {
|
|||
})
|
||||
.then(() => {})
|
||||
|
||||
sessionLoads.set(directory, promise)
|
||||
sessionLoads.set(key, promise)
|
||||
void promise.finally(() => {
|
||||
sessionLoads.delete(directory)
|
||||
children.unpin(directory)
|
||||
sessionLoads.delete(key)
|
||||
children.unpin(key)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
async function bootstrapInstance(directory: string) {
|
||||
if (!directory) return
|
||||
const pending = booting.get(directory)
|
||||
const key = directoryKey(directory)
|
||||
if (!key) return
|
||||
const pending = booting.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
children.pin(directory)
|
||||
children.pin(key)
|
||||
const promise = Promise.resolve().then(async () => {
|
||||
const child = children.ensureChild(directory)
|
||||
const cache = children.vcsCache.get(directory)
|
||||
const cache = children.vcsCache.get(key)
|
||||
if (!cache) return
|
||||
const sdk = sdkFor(directory)
|
||||
await bootstrapDirectory({
|
||||
|
|
@ -307,16 +313,17 @@ function createGlobalSync() {
|
|||
})
|
||||
})
|
||||
|
||||
booting.set(directory, promise)
|
||||
booting.set(key, promise)
|
||||
void promise.finally(() => {
|
||||
booting.delete(directory)
|
||||
children.unpin(directory)
|
||||
booting.delete(key)
|
||||
children.unpin(key)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
const unsub = globalSDK.event.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
|
|
@ -339,9 +346,9 @@ function createGlobalSync() {
|
|||
return
|
||||
}
|
||||
|
||||
const existing = children.children[directory]
|
||||
const existing = children.children[key]
|
||||
if (!existing) return
|
||||
children.mark(directory)
|
||||
children.mark(key)
|
||||
const [store, setStore] = existing
|
||||
applyDirectoryEvent({
|
||||
event,
|
||||
|
|
@ -350,9 +357,9 @@ function createGlobalSync() {
|
|||
setStore,
|
||||
push: queue.push,
|
||||
setSessionTodo,
|
||||
vcsCache: children.vcsCache.get(directory),
|
||||
vcsCache: children.vcsCache.get(key),
|
||||
loadLsp: () => {
|
||||
void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory)))
|
||||
void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory)))
|
||||
},
|
||||
})
|
||||
})
|
||||
|
|
@ -363,7 +370,7 @@ function createGlobalSync() {
|
|||
})
|
||||
onCleanup(() => {
|
||||
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 { 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)
|
||||
|
|
|
|||
46
packages/app/src/context/global-sync/queue.test.ts
Normal file
46
packages/app/src/context/global-sync/queue.test.ts
Normal file
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -64,14 +64,8 @@ import { DebugBar } from "@/components/debug-bar"
|
|||
import { Titlebar } from "@/components/titlebar"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useLanguage, type Locale } from "@/context/language"
|
||||
import {
|
||||
displayName,
|
||||
effectiveWorkspaceOrder,
|
||||
errorMessage,
|
||||
latestRootSession,
|
||||
sortedRootSessions,
|
||||
workspaceKey,
|
||||
} from "./layout/helpers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { displayName, effectiveWorkspaceOrder, errorMessage, latestRootSession, sortedRootSessions } from "./layout/helpers"
|
||||
import {
|
||||
collectNewSessionDeepLinks,
|
||||
collectOpenProjectDeepLinks,
|
||||
|
|
@ -164,7 +158,7 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
const editor = createInlineEditorController()
|
||||
const setBusy = (directory: string, value: boolean) => {
|
||||
const key = workspaceKey(directory)
|
||||
const key = pathKey(directory)
|
||||
if (value) {
|
||||
setState("busyWorkspaces", key, true)
|
||||
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 sortNow = () => state.sortNow
|
||||
let sizet: number | undefined
|
||||
|
|
@ -497,8 +491,8 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
|
||||
const currentSession = params.id
|
||||
if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
|
||||
if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
|
||||
if (pathKey(directory) === pathKey(currentDir()) && props.sessionID === currentSession) return
|
||||
if (pathKey(directory) === pathKey(currentDir()) && session?.parentID === currentSession) return
|
||||
|
||||
dismissSessionAlert(sessionKey)
|
||||
|
||||
|
|
@ -556,14 +550,14 @@ export default function Layout(props: ParentProps) {
|
|||
const currentProject = createMemo(() => {
|
||||
const directory = currentDir()
|
||||
if (!directory) return
|
||||
const key = workspaceKey(directory)
|
||||
const key = pathKey(directory)
|
||||
|
||||
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
|
||||
|
||||
const direct = projects.find((p) => workspaceKey(p.worktree) === key)
|
||||
const direct = projects.find((p) => pathKey(p.worktree) === key)
|
||||
if (direct) return direct
|
||||
|
||||
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 key = workspaceKey(directory)
|
||||
const key = pathKey(directory)
|
||||
const direct = store.workspaceName[key] ?? store.workspaceName[directory]
|
||||
if (direct) return direct
|
||||
if (!projectId) return
|
||||
|
|
@ -605,7 +599,7 @@ export default function Layout(props: ParentProps) {
|
|||
}
|
||||
|
||||
const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
|
||||
const key = workspaceKey(directory)
|
||||
const key = pathKey(directory)
|
||||
setStore("workspaceName", key, next)
|
||||
if (!projectId) return
|
||||
if (!branch) return
|
||||
|
|
@ -633,7 +627,7 @@ export default function Layout(props: ParentProps) {
|
|||
const activeDir = currentDir()
|
||||
return workspaceIds(project).filter((directory) => {
|
||||
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
|
||||
const active = workspaceKey(directory) === workspaceKey(activeDir)
|
||||
const active = pathKey(directory) === pathKey(activeDir)
|
||||
return expanded || active
|
||||
})
|
||||
})
|
||||
|
|
@ -644,10 +638,10 @@ export default function Layout(props: ParentProps) {
|
|||
const projects = layout.projects.list()
|
||||
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
|
||||
if (!expanded) continue
|
||||
const key = workspaceKey(directory)
|
||||
const key = pathKey(directory)
|
||||
const project = projects.find(
|
||||
(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.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
|
||||
|
|
@ -700,7 +694,7 @@ export default function Layout(props: ParentProps) {
|
|||
seen: lru,
|
||||
keep: sessionID,
|
||||
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) {
|
||||
const key = workspaceKey(directory)
|
||||
const key = pathKey(directory)
|
||||
const project = layout.projects
|
||||
.list()
|
||||
.find(
|
||||
(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
|
||||
|
||||
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]
|
||||
|
||||
|
|
@ -1283,7 +1277,7 @@ export default function Layout(props: ParentProps) {
|
|||
: [root]
|
||||
const canOpen = (value: string | undefined) => {
|
||||
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) => {
|
||||
if (!target || target === root || canOpen(target)) return canOpen(target)
|
||||
|
|
@ -1409,9 +1403,9 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
function closeProject(directory: string) {
|
||||
const list = layout.projects.list()
|
||||
const key = workspaceKey(directory)
|
||||
const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
|
||||
const active = workspaceKey(currentProject()?.worktree ?? "") === key
|
||||
const key = pathKey(directory)
|
||||
const index = list.findIndex((x) => pathKey(x.worktree) === key)
|
||||
const active = pathKey(currentProject()?.worktree ?? "") === key
|
||||
if (index === -1) return
|
||||
const next = list[index + 1]
|
||||
|
||||
|
|
@ -1485,8 +1479,8 @@ export default function Layout(props: ParentProps) {
|
|||
if (directory === root) return
|
||||
|
||||
const current = currentDir()
|
||||
const currentKey = workspaceKey(current)
|
||||
const deletedKey = workspaceKey(directory)
|
||||
const currentKey = pathKey(current)
|
||||
const deletedKey = pathKey(directory)
|
||||
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
|
||||
if (!leaveDeletedWorkspace && shouldLeave) {
|
||||
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||
|
|
@ -1509,7 +1503,7 @@ export default function Layout(props: ParentProps) {
|
|||
|
||||
if (!result) return
|
||||
|
||||
if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
|
||||
if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) {
|
||||
clearLastProjectSession(root)
|
||||
}
|
||||
|
||||
|
|
@ -1529,12 +1523,12 @@ export default function Layout(props: ParentProps) {
|
|||
if (shouldLeave) return
|
||||
|
||||
const nextCurrent = currentDir()
|
||||
const nextKey = workspaceKey(nextCurrent)
|
||||
const nextKey = pathKey(nextCurrent)
|
||||
const project = layout.projects.list().find((item) => item.worktree === root)
|
||||
const dirs = project
|
||||
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[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) {
|
||||
navigateWithSidebarReset(`/${base64Encode(root)}/session`)
|
||||
|
|
@ -1640,7 +1634,7 @@ export default function Layout(props: ParentProps) {
|
|||
})
|
||||
|
||||
const handleDelete = () => {
|
||||
const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
|
||||
const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory)
|
||||
if (leaveDeletedWorkspace) {
|
||||
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
|
||||
}
|
||||
|
|
@ -1867,11 +1861,11 @@ export default function Layout(props: ParentProps) {
|
|||
const local = project.worktree
|
||||
const dirs = [local, ...(project.sandboxes ?? [])]
|
||||
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 =
|
||||
directory &&
|
||||
workspaceKey(directory) !== workspaceKey(local) &&
|
||||
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
|
||||
pathKey(directory) !== pathKey(local) &&
|
||||
!dirs.some((item) => pathKey(item) === pathKey(directory))
|
||||
? directory
|
||||
: undefined
|
||||
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
|
||||
|
|
@ -1916,7 +1910,7 @@ export default function Layout(props: ParentProps) {
|
|||
setStore(
|
||||
"workspaceOrder",
|
||||
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)
|
||||
|
||||
const local = project.worktree
|
||||
const key = workspaceKey(created.directory)
|
||||
const root = workspaceKey(local)
|
||||
const key = pathKey(created.directory)
|
||||
const root = pathKey(local)
|
||||
|
||||
setBusy(created.directory, true)
|
||||
WorktreeState.pending(created.directory)
|
||||
|
|
@ -1954,7 +1948,7 @@ export default function Layout(props: ParentProps) {
|
|||
setStore("workspaceOrder", project.worktree, (prev) => {
|
||||
const existing = prev ?? []
|
||||
const next = existing.filter((item) => {
|
||||
const id = workspaceKey(item)
|
||||
const id = pathKey(item)
|
||||
return id !== root && id !== key
|
||||
})
|
||||
return [created.directory, ...next]
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import {
|
|||
errorMessage,
|
||||
hasProjectPermissions,
|
||||
latestRootSession,
|
||||
workspaceKey,
|
||||
} from "./helpers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
|
||||
({
|
||||
|
|
@ -104,16 +104,16 @@ describe("layout deep links", () => {
|
|||
|
||||
describe("layout workspace helpers", () => {
|
||||
test("normalizes trailing slash in workspace key", () => {
|
||||
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
|
||||
expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
|
||||
expect(String(pathKey("/tmp/demo///"))).toBe("/tmp/demo")
|
||||
expect(String(pathKey("C:\\tmp\\demo\\\\"))).toBe("C:/tmp/demo")
|
||||
})
|
||||
|
||||
test("preserves posix and drive roots in workspace key", () => {
|
||||
expect(workspaceKey("/")).toBe("/")
|
||||
expect(workspaceKey("///")).toBe("/")
|
||||
expect(workspaceKey("C:\\")).toBe("C:/")
|
||||
expect(workspaceKey("C://")).toBe("C:/")
|
||||
expect(workspaceKey("C:///")).toBe("C:/")
|
||||
expect(String(pathKey("/"))).toBe("/")
|
||||
expect(String(pathKey("///"))).toBe("/")
|
||||
expect(String(pathKey("C:\\"))).toBe("C:/")
|
||||
expect(String(pathKey("C://"))).toBe("C:/")
|
||||
expect(String(pathKey("C:///"))).toBe("C:/")
|
||||
})
|
||||
|
||||
test("keeps local first while preserving known order", () => {
|
||||
|
|
|
|||
|
|
@ -1,19 +1,12 @@
|
|||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { type Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
|
||||
type SessionStore = {
|
||||
session?: Session[]
|
||||
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) {
|
||||
const oneMinuteAgo = now - 60 * 1000
|
||||
return (a: Session, b: Session) => {
|
||||
|
|
@ -29,7 +22,7 @@ function sortSessions(now: number) {
|
|||
}
|
||||
|
||||
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) =>
|
||||
(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[]) => {
|
||||
const root = workspaceKey(local)
|
||||
const root = pathKey(local)
|
||||
const live = new Map<string, string>()
|
||||
|
||||
for (const dir of dirs) {
|
||||
const key = workspaceKey(dir)
|
||||
const key = pathKey(dir)
|
||||
if (key === root) continue
|
||||
if (!live.has(key)) live.set(key, dir)
|
||||
}
|
||||
|
|
@ -85,7 +78,7 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
|
|||
|
||||
const result = [local]
|
||||
for (const dir of persisted) {
|
||||
const key = workspaceKey(dir)
|
||||
const key = pathKey(dir)
|
||||
if (key === root) continue
|
||||
const match = live.get(key)
|
||||
if (!match) continue
|
||||
|
|
|
|||
|
|
@ -16,8 +16,9 @@ import { type Session } from "@opencode-ai/sdk/v2/client"
|
|||
import { type LocalProject } from "@/context/layout"
|
||||
import { loadSessionsQuery, useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { NewSessionItem, SessionItem, SessionSkeleton } from "./sidebar-items"
|
||||
import { sortedRootSessions, workspaceKey } from "./helpers"
|
||||
import { sortedRootSessions } from "./helpers"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
|
||||
type InlineEditorComponent = (props: {
|
||||
|
|
@ -309,7 +310,7 @@ export const SortableWorkspace = (props: {
|
|||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
const sessions = createMemo(() => sortedRootSessions(workspaceStore, props.sortNow()))
|
||||
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 branch = workspaceStore.vcs?.branch
|
||||
const name = branch ?? getFilename(props.directory)
|
||||
|
|
|
|||
24
packages/app/src/utils/path-key.ts
Normal file
24
packages/app/src/utils/path-key.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -71,11 +71,11 @@ describe("tui thread", () => {
|
|||
|
||||
async function check(project?: string) {
|
||||
setup()
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const cwd = process.cwd()
|
||||
const pwd = process.env.PWD
|
||||
const worker = globalThis.Worker
|
||||
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 type = process.platform === "win32" ? "junction" : "dir"
|
||||
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()
|
||||
})
|
||||
|
||||
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(".")
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue