diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c7c1643092d1..bdd436649aad 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -48,6 +48,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" +import { flushAllStores } from "./store" const APP_NAMES: Record = { dev: "OpenCode Dev", @@ -221,11 +222,13 @@ const main = Effect.gen(function* () { app.on("before-quit", () => { setAppQuitting() + flushAllStores() void stopSidecars() }) app.on("will-quit", () => { setAppQuitting() + flushAllStores() void stopSidecars() }) diff --git a/packages/desktop/src/main/store.test.ts b/packages/desktop/src/main/store.test.ts new file mode 100644 index 000000000000..6efd25cc865a --- /dev/null +++ b/packages/desktop/src/main/store.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, test, beforeEach, afterEach, vi } from "bun:test" +import { BufferedStore } from "./store" + +class MemoryStore { + public data = new Map() + get(key: string) { + return this.data.get(key) + } + set(key: string, value: unknown) { + this.data.set(key, value) + } + delete(key: string) { + this.data.delete(key) + } +} + +describe("BufferedStore", () => { + test("returns pending value before flush", () => { + const underlying = new MemoryStore() + const store = new BufferedStore(underlying as any, 100) + + store.set("theme", "dark") + + // MemoryStore hasn't been updated yet before flush + expect(underlying.data.get("theme")).toBeUndefined() + + // But BufferedStore returns the pending write immediately + expect(store.get("theme")).toBe("dark") + }) + + test("flushes pending writes to underlying store on flush()", () => { + const underlying = new MemoryStore() + const store = new BufferedStore(underlying as any, 100) + + store.set("key1", "value1") + store.set("key2", "value2") + store.flush() + + expect(underlying.data.get("key1")).toBe("value1") + expect(underlying.data.get("key2")).toBe("value2") + }) + + test("buffers delete operations until flush()", () => { + const underlying = new MemoryStore() + underlying.set("key1", "value1") + + const store = new BufferedStore(underlying as any, 100) + store.delete("key1") + + // Still in underlying before flush + expect(underlying.data.get("key1")).toBe("value1") + // Buffered as deleted + expect(store.get("key1")).toBeUndefined() + + store.flush() + expect(underlying.data.has("key1")).toBe(false) + }) +}) diff --git a/packages/desktop/src/main/store.ts b/packages/desktop/src/main/store.ts index 687429ed4365..c955d7039894 100644 --- a/packages/desktop/src/main/store.ts +++ b/packages/desktop/src/main/store.ts @@ -6,30 +6,124 @@ import { join } from "node:path" import { SETTINGS_STORE } from "./store-keys" import { deleteStoreFileIfEmpty } from "./store-cleanup" -const cache = new Map() +const DELETED = Symbol("DELETED") -// We cannot instantiate the electron-store at module load time because -// module import hoisting causes this to run before app.setPath("userData", ...) -// in index.ts has executed, which would result in files being written to the default directory -// (e.g. bad: %APPDATA%\@opencode-ai\desktop\opencode.settings vs good: %APPDATA%\ai.opencode.desktop.dev\opencode.settings). -export function getStore(name = SETTINGS_STORE) { +export class BufferedStore { + private pending = new Map() + private cleared = false + private timer: ReturnType | null = null + + constructor( + private target: { + get(key: string): unknown + set(key: string, val: unknown): void + delete(key: string): void + clear(): void + has(key: string): boolean + store: Record + }, + private delay = 150, + ) {} + + get store(): Record { + const base = this.cleared ? {} : { ...this.target.store } + for (const [key, value] of this.pending) { + if (value === DELETED) { + delete base[key] + } else { + base[key] = value + } + } + return base + } + + has(key: string): boolean { + if (this.pending.has(key)) { + return this.pending.get(key) !== DELETED + } + if (this.cleared) return false + return this.target.has(key) + } + + get(key: string) { + if (this.pending.has(key)) { + const val = this.pending.get(key) + return val === DELETED ? undefined : val + } + if (this.cleared) return undefined + return this.target.get(key) + } + + set(key: string, value: unknown) { + this.pending.set(key, value) + this.scheduleFlush() + } + + delete(key: string) { + this.pending.set(key, DELETED) + this.scheduleFlush() + } + + clear() { + this.cleared = true + this.pending.clear() + this.scheduleFlush() + } + + flush() { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + if (this.cleared) { + this.target.clear() + this.cleared = false + } + for (const [key, value] of this.pending) { + if (value === DELETED) { + this.target.delete(key) + } else { + this.target.set(key, value) + } + } + this.pending.clear() + } + + private scheduleFlush() { + if (this.timer) return + this.timer = setTimeout(() => this.flush(), this.delay) + } +} + +const cache = new Map() + +export function getStore(name = SETTINGS_STORE): BufferedStore { const cached = cache.get(name) if (cached) return cached - const next = new Store({ + const underlying = new Store({ name, cwd: electron.app.getPath("userData"), fileExtension: "", accessPropertiesByDotNotation: false, }) + const next = new BufferedStore(underlying) cache.set(name, next) return next } +export function flushAllStores() { + for (const store of cache.values()) { + store.flush() + } +} + export async function removeStoreFileIfEmpty(name: string) { + cache.get(name)?.flush() if (await deleteStoreFileIfEmpty(electron.app.getPath("userData"), name)) cache.delete(name) } export function removeStoreFile(name: string) { + cache.get(name)?.flush() rmSync(join(electron.app.getPath("userData"), name), { force: true }) cache.delete(name) }