From 9ee337469d080cfb3ec615629016e9f4188177a0 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 29 Jul 2026 12:32:24 -0400 Subject: [PATCH] feat(tui): add persistent storage context --- packages/plugin/src/tui/context.ts | 11 ++ packages/tui/package.json | 1 + packages/tui/src/app.tsx | 185 ++++++++++---------- packages/tui/src/config/index.tsx | 3 + packages/tui/src/context/session-tabs.tsx | 204 ++++++++-------------- packages/tui/src/context/storage.tsx | 94 ++++++++++ packages/tui/src/plugin/context.tsx | 5 + 7 files changed, 280 insertions(+), 223 deletions(-) create mode 100644 packages/tui/src/context/storage.tsx diff --git a/packages/plugin/src/tui/context.ts b/packages/plugin/src/tui/context.ts index e4087b5aa01..05e66ba1638 100644 --- a/packages/plugin/src/tui/context.ts +++ b/packages/plugin/src/tui/context.ts @@ -21,6 +21,16 @@ import type { } from "@opencode-ai/client" import type { CliRenderer, KeyEvent, Renderable } from "@opentui/core" import type { JSX } from "@opentui/solid" +import type { Store } from "solid-js/store" + +export interface Storage { + store( + key: string, + options: { + readonly initial: Value + }, + ): readonly [Store, (mutation: (draft: Value) => void) => Promise] +} interface LocationCollection { list(location?: LocationRef): Value[] | undefined @@ -348,5 +358,6 @@ export interface Context { readonly attention: Attention readonly theme: any readonly keymap: Keymap + readonly storage: Storage readonly ui: UI } diff --git a/packages/tui/package.json b/packages/tui/package.json index ac929f9d6e7..8ae0222ebe4 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -20,6 +20,7 @@ "./context/exit": "./src/context/exit.tsx", "./context/log": "./src/context/log.tsx", "./context/runtime": "./src/context/runtime.tsx", + "./context/storage": "./src/context/storage.tsx", "./context/client": "./src/context/client.tsx", "./context/theme": "./src/context/theme.tsx", "./theme/discovery": "./src/theme/discovery.ts", diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index a0f062e13b4..d6ba165e429 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -90,6 +90,7 @@ import { win32DisableProcessedInput, win32FlushInputBuffer } from "./terminal-wi import { destroyRenderer } from "./util/renderer" import { cliErrorMessage, errorFormat } from "./util/error" import { AttentionProvider } from "./context/attention" +import { StorageProvider } from "./context/storage" registerOpencodeSpinner() @@ -303,104 +304,106 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) { worktree: global.data + "/worktree", }} > - finalizers.delete(finalizer) - }, - }} - > - + finalizers.delete(finalizer) + }, }} > - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/tui/src/config/index.tsx b/packages/tui/src/config/index.tsx index 867576d352a..faebe44f406 100644 --- a/packages/tui/src/config/index.tsx +++ b/packages/tui/src/config/index.tsx @@ -127,6 +127,9 @@ export const Info = Schema.Struct({ enabled: Schema.optional(Schema.Boolean).annotate({ description: "Use a persistent session tab strip instead of pinned quick-switch sessions", }), + scope: Schema.optional(Schema.Literals(["global", "cwd"])).annotate({ + description: "Share session tabs globally or keep a separate set for each working directory", + }), }), ).annotate({ description: "Session tab settings" }), mini: Schema.optional( diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 14fc91a5089..c59c00731d8 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -1,15 +1,12 @@ -import { batch, createEffect, onCleanup, untrack } from "solid-js" -import { createStore, produce, reconcile } from "solid-js/store" -import path from "path" +import { createEffect, onCleanup } from "solid-js" import { isDeepEqual } from "remeda" import { createSimpleContext } from "./helper" import { useData } from "./data" import { useEvent } from "./event" import { useRoute } from "./route" -import { useTuiPaths } from "./runtime" import { useConfig } from "../config" -import { readJson, writeJsonAtomic } from "../util/persistence" -import { isRecord } from "../util/record" +import { useStorage } from "./storage" +import { useTuiPaths } from "./runtime" import { closeSessionTab, cycleSessionTab, @@ -21,11 +18,18 @@ import { type SessionTabUnread, } from "./session-tabs-model" -type PersistedState = { +type TabsState = { tabs: SessionTab[] unread: Record } +type PersistedState = { + global: TabsState + cwd: Record +} + +const empty = (): TabsState => ({ tabs: [], unread: {} }) + export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimpleContext({ name: "SessionTabs", init: () => { @@ -33,21 +37,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const data = useData() const event = useEvent() const config = useConfig().data - const filePath = path.join(useTuiPaths().state, "session-tabs.json") + const paths = useTuiPaths() const enabled = () => config.tabs?.enabled ?? false - const state: { - pending: boolean - saving: boolean - snapshot: string - value?: PersistedState - } = { pending: false, saving: false, snapshot: "" } - const [store, setStore] = createStore({ - ready: false, - tabs: [], - unread: {}, + const [store, updateStore] = useStorage().store("tabs", { + initial: { + global: empty(), + cwd: {}, + }, }) + const fallback = empty() let history: SessionTabHistory = { entries: [], index: -1 } + function state() { + if (config.tabs?.scope !== "cwd") return store.global + return store.cwd[paths.cwd] ?? fallback + } + + function update(mutation: (draft: TabsState) => void) { + const scope = config.tabs?.scope ?? "global" + void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch( + () => {}, + ) + } + const root = (sessionID: string) => data.session.root(sessionID) const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined) const status = (sessionID: string) => { @@ -55,7 +67,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const members = data.session.family(session) const family = members.length > 0 ? members : [session] return { - unread: store.unread[session], + unread: state().unread[session], attention: family.some( (id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0, ), @@ -63,125 +75,54 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp } } - function save() { - if (!store.ready) { - state.pending = true - return - } - const value = { tabs: [...store.tabs], unread: { ...store.unread } } - const snapshot = JSON.stringify(value) - if (snapshot === state.snapshot && !state.saving) return - state.value = value - state.pending = true - flush() - } - - function flush() { - if (state.saving || !state.pending || !state.value) return - const value = state.value - const snapshot = JSON.stringify(value) - state.pending = false - if (snapshot === state.snapshot) return - state.saving = true - void writeJsonAtomic(filePath, value) - .then(() => { - state.snapshot = snapshot - }) - .catch(() => {}) - .finally(() => { - state.saving = false - flush() - }) - } - - function open(sessionID: string) { - const session = root(sessionID) - const next = openSessionTab(store.tabs, { sessionID: session, title: data.session.get(session)?.title }) - if (next === store.tabs) return { sessionID: session, changed: false } - setStore("tabs", reconcile(next)) - return { sessionID: session, changed: true } - } - - function clearUnread(sessionID: string) { - const session = root(sessionID) - if (!store.unread[session]) return false - setStore( - "unread", - produce((draft) => { - delete draft[session] - }), - ) - return true - } - function markUnread(sessionID: string, unread: SessionTabUnread) { if (!enabled()) return const session = root(sessionID) - if (current() === session || !store.tabs.some((tab) => tab.sessionID === session)) return - if (store.unread[session] === unread) return - setStore("unread", session, unread) - save() + if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return + if (state().unread[session] === unread) return + update((draft) => { + if (!draft.tabs.some((tab) => tab.sessionID === session)) return + draft.unread[session] = unread + }) } - readJson(filePath) - .then((value) => { - if (!isRecord(value)) return - const persisted = value - if (Array.isArray(persisted.tabs)) - setStore( - "tabs", - persisted.tabs.flatMap((tab) => { - if (!isRecord(tab) || typeof tab.sessionID !== "string") return [] - if ("title" in tab && tab.title !== undefined && typeof tab.title !== "string") return [] - return [{ sessionID: tab.sessionID, title: typeof tab.title === "string" ? tab.title : undefined }] - }), - ) - if (persisted.unread && typeof persisted.unread === "object") - setStore( - "unread", - Object.fromEntries( - Object.entries(persisted.unread).filter( - (entry): entry is [string, SessionTabUnread] => entry[1] === "activity" || entry[1] === "error", - ), - ), - ) - }) - .catch(() => {}) - .finally(() => { - setStore("ready", true) - if (state.pending) save() - else state.snapshot = JSON.stringify({ tabs: store.tabs, unread: store.unread }) - }) - createEffect(() => { if (!enabled()) return - if (!store.ready || route.data.type !== "session" || route.data.sessionID === "dummy") return - const routeSessionID = route.data.sessionID - batch(() => { - const opened = open(routeSessionID) - history = recordSessionTabHistory(history, opened.sessionID) - const changed = clearUnread(opened.sessionID) - if (opened.changed || changed) untrack(save) + if (route.data.type !== "session" || route.data.sessionID === "dummy") return + const sessionID = root(route.data.sessionID) + history = recordSessionTabHistory(history, sessionID) + const title = data.session.get(sessionID)?.title + const tabs = openSessionTab(state().tabs, { sessionID, title }) + if (tabs === state().tabs && !state().unread[sessionID]) return + update((draft) => { + draft.tabs = openSessionTab(draft.tabs, { sessionID, title }) + delete draft.unread[sessionID] }) }) createEffect(() => { - if (!enabled() || !store.ready) return - const next = store.tabs.reduce((tabs, tab) => { + if (!enabled()) return + const next = state().tabs.reduce((tabs, tab) => { const sessionID = root(tab.sessionID) return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title }) }, []) - const unread = Object.entries(store.unread).reduce>((result, entry) => { + const unread = Object.entries(state().unread).reduce>((result, entry) => { const sessionID = root(entry[0]) result[sessionID] = result[sessionID] === "error" ? "error" : entry[1] return result }, {}) - if (isDeepEqual(next, store.tabs) && isDeepEqual(unread, store.unread)) return - batch(() => { - setStore("tabs", reconcile(next)) - setStore("unread", reconcile(unread)) + if (isDeepEqual(next, state().tabs) && isDeepEqual(unread, state().unread)) return + update((draft) => { + draft.tabs = draft.tabs.reduce((tabs, tab) => { + const sessionID = root(tab.sessionID) + return openSessionTab(tabs, { sessionID, title: data.session.get(sessionID)?.title ?? tab.title }) + }, []) + draft.unread = Object.entries(draft.unread).reduce>((result, entry) => { + const sessionID = root(entry[0]) + result[sessionID] = result[sessionID] === "error" ? "error" : entry[1] + return result + }, {}) }) - save() }) onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity"))) @@ -200,26 +141,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp function remove(sessionID: string, navigate: boolean) { const target = root(sessionID) - const closed = closeSessionTab(store.tabs, target) - if (closed.tabs.length === store.tabs.length) return + const closed = closeSessionTab(state().tabs, target) + if (closed.tabs.length === state().tabs.length) return const selected = navigate && current() === target const previous = selected ? moveSessionTabHistory(recordSessionTabHistory(history, target), closed.tabs, target, -1) : { history, sessionID: undefined } const next = previous.sessionID ?? closed.next history = previous.history - batch(() => { - setStore("tabs", reconcile(closed.tabs)) - clearUnread(target) - if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" }) + update((draft) => { + draft.tabs = closeSessionTab(draft.tabs, target).tabs + delete draft.unread[target] }) - save() + if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" }) } return { enabled, tabs() { - return store.tabs + return state().tabs }, current, status, @@ -231,7 +171,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (!enabled()) return const target = sessionID ? root(sessionID) : current() if (!target) { - const previous = store.tabs.at(-1) + const previous = state().tabs.at(-1) if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID }) return } @@ -239,13 +179,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }, cycle(direction: 1 | -1) { if (!enabled()) return - const tab = cycleSessionTab(store.tabs, current(), direction) + const tab = cycleSessionTab(state().tabs, current(), direction) if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) }, cycleUnread(direction: 1 | -1) { if (!enabled()) return const tab = cycleSessionTab( - store.tabs.filter((tab) => store.unread[tab.sessionID] || status(tab.sessionID).attention), + state().tabs.filter((tab) => state().unread[tab.sessionID] || status(tab.sessionID).attention), current(), direction, ) @@ -253,13 +193,13 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }, history(direction: 1 | -1) { if (!enabled()) return - const next = moveSessionTabHistory(history, store.tabs, current(), direction) + const next = moveSessionTabHistory(history, state().tabs, current(), direction) history = next.history if (next.sessionID) route.navigate({ type: "session", sessionID: next.sessionID }) }, selectIndex(index: number) { if (!enabled()) return - const tab = store.tabs[index] + const tab = state().tabs[index] if (tab) route.navigate({ type: "session", sessionID: tab.sessionID }) }, } diff --git a/packages/tui/src/context/storage.tsx b/packages/tui/src/context/storage.tsx new file mode 100644 index 00000000000..0f1303c7817 --- /dev/null +++ b/packages/tui/src/context/storage.tsx @@ -0,0 +1,94 @@ +import { batch, createContext, onCleanup, useContext, type ParentProps } from "solid-js" +import { createStore, reconcile, type Store } from "solid-js/store" +import path from "path" +import { mkdirSync, readFileSync, watch } from "fs" +import { Flock } from "@opencode-ai/util/flock" +import { writeJsonAtomic } from "../util/persistence" +import { useTuiApp, useTuiPaths } from "./runtime" + +type Options = { + readonly initial: Value +} + +type Entry = readonly [Store, (mutation: (draft: Value) => void) => Promise] + +export interface Storage { + store( + key: string, + options: Options, + ): readonly [Store, (mutation: (draft: Value) => void) => Promise] +} + +function clone(value: Value) { + const json = JSON.stringify(value) + if (json === undefined) throw new TypeError("Storage values must be JSON-compatible objects") + const result = JSON.parse(json) as Value + if (typeof result !== "object" || result === null) throw new TypeError("Storage values must be objects") + return result as Value +} + +function segment(value: string) { + if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(value) || value === "." || value === "..") + throw new TypeError(`Invalid storage segment: ${value}`) + return value +} + +function createStorage(root: string, channel: string) { + const entries = new Map; readonly reload: () => void }>() + const directory = path.join(root, segment(channel), "tui") + const locks = path.join(root, segment(channel), "locks") + mkdirSync(directory, { recursive: true }) + + const storage: Storage = { + store(key: string, options: Options) { + const file = path.join(directory, segment(key) + ".json") + const existing = entries.get(file) + if (existing) return existing.value as Entry + + const load = () => { + try { + return clone(JSON.parse(readFileSync(file, "utf8")) as Value) + } catch { + return clone(options.initial) + } + } + const [store, setStore] = createStore(load()) + const reload = () => batch(() => setStore(reconcile(load()))) + const update = (mutation: (draft: Value) => void) => + Flock.withLock( + file, + async () => { + const draft = load() + mutation(draft) + const next = clone(draft) + await writeJsonAtomic(file, next) + batch(() => setStore(reconcile(next))) + }, + { dir: locks }, + ) + const entry = [store, update] as const + entries.set(file, { value: entry as Entry, reload }) + return entry + }, + } + + const watcher = watch(directory, () => entries.forEach((entry) => entry.reload())) + return { + storage, + close: () => watcher.close(), + } +} + +const Context = createContext() + +export function StorageProvider(props: ParentProps) { + const result = createStorage(path.join(useTuiPaths().state, "storage"), useTuiApp().channel) + onCleanup(result.close) + return {props.children} +} + +export function useStorage() { + const storage = useContext(Context) + if (!storage) throw new Error("StorageProvider is missing") + return storage +} diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a5e23d0ec75..a6d8f1f4cca 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -32,6 +32,7 @@ import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useToast } from "../ui/toast" import { useAttention } from "../context/attention" +import { useStorage } from "../context/storage" import { abbreviateHome } from "../util/path-format" import { builtins } from "./builtins" import { discoverTuiPlugins } from "./discovery" @@ -96,6 +97,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> const dialog = useDialog() const toast = useToast() const attention = useAttention() + const storage = useStorage() const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -234,6 +236,9 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }> active: keymapState.active, mode: keymap.mode, }, + storage: { + store: (key, options) => storage.store(`plugin.${item.plugin.id}.${key}`, options), + }, ui: { dialog: dialogApi, toast: toastApi,