summaryrefslogtreecommitdiffhomepage
path: root/packages/app
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-01-06 21:38:08 -0600
committerAdam <[email protected]>2026-01-06 21:42:03 -0600
commit761863ae355b3ca1e606ea5eb2106772fa763c19 (patch)
treef8fef26687875e75f4279b6695e35a33977b728c /packages/app
parentdadc08ddc7f3c9b1a34e6f401a99406b6714b965 (diff)
downloadopencode-761863ae355b3ca1e606ea5eb2106772fa763c19.tar.gz
opencode-761863ae355b3ca1e606ea5eb2106772fa763c19.zip
chore(app): rework storage approach
Diffstat (limited to 'packages/app')
-rw-r--r--packages/app/src/components/prompt-input.tsx12
-rw-r--r--packages/app/src/context/file.tsx37
-rw-r--r--packages/app/src/context/layout.tsx28
-rw-r--r--packages/app/src/context/local.tsx4
-rw-r--r--packages/app/src/context/notification.tsx33
-rw-r--r--packages/app/src/context/permission.tsx52
-rw-r--r--packages/app/src/context/prompt.tsx6
-rw-r--r--packages/app/src/context/server.tsx4
-rw-r--r--packages/app/src/context/terminal.tsx6
-rw-r--r--packages/app/src/pages/layout.tsx2
-rw-r--r--packages/app/src/pages/session.tsx11
-rw-r--r--packages/app/src/utils/persist.ts228
12 files changed, 370 insertions, 53 deletions
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index fc4a3d1e6..3b14098a3 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -42,7 +42,7 @@ import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
@@ -189,7 +189,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const MAX_HISTORY = 100
const [history, setHistory] = persisted(
- "prompt-history.v1",
+ Persist.global("prompt-history", ["prompt-history.v1"]),
createStore<{
entries: Prompt[]
}>({
@@ -197,7 +197,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
const [shellHistory, setShellHistory] = persisted(
- "prompt-history-shell.v1",
+ Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
createStore<{
entries: Prompt[]
}>({
@@ -1593,14 +1593,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
- "text-text-base": !permission.isAutoAccepting(params.id!),
- "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
+ "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
+ "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
>
<Icon
name="chevron-double-right"
size="small"
- classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
+ classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
/>
</Button>
</TooltipKeybind>
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index a26f97c2a..ff36919a1 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -1,4 +1,4 @@
-import { createMemo, onCleanup } from "solid-js"
+import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2"
@@ -7,7 +7,7 @@ import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
export type FileSelection = {
startLine: number
@@ -134,10 +134,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {},
})
- const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
+ const legacyViewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const [view, setView, _, ready] = persisted(
- viewKey(),
+ Persist.scoped(params.dir, params.id, "file-view", [legacyViewKey()]),
createStore<{
file: Record<string, FileViewState>
}>({
@@ -145,6 +145,32 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}),
)
+ const MAX_VIEW_FILES = 500
+ const viewMeta = { pruned: false }
+
+ const pruneView = (keep?: string) => {
+ const keys = Object.keys(view.file)
+ if (keys.length <= MAX_VIEW_FILES) return
+
+ const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
+ if (drop.length === 0) return
+
+ setView(
+ produce((draft) => {
+ for (const key of drop) {
+ delete draft.file[key]
+ }
+ }),
+ )
+ }
+
+ createEffect(() => {
+ if (!ready()) return
+ if (viewMeta.pruned) return
+ viewMeta.pruned = true
+ pruneView()
+ })
+
function ensure(path: string) {
if (!path) return
if (store.file[path]) return
@@ -233,6 +259,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scrollTop: top,
}
})
+ pruneView(path)
}
const setScrollLeft = (input: string, left: number) => {
@@ -244,6 +271,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
scrollLeft: left,
}
})
+ pruneView(path)
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
@@ -256,6 +284,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
selectedLines: next,
}
})
+ pruneView(path)
}
onCleanup(() => stop())
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index def933c39..2d92409f0 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -5,7 +5,7 @@ import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted, removePersisted } from "@/utils/persist"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@@ -46,7 +46,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSync = useGlobalSync()
const server = useServer()
const [store, setStore, _, ready] = persisted(
- "layout.v6",
+ Persist.global("layout", ["layout.v6"]),
createStore({
sidebar: {
opened: false,
@@ -75,6 +75,29 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
+ const SESSION_STATE_KEYS = [
+ { key: "prompt", legacy: "prompt", version: "v2" },
+ { key: "terminal", legacy: "terminal", version: "v1" },
+ { key: "file-view", legacy: "file", version: "v1" },
+ ] as const
+
+ const dropSessionState = (keys: string[]) => {
+ for (const key of keys) {
+ const parts = key.split("/")
+ const dir = parts[0]
+ const session = parts[1]
+ if (!dir) continue
+
+ for (const entry of SESSION_STATE_KEYS) {
+ const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
+ void removePersisted(target)
+
+ const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
+ void removePersisted({ key: legacyKey })
+ }
+ }
+ }
+
function prune(keep?: string) {
if (!keep) return
@@ -102,6 +125,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
)
scroll.drop(drop)
+ dropSessionState(drop)
for (const key of drop) {
used.delete(key)
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index 3af840556..ea71ec499 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -8,7 +8,7 @@ import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@opencode-ai/ui/toast"
export type LocalFile = FileNode &
@@ -111,7 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const model = (() => {
const [store, setStore, _, modelReady] = persisted(
- "model.v1",
+ Persist.global("model", ["model.v1"]),
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx
index 09f32a3c4..16b3d306c 100644
--- a/packages/app/src/context/notification.tsx
+++ b/packages/app/src/context/notification.tsx
@@ -1,5 +1,5 @@
import { createStore } from "solid-js/store"
-import { onCleanup } from "solid-js"
+import { createEffect, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
@@ -10,7 +10,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
type NotificationBase = {
directory?: string
@@ -31,6 +31,16 @@ type ErrorNotification = NotificationBase & {
export type Notification = TurnCompleteNotification | ErrorNotification
+const MAX_NOTIFICATIONS = 500
+const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
+
+function pruneNotifications(list: Notification[]) {
+ const cutoff = Date.now() - NOTIFICATION_TTL_MS
+ const pruned = list.filter((n) => n.time >= cutoff)
+ if (pruned.length <= MAX_NOTIFICATIONS) return pruned
+ return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
+}
+
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
@@ -49,12 +59,25 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
- "notification.v1",
+ Persist.global("notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
)
+ const meta = { pruned: false }
+
+ createEffect(() => {
+ if (!ready()) return
+ if (meta.pruned) return
+ meta.pruned = true
+ setStore("list", pruneNotifications(store.list))
+ })
+
+ const append = (notification: Notification) => {
+ setStore("list", (list) => pruneNotifications([...list, notification]))
+ }
+
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -73,7 +96,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
try {
idlePlayer?.play()
} catch {}
- setStore("list", store.list.length, {
+ append({
...base,
type: "turn-complete",
session: sessionID,
@@ -92,7 +115,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
errorPlayer?.play()
} catch {}
const error = "error" in event.properties ? event.properties.error : undefined
- setStore("list", store.list.length, {
+ append({
...base,
type: "error",
session: sessionID ?? "global",
diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx
index dc75553c7..52878ba8f 100644
--- a/packages/app/src/context/permission.tsx
+++ b/packages/app/src/context/permission.tsx
@@ -1,12 +1,12 @@
import { createMemo, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
-import { base64Decode } from "@opencode-ai/util/encode"
+import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
type PermissionRespondFn = (input: {
sessionID: string
@@ -60,7 +60,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
const [store, setStore, _, ready] = persisted(
- "permission.v3",
+ Persist.global("permission", ["permission.v3"]),
createStore({
autoAcceptEdits: {} as Record<string, boolean>,
}),
@@ -85,8 +85,14 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
}
- function isAutoAccepting(sessionID: string) {
- return store.autoAcceptEdits[sessionID] ?? false
+ function acceptKey(sessionID: string, directory?: string) {
+ if (!directory) return sessionID
+ return `${base64Encode(directory)}/${sessionID}`
+ }
+
+ function isAutoAccepting(sessionID: string, directory?: string) {
+ const key = acceptKey(sessionID, directory)
+ return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
}
const unsubscribe = globalSDK.event.listen((e) => {
@@ -94,7 +100,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (event?.type !== "permission.asked") return
const perm = event.properties
- if (!isAutoAccepting(perm.sessionID)) return
+ if (!isAutoAccepting(perm.sessionID, e.name)) return
if (!shouldAutoAccept(perm)) return
respondOnce(perm, e.name)
@@ -102,7 +108,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
onCleanup(unsubscribe)
function enable(sessionID: string, directory: string) {
- setStore("autoAcceptEdits", sessionID, true)
+ const key = acceptKey(sessionID, directory)
+ setStore(
+ produce((draft) => {
+ draft.autoAcceptEdits[key] = true
+ delete draft.autoAcceptEdits[sessionID]
+ }),
+ )
globalSDK.client.permission
.list({ directory })
@@ -117,31 +129,37 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
.catch(() => undefined)
}
- function disable(sessionID: string) {
- setStore("autoAcceptEdits", sessionID, false)
+ function disable(sessionID: string, directory?: string) {
+ const key = directory ? acceptKey(sessionID, directory) : undefined
+ setStore(
+ produce((draft) => {
+ if (key) delete draft.autoAcceptEdits[key]
+ delete draft.autoAcceptEdits[sessionID]
+ }),
+ )
}
return {
ready,
respond,
- autoResponds(permission: PermissionRequest) {
- return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
+ autoResponds(permission: PermissionRequest, directory?: string) {
+ return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
},
isAutoAccepting,
toggleAutoAccept(sessionID: string, directory: string) {
- if (isAutoAccepting(sessionID)) {
- disable(sessionID)
+ if (isAutoAccepting(sessionID, directory)) {
+ disable(sessionID, directory)
return
}
enable(sessionID, directory)
},
enableAutoAccept(sessionID: string, directory: string) {
- if (isAutoAccepting(sessionID)) return
+ if (isAutoAccepting(sessionID, directory)) return
enable(sessionID, directory)
},
- disableAutoAccept(sessionID: string) {
- disable(sessionID)
+ disableAutoAccept(sessionID: string, directory?: string) {
+ disable(sessionID, directory)
},
permissionsEnabled,
}
diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx
index f77f62e3c..ee62938d9 100644
--- a/packages/app/src/context/prompt.tsx
+++ b/packages/app/src/context/prompt.tsx
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
interface PartBase {
content: string
@@ -103,10 +103,10 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
name: "Prompt",
init: () => {
const params = useParams()
- const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
+ const legacy = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
const [store, setStore, _, ready] = persisted(
- name(),
+ Persist.scoped(params.dir, params.id, "prompt", [legacy()]),
createStore<{
prompt: Prompt
cursor?: number
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index 48e7e99cc..06c37b592 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
type StoredProject = { worktree: string; expanded: boolean }
@@ -35,7 +35,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
- "server.v3",
+ Persist.global("server", ["server.v3"]),
createStore({
list: [] as string[],
projects: {} as Record<string, StoredProject[]>,
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index e9a07077c..6188772f0 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
-import { persisted } from "@/utils/persist"
+import { Persist, persisted } from "@/utils/persist"
export type LocalPTY = {
id: string
@@ -19,10 +19,10 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
init: () => {
const sdk = useSDK()
const params = useParams()
- const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
+ const legacy = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const [store, setStore, _, ready] = persisted(
- name(),
+ Persist.scoped(params.dir, params.id, "terminal", [legacy()]),
createStore<{
active?: string
all: LocalPTY[]
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 9e3d3fc0a..85d61d57b 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -170,7 +170,7 @@ export default function Layout(props: ParentProps) {
if (e.details?.type !== "permission.asked") return
const directory = e.name
const perm = e.details.properties
- if (permission.autoResponds(perm)) return
+ if (permission.autoResponds(perm, directory)) return
const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === perm.sessionID)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 853d3a894..d3d8ef387 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -467,7 +467,10 @@ export default function Page() {
},
{
id: "permissions.autoaccept",
- title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
+ title:
+ params.id && permission.isAutoAccepting(params.id, sdk.directory)
+ ? "Stop auto-accepting edits"
+ : "Auto-accept edits",
category: "Permissions",
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
@@ -476,8 +479,10 @@ export default function Page() {
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
- title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
- description: permission.isAutoAccepting(sessionID)
+ title: permission.isAutoAccepting(sessionID, sdk.directory)
+ ? "Auto-accepting edits"
+ : "Stopped auto-accepting edits",
+ description: permission.isAutoAccepting(sessionID, sdk.directory)
? "Edit and write permissions will be automatically approved"
: "Edit and write permissions will require approval",
})
diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts
index 12b334f9f..0c20ee31c 100644
--- a/packages/app/src/utils/persist.ts
+++ b/packages/app/src/utils/persist.ts
@@ -1,17 +1,235 @@
import { usePlatform } from "@/context/platform"
-import { makePersisted } from "@solid-primitives/storage"
+import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
+import { checksum } from "@opencode-ai/util/encode"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
-export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
+type PersistTarget = {
+ storage?: string
+ key: string
+ legacy?: string[]
+ migrate?: (value: unknown) => unknown
+}
+
+const LEGACY_STORAGE = "default.dat"
+const GLOBAL_STORAGE = "opencode.global.dat"
+
+function snapshot(value: unknown) {
+ return JSON.parse(JSON.stringify(value)) as unknown
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+}
+
+function merge(defaults: unknown, value: unknown): unknown {
+ if (value === undefined) return defaults
+ if (value === null) return value
+
+ if (Array.isArray(defaults)) {
+ if (Array.isArray(value)) return value
+ return defaults
+ }
+
+ if (isRecord(defaults)) {
+ if (!isRecord(value)) return defaults
+
+ const result: Record<string, unknown> = { ...defaults }
+ for (const key of Object.keys(value)) {
+ if (key in defaults) {
+ result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
+ } else {
+ result[key] = (value as Record<string, unknown>)[key]
+ }
+ }
+ return result
+ }
+
+ return value
+}
+
+function parse(value: string) {
+ try {
+ return JSON.parse(value) as unknown
+ } catch {
+ return undefined
+ }
+}
+
+function workspaceStorage(dir: string) {
+ const head = dir.slice(0, 12) || "workspace"
+ const sum = checksum(dir) ?? "0"
+ return `opencode.workspace.${head}.${sum}.dat`
+}
+
+function localStorageWithPrefix(prefix: string): SyncStorage {
+ const base = `${prefix}:`
+ return {
+ getItem: (key) => localStorage.getItem(base + key),
+ setItem: (key, value) => localStorage.setItem(base + key, value),
+ removeItem: (key) => localStorage.removeItem(base + key),
+ }
+}
+
+export const Persist = {
+ global(key: string, legacy?: string[]): PersistTarget {
+ return { storage: GLOBAL_STORAGE, key, legacy }
+ },
+ workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
+ return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
+ },
+ session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
+ return { storage: workspaceStorage(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)
+ return Persist.workspace(dir, key, legacy)
+ },
+}
+
+export function removePersisted(target: { storage?: string; key: string }) {
const platform = usePlatform()
- const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
+ const isDesktop = platform.platform === "desktop" && !!platform.storage
+
+ if (isDesktop) {
+ return platform.storage?.(target.storage)?.removeItem(target.key)
+ }
+
+ if (!target.storage) {
+ localStorage.removeItem(target.key)
+ return
+ }
+
+ localStorageWithPrefix(target.storage).removeItem(target.key)
+}
+
+export function persisted<T>(
+ target: string | PersistTarget,
+ store: [Store<T>, SetStoreFunction<T>],
+): PersistedWithReady<T> {
+ const platform = usePlatform()
+ const config: PersistTarget = typeof target === "string" ? { key: target } : target
+
+ const defaults = snapshot(store[0])
+ const legacy = config.legacy ?? []
+
+ const isDesktop = platform.platform === "desktop" && !!platform.storage
+
+ const currentStorage = (() => {
+ if (isDesktop) return platform.storage?.(config.storage)
+ if (!config.storage) return localStorage
+ return localStorageWithPrefix(config.storage)
+ })()
+
+ const legacyStorage = (() => {
+ if (!isDesktop) return localStorage
+ if (!config.storage) return platform.storage?.()
+ return platform.storage?.(LEGACY_STORAGE)
+ })()
+
+ const storage = (() => {
+ if (!isDesktop) {
+ const current = currentStorage as SyncStorage
+ const legacyStore = legacyStorage as SyncStorage
+
+ const api: SyncStorage = {
+ getItem: (key) => {
+ const raw = current.getItem(key)
+ if (raw !== null) {
+ const parsed = parse(raw)
+ if (parsed === undefined) return raw
+
+ const migrated = config.migrate ? config.migrate(parsed) : parsed
+ const merged = merge(defaults, migrated)
+ const next = JSON.stringify(merged)
+ if (raw !== next) current.setItem(key, next)
+ return next
+ }
+
+ for (const legacyKey of legacy) {
+ const legacyRaw = legacyStore.getItem(legacyKey)
+ if (legacyRaw === null) continue
+
+ current.setItem(key, legacyRaw)
+ legacyStore.removeItem(legacyKey)
+
+ const parsed = parse(legacyRaw)
+ if (parsed === undefined) return legacyRaw
+
+ const migrated = config.migrate ? config.migrate(parsed) : parsed
+ const merged = merge(defaults, migrated)
+ const next = JSON.stringify(merged)
+ if (legacyRaw !== next) current.setItem(key, next)
+ return next
+ }
+
+ return null
+ },
+ setItem: (key, value) => {
+ current.setItem(key, value)
+ },
+ removeItem: (key) => {
+ current.removeItem(key)
+ },
+ }
+
+ return api
+ }
+
+ const current = currentStorage as AsyncStorage
+ const legacyStore = legacyStorage as AsyncStorage | undefined
+
+ const api: AsyncStorage = {
+ getItem: async (key) => {
+ const raw = await current.getItem(key)
+ if (raw !== null) {
+ const parsed = parse(raw)
+ if (parsed === undefined) return raw
+
+ const migrated = config.migrate ? config.migrate(parsed) : parsed
+ const merged = merge(defaults, migrated)
+ const next = JSON.stringify(merged)
+ 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
+
+ await current.setItem(key, legacyRaw)
+ await legacyStore.removeItem(legacyKey)
+
+ const parsed = parse(legacyRaw)
+ if (parsed === undefined) return legacyRaw
+
+ const migrated = config.migrate ? config.migrate(parsed) : parsed
+ const merged = merge(defaults, migrated)
+ const next = JSON.stringify(merged)
+ if (legacyRaw !== next) await current.setItem(key, next)
+ return next
+ }
+
+ return null
+ },
+ setItem: async (key, value) => {
+ await current.setItem(key, value)
+ },
+ removeItem: async (key) => {
+ await current.removeItem(key)
+ },
+ }
+
+ return api
+ })()
+
+ const [state, setState, init] = makePersisted(store, { name: config.key, storage })
- // Create a resource that resolves when the store is initialized
- // This integrates with Suspense and provides a ready signal
const isAsync = init instanceof Promise
const [ready] = createResource(
() => init,