Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 3606b92824 feat(tui): ephemeral memory storage for plugins 2026-07-31 12:42:34 -04:00
4 changed files with 78 additions and 1 deletions
+15
View File
@@ -26,12 +26,27 @@ import type { JSX } from "@opentui/solid"
import type { Store } from "solid-js/store"
export interface Storage {
/**
* Durable JSON state: persisted to disk, survives hot reloads and TUI
* restarts, and stays live-synced across running TUI instances.
*/
store<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-memory state: survives plugin hot reloads (old and new
* generations share the same live store) and is gone when the TUI exits.
* Updates are synchronous and values need not be JSON-serializable.
*/
memory<Value extends object>(
key: string,
options: {
readonly initial: Value
},
): readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
}
interface LocationCollection<Value> {
+18 -1
View File
@@ -1,5 +1,5 @@
import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js"
import { createStore, reconcile, type Store } from "solid-js/store"
import { createStore, produce, reconcile, type Store } from "solid-js/store"
import path from "path"
import { mkdirSync, readFileSync, watch } from "fs"
import { Flock } from "@opencode-ai/util/flock"
@@ -13,12 +13,20 @@ type Options<Value extends object> = {
}
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
type MemoryEntry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => void]
export interface Storage {
store<Value extends object>(
key: string,
options: Options<Value>,
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
/**
* Ephemeral in-process state. Entries are memoized here, above consumer
* lifecycles, so the same live store survives plugin hot reloads; it is
* gone when the TUI exits. Updates are synchronous and values need not be
* JSON-serializable.
*/
memory<Value extends object>(key: string, options: { readonly initial: Value }): MemoryEntry<Value>
}
function clone<Value extends object>(value: Value) {
@@ -37,6 +45,7 @@ function segment(value: string) {
function createStorage(root: string, channel: string) {
const entries = new Map<string, { readonly value: Entry<object>; readonly reload: () => void }>()
const memories = new Map<string, MemoryEntry<object>>()
const directory = path.join(root, segment(channel), "tui")
const locks = path.join(root, segment(channel), "locks")
mkdirSync(directory, { recursive: true })
@@ -73,6 +82,14 @@ function createStorage(root: string, channel: string) {
entries.set(file, { value: entry as Entry<object>, reload })
return entry
},
memory<Value extends object>(key: string, options: { readonly initial: Value }) {
const existing = memories.get(key)
if (existing) return existing as MemoryEntry<Value>
const [store, setStore] = createStore(options.initial)
const entry = [store, (mutation: (draft: Value) => void) => setStore(produce(mutation))] as const
memories.set(key, entry as MemoryEntry<object>)
return entry
},
}
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
+1
View File
@@ -240,6 +240,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
},
storage: {
store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options),
memory: (key, options) => storage.memory(`plugin.${item.plugin.id}.${key}`, options),
},
ui: {
dialog: dialogApi,
@@ -0,0 +1,44 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { testRender } from "@opentui/solid"
import { mkdtempSync } from "fs"
import { tmpdir } from "os"
import path from "path"
import { TuiAppProvider } from "../../src/context/runtime"
import { StorageProvider, useStorage, type Storage } from "../../src/context/storage"
import { TestTuiContexts } from "../fixture/tui-environment"
test("memory storage is synchronous, keyed, and stable across lookups", async () => {
let storage!: Storage
function Probe() {
storage = useStorage()
return <box />
}
await testRender(() => (
<TestTuiContexts paths={{ state: mkdtempSync(path.join(tmpdir(), "opencode-storage-test-")) }}>
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<Probe />
</StorageProvider>
</TuiAppProvider>
</TestTuiContexts>
))
const [state, update] = storage.memory("tick", { initial: { count: 0, at: undefined as Date | undefined } })
// Synchronous update, no JSON round-trip: a Date survives as-is.
const now = new Date()
update((draft) => {
draft.count += 1
draft.at = now
})
expect(state.count).toBe(1)
expect(state.at).toBe(now)
// Same key returns the same live store (what hot-reload survival relies
// on); a different key is isolated.
const [again] = storage.memory("tick", { initial: { count: 99, at: undefined as Date | undefined } })
expect(again).toBe(state)
expect(again.count).toBe(1)
const [other] = storage.memory("other", { initial: { count: 0 } })
expect(other.count).toBe(0)
})