Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
dev: "OpenCode Dev",
Expand Down Expand Up @@ -221,11 +222,13 @@ const main = Effect.gen(function* () {

app.on("before-quit", () => {
setAppQuitting()
flushAllStores()
void stopSidecars()
})
Comment on lines 223 to 227

app.on("will-quit", () => {
setAppQuitting()
flushAllStores()
void stopSidecars()
})

Expand Down
58 changes: 58 additions & 0 deletions packages/desktop/src/main/store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { describe, expect, test, beforeEach, afterEach, vi } from "bun:test"
import { BufferedStore } from "./store"

class MemoryStore {
public data = new Map<string, unknown>()
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)
}
}
Comment on lines +4 to +15

describe("BufferedStore", () => {
test("returns pending value before flush", () => {
const underlying = new MemoryStore()
const store = new BufferedStore(underlying as any, 100)

Comment on lines +19 to +21
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)

Comment on lines +32 to +34
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")
Comment on lines +44 to +48

// 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)
})
})
108 changes: 101 additions & 7 deletions packages/desktop/src/main/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,30 +6,124 @@ import { join } from "node:path"
import { SETTINGS_STORE } from "./store-keys"
import { deleteStoreFileIfEmpty } from "./store-cleanup"

const cache = new Map<string, Store>()
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<string, unknown>()
private cleared = false
private timer: ReturnType<typeof setTimeout> | 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<string, unknown>
},
private delay = 150,
) {}

get store(): Record<string, unknown> {
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)
}
Comment on lines +92 to +95
}

const cache = new Map<string, BufferedStore>()

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)
}
Loading