mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 09:09:50 +00:00
feat(tui): add persistent storage context
This commit is contained in:
parent
813c41ff6c
commit
9ee337469d
7 changed files with 280 additions and 223 deletions
|
|
@ -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<Value extends object>(
|
||||
key: string,
|
||||
options: {
|
||||
readonly initial: Value
|
||||
},
|
||||
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
|
||||
}
|
||||
|
||||
interface LocationCollection<Value> {
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}}
|
||||
>
|
||||
<TuiLifecycleProvider
|
||||
value={{
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TuiTerminalEnvironmentProvider
|
||||
<StorageProvider>
|
||||
<TuiLifecycleProvider
|
||||
value={{
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
add(finalizer) {
|
||||
finalizers.add(finalizer)
|
||||
return () => finalizers.delete(finalizer)
|
||||
},
|
||||
}}
|
||||
>
|
||||
<TuiStartupProvider
|
||||
<TuiTerminalEnvironmentProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
platform: process.platform,
|
||||
multiplexer: process.env.TMUX ? "tmux" : process.env.STY ? "screen" : undefined,
|
||||
displayServer: process.env.WAYLAND_DISPLAY
|
||||
? "wayland"
|
||||
: process.env.DISPLAY
|
||||
? "x11"
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
skipInitialLoading: Boolean(process.env.OPENCODE_FAST_BOOT),
|
||||
}}
|
||||
>
|
||||
<ClipboardProvider>
|
||||
<ArgsProvider {...input.args}>
|
||||
<ConfigProvider
|
||||
config={config}
|
||||
service={input.config}
|
||||
options={{ terminalSuspend: process.platform !== "win32" }}
|
||||
>
|
||||
<Keymap.Provider>
|
||||
<ToastProvider>
|
||||
<RouteProvider
|
||||
initialRoute={
|
||||
input.args.continue
|
||||
? {
|
||||
type: "session",
|
||||
sessionID: "dummy",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ClientProvider api={api} service={service}>
|
||||
<PermissionProvider>
|
||||
<DataProvider>
|
||||
<LocationProvider>
|
||||
<SessionTabsProvider>
|
||||
<ThemeProvider mode={mode}>
|
||||
<ThemeErrorToast />
|
||||
<LocalProvider>
|
||||
<PromptStashProvider>
|
||||
<DialogProvider>
|
||||
<FrecencyProvider>
|
||||
<PromptHistoryProvider>
|
||||
<PromptRefProvider>
|
||||
<EditorContextProvider>
|
||||
<AttentionProvider>
|
||||
<PluginProvider packages={input.packages}>
|
||||
<App
|
||||
pair={
|
||||
input.server.endpoint.auth
|
||||
? input.server.endpoint.auth
|
||||
: {
|
||||
username: "opencode",
|
||||
password: "",
|
||||
}
|
||||
}
|
||||
/>
|
||||
</PluginProvider>
|
||||
</AttentionProvider>
|
||||
</EditorContextProvider>
|
||||
</PromptRefProvider>
|
||||
</PromptHistoryProvider>
|
||||
</FrecencyProvider>
|
||||
</DialogProvider>
|
||||
</PromptStashProvider>
|
||||
</LocalProvider>
|
||||
</ThemeProvider>
|
||||
</SessionTabsProvider>
|
||||
</LocationProvider>
|
||||
</DataProvider>
|
||||
</PermissionProvider>
|
||||
</ClientProvider>
|
||||
</RouteProvider>
|
||||
</ToastProvider>
|
||||
</Keymap.Provider>
|
||||
</ConfigProvider>
|
||||
</ArgsProvider>
|
||||
</ClipboardProvider>
|
||||
</TuiStartupProvider>
|
||||
</TuiTerminalEnvironmentProvider>
|
||||
</TuiLifecycleProvider>
|
||||
</StorageProvider>
|
||||
</TuiPathsProvider>
|
||||
</ErrorBoundary>
|
||||
</TuiAppProvider>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<string, SessionTabUnread>
|
||||
}
|
||||
|
||||
type PersistedState = {
|
||||
global: TabsState
|
||||
cwd: Record<string, TabsState>
|
||||
}
|
||||
|
||||
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<PersistedState & { ready: boolean }>({
|
||||
ready: false,
|
||||
tabs: [],
|
||||
unread: {},
|
||||
const [store, updateStore] = useStorage().store<PersistedState>("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<unknown>(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<SessionTab[]>((tabs, tab) => {
|
||||
if (!enabled()) return
|
||||
const next = state().tabs.reduce<SessionTab[]>((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<Record<string, SessionTabUnread>>((result, entry) => {
|
||||
const unread = Object.entries(state().unread).reduce<Record<string, SessionTabUnread>>((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<SessionTab[]>((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<Record<string, SessionTabUnread>>((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 })
|
||||
},
|
||||
}
|
||||
|
|
|
|||
94
packages/tui/src/context/storage.tsx
Normal file
94
packages/tui/src/context/storage.tsx
Normal file
|
|
@ -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<Value extends object> = {
|
||||
readonly initial: Value
|
||||
}
|
||||
|
||||
type Entry<Value extends object> = readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
|
||||
|
||||
export interface Storage {
|
||||
store<Value extends object>(
|
||||
key: string,
|
||||
options: Options<Value>,
|
||||
): readonly [Store<Value>, (mutation: (draft: Value) => void) => Promise<void>]
|
||||
}
|
||||
|
||||
function clone<Value extends object>(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<string, { readonly value: Entry<object>; 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<Value extends object>(key: string, options: Options<Value>) {
|
||||
const file = path.join(directory, segment(key) + ".json")
|
||||
const existing = entries.get(file)
|
||||
if (existing) return existing.value as Entry<Value>
|
||||
|
||||
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<object>, reload })
|
||||
return entry
|
||||
},
|
||||
}
|
||||
|
||||
const watcher = watch(directory, () => entries.forEach((entry) => entry.reload()))
|
||||
return {
|
||||
storage,
|
||||
close: () => watcher.close(),
|
||||
}
|
||||
}
|
||||
|
||||
const Context = createContext<Storage>()
|
||||
|
||||
export function StorageProvider(props: ParentProps) {
|
||||
const result = createStorage(path.join(useTuiPaths().state, "storage"), useTuiApp().channel)
|
||||
onCleanup(result.close)
|
||||
return <Context.Provider value={result.storage}>{props.children}</Context.Provider>
|
||||
}
|
||||
|
||||
export function useStorage() {
|
||||
const storage = useContext(Context)
|
||||
if (!storage) throw new Error("StorageProvider is missing")
|
||||
return storage
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue