From 761863ae355b3ca1e606ea5eb2106772fa763c19 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 6 Jan 2026 21:38:08 -0600 Subject: chore(app): rework storage approach --- packages/app/src/context/file.tsx | 37 +++++++++++++++++++--- packages/app/src/context/layout.tsx | 28 +++++++++++++++-- packages/app/src/context/local.tsx | 4 +-- packages/app/src/context/notification.tsx | 33 +++++++++++++++++--- packages/app/src/context/permission.tsx | 52 +++++++++++++++++++++---------- packages/app/src/context/prompt.tsx | 6 ++-- packages/app/src/context/server.tsx | 4 +-- packages/app/src/context/terminal.tsx | 6 ++-- 8 files changed, 132 insertions(+), 38 deletions(-) (limited to 'packages/app/src/context') 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 }>({ @@ -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() + 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, }), @@ -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, 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[] -- cgit v1.2.3