diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index fc4a3d1e64..3b14098a39 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 = (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 = (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 = (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), }} > diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index a26f97c2a5..ff36919a1c 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 }>({ @@ -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 def933c39a..2d92409f04 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() + 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 3af840556e..ea71ec4991 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 09f32a3c43..16b3d306c2 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 dc75553c76..52878ba8f8 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, }), @@ -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 f77f62e3ca..ee62938d93 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 48e7e99cce..06c37b5929 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, diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index e9a07077ce..6188772f03 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 9e3d3fc0ad..85d61d57be 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 853d3a894c..d3d8ef387c 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 12b334f9f0..0c20ee31ca 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 | null type PersistedWithReady = [Store, SetStoreFunction, InitType, Accessor] -export function persisted(key: string, store: [Store, SetStoreFunction]): PersistedWithReady { - const platform = usePlatform() - const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage }) +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 { + 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 = { ...defaults } + for (const key of Object.keys(value)) { + if (key in defaults) { + result[key] = merge((defaults as Record)[key], (value as Record)[key]) + } else { + result[key] = (value as Record)[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 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( + target: string | PersistTarget, + store: [Store, SetStoreFunction], +): PersistedWithReady { + 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, diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 1b822e2652..aee2c8249d 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -60,7 +60,7 @@ const platform: Platform = { void shellOpen(url).catch(() => undefined) }, - storage: (name = "default.dat") => { + storage: (() => { type StoreLike = { get(key: string): Promise set(key: string, value: string): Promise @@ -70,7 +70,13 @@ const platform: Platform = { length(): Promise } - const memory = () => { + const WRITE_DEBOUNCE_MS = 250 + + const storeCache = new Map>() + const apiCache = new Map Promise }>() + const memoryCache = new Map() + + const createMemoryStore = () => { const data = new Map() const store: StoreLike = { get: async (key) => data.get(key), @@ -89,45 +95,108 @@ const platform: Platform = { return store } - const api: AsyncStorage & { _store: Promise | null; _getStore: () => Promise } = { - _store: null, - _getStore: async () => { - if (api._store) return api._store - api._store = Store.load(name).catch(() => memory()) - return api._store - }, - getItem: async (key: string) => { - const store = await api._getStore() - const value = await store.get(key).catch(() => null) - if (value === undefined) return null - return value - }, - setItem: async (key: string, value: string) => { - const store = await api._getStore() - await store.set(key, value).catch(() => undefined) - }, - removeItem: async (key: string) => { - const store = await api._getStore() - await store.delete(key).catch(() => undefined) - }, - clear: async () => { - const store = await api._getStore() - await store.clear().catch(() => undefined) - }, - key: async (index: number) => { - const store = await api._getStore() - return (await store.keys().catch(() => []))[index] - }, - getLength: async () => { - const store = await api._getStore() - return await store.length().catch(() => 0) - }, - get length() { - return api.getLength() - }, + const getStore = (name: string) => { + const cached = storeCache.get(name) + if (cached) return cached + + const store = Store.load(name).catch(() => { + const cached = memoryCache.get(name) + if (cached) return cached + + const memory = createMemoryStore() + memoryCache.set(name, memory) + return memory + }) + + storeCache.set(name, store) + return store } - return api - }, + + const createStorage = (name: string) => { + const pending = new Map() + let timer: ReturnType | undefined + let flushing: Promise | undefined + + const flush = async () => { + if (flushing) return flushing + + flushing = (async () => { + const store = await getStore(name) + while (pending.size > 0) { + const batch = Array.from(pending.entries()) + pending.clear() + for (const [key, value] of batch) { + if (value === null) { + await store.delete(key).catch(() => undefined) + } else { + await store.set(key, value).catch(() => undefined) + } + } + } + })().finally(() => { + flushing = undefined + }) + + return flushing + } + + const schedule = () => { + if (timer) return + timer = setTimeout(() => { + timer = undefined + void flush() + }, WRITE_DEBOUNCE_MS) + } + + const api: AsyncStorage & { flush: () => Promise } = { + flush, + getItem: async (key: string) => { + const next = pending.get(key) + if (next !== undefined) return next + + const store = await getStore(name) + const value = await store.get(key).catch(() => null) + if (value === undefined) return null + return value + }, + setItem: async (key: string, value: string) => { + pending.set(key, value) + schedule() + }, + removeItem: async (key: string) => { + pending.set(key, null) + schedule() + }, + clear: async () => { + pending.clear() + const store = await getStore(name) + await store.clear().catch(() => undefined) + }, + key: async (index: number) => { + const store = await getStore(name) + return (await store.keys().catch(() => []))[index] + }, + getLength: async () => { + const store = await getStore(name) + return await store.length().catch(() => 0) + }, + get length() { + return api.getLength() + }, + } + + return api + } + + return (name = "default.dat") => { + const cached = apiCache.get(name) + if (cached) return cached + + const api = createStorage(name) + apiCache.set(name, api) + return api + } + })(), checkUpdate: async () => { if (!UPDATER_ENABLED) return { updateAvailable: false }