summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 10:02:31 -0600
committerGitHub <[email protected]>2026-02-06 10:02:31 -0600
commit2c58dd6203df7806f57ef6b29672091cb764e871 (patch)
tree10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/context
parenta4bc883595df9ea0f752079519081bc602408553 (diff)
downloadopencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz
opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/command-keybind.test.ts43
-rw-r--r--packages/app/src/context/file-content-eviction-accounting.test.ts40
-rw-r--r--packages/app/src/context/file.tsx716
-rw-r--r--packages/app/src/context/file/content-cache.ts88
-rw-r--r--packages/app/src/context/file/path.test.ts27
-rw-r--r--packages/app/src/context/file/path.ts119
-rw-r--r--packages/app/src/context/file/tree-store.ts170
-rw-r--r--packages/app/src/context/file/types.ts41
-rw-r--r--packages/app/src/context/file/view-cache.ts136
-rw-r--r--packages/app/src/context/file/watcher.test.ts118
-rw-r--r--packages/app/src/context/file/watcher.ts52
-rw-r--r--packages/app/src/context/global-sync.tsx1225
-rw-r--r--packages/app/src/context/global-sync/bootstrap.ts195
-rw-r--r--packages/app/src/context/global-sync/child-store.ts263
-rw-r--r--packages/app/src/context/global-sync/event-reducer.test.ts201
-rw-r--r--packages/app/src/context/global-sync/event-reducer.ts319
-rw-r--r--packages/app/src/context/global-sync/eviction.ts28
-rw-r--r--packages/app/src/context/global-sync/queue.ts83
-rw-r--r--packages/app/src/context/global-sync/session-load.ts26
-rw-r--r--packages/app/src/context/global-sync/session-trim.test.ts59
-rw-r--r--packages/app/src/context/global-sync/session-trim.ts56
-rw-r--r--packages/app/src/context/global-sync/types.ts134
-rw-r--r--packages/app/src/context/global-sync/utils.ts25
-rw-r--r--packages/app/src/context/language.tsx20
-rw-r--r--packages/app/src/context/layout-scroll.test.ts83
-rw-r--r--packages/app/src/context/server.tsx16
-rw-r--r--packages/app/src/context/sync-optimistic.test.ts56
-rw-r--r--packages/app/src/context/sync.tsx75
28 files changed, 2599 insertions, 1815 deletions
diff --git a/packages/app/src/context/command-keybind.test.ts b/packages/app/src/context/command-keybind.test.ts
new file mode 100644
index 000000000..4e38efd8d
--- /dev/null
+++ b/packages/app/src/context/command-keybind.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import { formatKeybind, matchKeybind, parseKeybind } from "./command"
+
+describe("command keybind helpers", () => {
+ test("parseKeybind handles aliases and multiple combos", () => {
+ const keybinds = parseKeybind("control+option+k, mod+shift+comma")
+
+ expect(keybinds).toHaveLength(2)
+ expect(keybinds[0]).toEqual({
+ key: "k",
+ ctrl: true,
+ meta: false,
+ shift: false,
+ alt: true,
+ })
+ expect(keybinds[1]?.shift).toBe(true)
+ expect(keybinds[1]?.key).toBe("comma")
+ expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true)
+ })
+
+ test("parseKeybind treats none and empty as disabled", () => {
+ expect(parseKeybind("none")).toEqual([])
+ expect(parseKeybind("")).toEqual([])
+ })
+
+ test("matchKeybind normalizes punctuation keys", () => {
+ const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space")
+
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true)
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true)
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true)
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
+ })
+
+ test("formatKeybind returns human readable output", () => {
+ const display = formatKeybind("ctrl+alt+arrowup")
+
+ expect(display).toContain("↑")
+ expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true)
+ expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
+ expect(formatKeybind("none")).toBe("")
+ })
+})
diff --git a/packages/app/src/context/file-content-eviction-accounting.test.ts b/packages/app/src/context/file-content-eviction-accounting.test.ts
index 9a455e2af..4ef5f947c 100644
--- a/packages/app/src/context/file-content-eviction-accounting.test.ts
+++ b/packages/app/src/context/file-content-eviction-accounting.test.ts
@@ -1,33 +1,13 @@
-import { afterEach, beforeAll, describe, expect, mock, test } from "bun:test"
-
-let evictContentLru: (keep: Set<string> | undefined, evict: (path: string) => void) => void
-let getFileContentBytesTotal: () => number
-let getFileContentEntryCount: () => number
-let removeFileContentBytes: (path: string) => void
-let resetFileContentLru: () => void
-let setFileContentBytes: (path: string, bytes: number) => void
-let touchFileContent: (path: string, bytes?: number) => void
-
-beforeAll(async () => {
- mock.module("@solidjs/router", () => ({
- useParams: () => ({}),
- }))
- mock.module("@opencode-ai/ui/context", () => ({
- createSimpleContext: () => ({
- use: () => undefined,
- provider: () => undefined,
- }),
- }))
-
- const mod = await import("./file")
- evictContentLru = mod.evictContentLru
- getFileContentBytesTotal = mod.getFileContentBytesTotal
- getFileContentEntryCount = mod.getFileContentEntryCount
- removeFileContentBytes = mod.removeFileContentBytes
- resetFileContentLru = mod.resetFileContentLru
- setFileContentBytes = mod.setFileContentBytes
- touchFileContent = mod.touchFileContent
-})
+import { afterEach, describe, expect, test } from "bun:test"
+import {
+ evictContentLru,
+ getFileContentBytesTotal,
+ getFileContentEntryCount,
+ removeFileContentBytes,
+ resetFileContentLru,
+ setFileContentBytes,
+ touchFileContent,
+} from "./file/content-cache"
describe("file content eviction accounting", () => {
afterEach(() => {
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index 164da726f..996ea2aaf 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -1,324 +1,45 @@
-import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
+import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
-import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useLanguage } from "@/context/language"
-import { Persist, persisted } from "@/utils/persist"
-import { createScopedCache } from "@/utils/scoped-cache"
-
-export type FileSelection = {
- startLine: number
- startChar: number
- endLine: number
- endChar: number
-}
-
-export type SelectedLineRange = {
- start: number
- end: number
- side?: "additions" | "deletions"
- endSide?: "additions" | "deletions"
-}
-
-export type FileViewState = {
- scrollTop?: number
- scrollLeft?: number
- selectedLines?: SelectedLineRange | null
-}
-
-export type FileState = {
- path: string
- name: string
- loaded?: boolean
- loading?: boolean
- error?: string
- content?: FileContent
-}
-
-type DirectoryState = {
- expanded: boolean
- loaded?: boolean
- loading?: boolean
- error?: string
- children?: string[]
-}
-
-function stripFileProtocol(input: string) {
- if (!input.startsWith("file://")) return input
- return input.slice("file://".length)
-}
-
-function stripQueryAndHash(input: string) {
- const hashIndex = input.indexOf("#")
- const queryIndex = input.indexOf("?")
-
- if (hashIndex !== -1 && queryIndex !== -1) {
- return input.slice(0, Math.min(hashIndex, queryIndex))
- }
-
- if (hashIndex !== -1) return input.slice(0, hashIndex)
- if (queryIndex !== -1) return input.slice(0, queryIndex)
- return input
-}
-
-function unquoteGitPath(input: string) {
- if (!input.startsWith('"')) return input
- if (!input.endsWith('"')) return input
- const body = input.slice(1, -1)
- const bytes: number[] = []
-
- for (let i = 0; i < body.length; i++) {
- const char = body[i]!
- if (char !== "\\") {
- bytes.push(char.charCodeAt(0))
- continue
- }
-
- const next = body[i + 1]
- if (!next) {
- bytes.push("\\".charCodeAt(0))
- continue
- }
-
- if (next >= "0" && next <= "7") {
- const chunk = body.slice(i + 1, i + 4)
- const match = chunk.match(/^[0-7]{1,3}/)
- if (!match) {
- bytes.push(next.charCodeAt(0))
- i++
- continue
- }
- bytes.push(parseInt(match[0], 8))
- i += match[0].length
- continue
- }
-
- const escaped =
- next === "n"
- ? "\n"
- : next === "r"
- ? "\r"
- : next === "t"
- ? "\t"
- : next === "b"
- ? "\b"
- : next === "f"
- ? "\f"
- : next === "v"
- ? "\v"
- : next === "\\" || next === '"'
- ? next
- : undefined
-
- bytes.push((escaped ?? next).charCodeAt(0))
- i++
- }
-
- return new TextDecoder().decode(new Uint8Array(bytes))
-}
-
-export function selectionFromLines(range: SelectedLineRange): FileSelection {
- const startLine = Math.min(range.start, range.end)
- const endLine = Math.max(range.start, range.end)
- return {
- startLine,
- endLine,
- startChar: 0,
- endChar: 0,
- }
-}
-
-function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
- if (range.start <= range.end) return range
-
- const startSide = range.side
- const endSide = range.endSide ?? startSide
-
- return {
- ...range,
- start: range.end,
- end: range.start,
- side: endSide,
- endSide: startSide !== endSide ? startSide : undefined,
- }
-}
-
-const WORKSPACE_KEY = "__workspace__"
-const MAX_FILE_VIEW_SESSIONS = 20
-const MAX_VIEW_FILES = 500
-
-const MAX_FILE_CONTENT_ENTRIES = 40
-const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
-
-const contentLru = new Map<string, number>()
-let contentBytesTotal = 0
-
-function approxBytes(content: FileContent) {
- const patchBytes =
- content.patch?.hunks.reduce((total, hunk) => {
- return total + hunk.lines.reduce((sum, line) => sum + line.length, 0)
- }, 0) ?? 0
-
- return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
-}
-
-function setContentBytes(path: string, nextBytes: number) {
- const prev = contentLru.get(path)
- if (prev !== undefined) contentBytesTotal -= prev
- contentLru.delete(path)
- contentLru.set(path, nextBytes)
- contentBytesTotal += nextBytes
-}
-
-function touchContent(path: string, bytes?: number) {
- const prev = contentLru.get(path)
- if (prev === undefined && bytes === undefined) return
- setContentBytes(path, bytes ?? prev ?? 0)
-}
-
-function removeContentBytes(path: string) {
- const prev = contentLru.get(path)
- if (prev === undefined) return
- contentLru.delete(path)
- contentBytesTotal -= prev
-}
-
-function resetContentBytes() {
- contentLru.clear()
- contentBytesTotal = 0
-}
-
-export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
- const protectedSet = keep ?? new Set<string>()
-
- while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || contentBytesTotal > MAX_FILE_CONTENT_BYTES) {
- const path = contentLru.keys().next().value
- if (!path) return
-
- if (protectedSet.has(path)) {
- touchContent(path)
- if (contentLru.size <= protectedSet.size) return
- continue
- }
-
- removeContentBytes(path)
- evict(path)
- }
-}
-
-export function resetFileContentLru() {
- resetContentBytes()
-}
-
-export function setFileContentBytes(path: string, bytes: number) {
- setContentBytes(path, bytes)
-}
-
-export function removeFileContentBytes(path: string) {
- removeContentBytes(path)
-}
-
-export function touchFileContent(path: string, bytes?: number) {
- touchContent(path, bytes)
-}
-
-export function getFileContentBytesTotal() {
- return contentBytesTotal
-}
-
-export function getFileContentEntryCount() {
- return contentLru.size
-}
-
-function createViewSession(dir: string, id: string | undefined) {
- const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
-
- const [view, setView, _, ready] = persisted(
- Persist.scoped(dir, id, "file-view", [legacyViewKey]),
- createStore<{
- file: Record<string, FileViewState>
- }>({
- file: {},
- }),
- )
-
- const meta = { pruned: false }
-
- const pruneView = (keep?: string) => {
- const keys = Object.keys(view.file)
- if (keys.length <= MAX_VIEW_FILES) return
-
- const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
- if (drop.length === 0) return
-
- setView(
- produce((draft) => {
- for (const key of drop) {
- delete draft.file[key]
- }
- }),
- )
- }
-
- createEffect(() => {
- if (!ready()) return
- if (meta.pruned) return
- meta.pruned = true
- pruneView()
- })
-
- const scrollTop = (path: string) => view.file[path]?.scrollTop
- const scrollLeft = (path: string) => view.file[path]?.scrollLeft
- const selectedLines = (path: string) => view.file[path]?.selectedLines
-
- const setScrollTop = (path: string, top: number) => {
- setView("file", path, (current) => {
- if (current?.scrollTop === top) return current
- return {
- ...(current ?? {}),
- scrollTop: top,
- }
- })
- pruneView(path)
- }
-
- const setScrollLeft = (path: string, left: number) => {
- setView("file", path, (current) => {
- if (current?.scrollLeft === left) return current
- return {
- ...(current ?? {}),
- scrollLeft: left,
- }
- })
- pruneView(path)
- }
-
- const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
- const next = range ? normalizeSelectedLines(range) : null
- setView("file", path, (current) => {
- if (current?.selectedLines === next) return current
- return {
- ...(current ?? {}),
- selectedLines: next,
- }
- })
- pruneView(path)
- }
-
- return {
- ready,
- scrollTop,
- scrollLeft,
- selectedLines,
- setScrollTop,
- setScrollLeft,
- setSelectedLines,
- }
+import { createPathHelpers } from "./file/path"
+import {
+ approxBytes,
+ evictContentLru,
+ getFileContentBytesTotal,
+ getFileContentEntryCount,
+ hasFileContent,
+ removeFileContentBytes,
+ resetFileContentLru,
+ setFileContentBytes,
+ touchFileContent,
+} from "./file/content-cache"
+import { createFileViewCache } from "./file/view-cache"
+import { createFileTreeStore } from "./file/tree-store"
+import { invalidateFromWatcher } from "./file/watcher"
+import {
+ selectionFromLines,
+ type FileState,
+ type FileSelection,
+ type FileViewState,
+ type SelectedLineRange,
+} from "./file/types"
+
+export type { FileSelection, SelectedLineRange, FileViewState, FileState }
+export { selectionFromLines }
+export {
+ evictContentLru,
+ getFileContentBytesTotal,
+ getFileContentEntryCount,
+ removeFileContentBytes,
+ resetFileContentLru,
+ setFileContentBytes,
+ touchFileContent,
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
@@ -326,76 +47,39 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
gate: false,
init: () => {
const sdk = useSDK()
- const sync = useSync()
+ useSync()
const params = useParams()
const language = useLanguage()
const scope = createMemo(() => sdk.directory)
-
- function normalize(input: string) {
- const root = scope()
- const prefix = root.endsWith("/") ? root : root + "/"
-
- let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
-
- if (path.startsWith(prefix)) {
- path = path.slice(prefix.length)
- }
-
- if (path.startsWith(root)) {
- path = path.slice(root.length)
- }
-
- if (path.startsWith("./")) {
- path = path.slice(2)
- }
-
- if (path.startsWith("/")) {
- path = path.slice(1)
- }
-
- return path
- }
-
- function tab(input: string) {
- const path = normalize(input)
- return `file://${path}`
- }
-
- function pathFromTab(tabValue: string) {
- if (!tabValue.startsWith("file://")) return
- return normalize(tabValue)
- }
+ const path = createPathHelpers(scope)
const inflight = new Map<string, Promise<void>>()
- const treeInflight = new Map<string, Promise<void>>()
-
- const search = (query: string, dirs: "true" | "false") =>
- sdk.client.find.files({ query, dirs }).then(
- (x) => (x.data ?? []).map(normalize),
- () => [],
- )
-
const [store, setStore] = createStore<{
file: Record<string, FileState>
}>({
file: {},
})
- const [tree, setTree] = createStore<{
- node: Record<string, FileNode>
- dir: Record<string, DirectoryState>
- }>({
- node: {},
- dir: { "": { expanded: true } },
+ const tree = createFileTreeStore({
+ scope,
+ normalizeDir: path.normalizeDir,
+ list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
+ onError: (message) => {
+ showToast({
+ variant: "error",
+ title: language.t("toast.file.listFailed.title"),
+ description: message,
+ })
+ },
})
const evictContent = (keep?: Set<string>) => {
- evictContentLru(keep, (path) => {
- if (!store.file[path]) return
+ evictContentLru(keep, (target) => {
+ if (!store.file[target]) return
setStore(
"file",
- path,
+ target,
produce((draft) => {
draft.content = undefined
draft.loaded = false
@@ -407,57 +91,31 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
createEffect(() => {
scope()
inflight.clear()
- treeInflight.clear()
- resetContentBytes()
-
+ resetFileContentLru()
batch(() => {
setStore("file", reconcile({}))
- setTree("node", reconcile({}))
- setTree("dir", reconcile({}))
- setTree("dir", "", { expanded: true })
+ tree.reset()
})
})
- const viewCache = createScopedCache(
- (key) => {
- const split = key.lastIndexOf("\n")
- const dir = split >= 0 ? key.slice(0, split) : key
- const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
- return createRoot((dispose) => ({
- value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
- dispose,
- }))
- },
- {
- maxEntries: MAX_FILE_VIEW_SESSIONS,
- dispose: (entry) => entry.dispose(),
- },
- )
-
- const loadView = (dir: string, id: string | undefined) => {
- const key = `${dir}\n${id ?? WORKSPACE_KEY}`
- return viewCache.get(key).value
- }
-
- const view = createMemo(() => loadView(scope(), params.id))
+ const viewCache = createFileViewCache()
+ const view = createMemo(() => viewCache.load(scope(), params.id))
- function ensure(path: string) {
- if (!path) return
- if (store.file[path]) return
- setStore("file", path, { path, name: getFilename(path) })
+ const ensure = (file: string) => {
+ if (!file) return
+ if (store.file[file]) return
+ setStore("file", file, { path: file, name: getFilename(file) })
}
- function load(input: string, options?: { force?: boolean }) {
- const path = normalize(input)
- if (!path) return Promise.resolve()
+ const load = (input: string, options?: { force?: boolean }) => {
+ const file = path.normalize(input)
+ if (!file) return Promise.resolve()
const directory = scope()
- const key = `${directory}\n${path}`
- const client = sdk.client
-
- ensure(path)
+ const key = `${directory}\n${file}`
+ ensure(file)
- const current = store.file[path]
+ const current = store.file[file]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = inflight.get(key)
@@ -465,21 +123,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setStore(
"file",
- path,
+ file,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
- const promise = client.file
- .read({ path })
+ const promise = sdk.client.file
+ .read({ path: file })
.then((x) => {
if (scope() !== directory) return
const content = x.data
setStore(
"file",
- path,
+ file,
produce((draft) => {
draft.loaded = true
draft.loading = false
@@ -488,14 +146,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
)
if (!content) return
- touchContent(path, approxBytes(content))
- evictContent(new Set([path]))
+ touchFileContent(file, approxBytes(content))
+ evictContent(new Set([file]))
})
.catch((e) => {
if (scope() !== directory) return
setStore(
"file",
- path,
+ file,
produce((draft) => {
draft.loading = false
draft.error = e.message
@@ -515,200 +173,54 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
- function normalizeDir(input: string) {
- return normalize(input).replace(/\/+$/, "")
- }
-
- function ensureDir(path: string) {
- if (tree.dir[path]) return
- setTree("dir", path, { expanded: false })
- }
-
- function listDir(input: string, options?: { force?: boolean }) {
- const dir = normalizeDir(input)
- ensureDir(dir)
-
- const current = tree.dir[dir]
- if (!options?.force && current?.loaded) return Promise.resolve()
-
- const pending = treeInflight.get(dir)
- if (pending) return pending
-
- setTree(
- "dir",
- dir,
- produce((draft) => {
- draft.loading = true
- draft.error = undefined
- }),
+ const search = (query: string, dirs: "true" | "false") =>
+ sdk.client.find.files({ query, dirs }).then(
+ (x) => (x.data ?? []).map(path.normalize),
+ () => [],
)
- const directory = scope()
-
- const promise = sdk.client.file
- .list({ path: dir })
- .then((x) => {
- if (scope() !== directory) return
- const nodes = x.data ?? []
- const prevChildren = tree.dir[dir]?.children ?? []
- const nextChildren = nodes.map((node) => node.path)
- const nextSet = new Set(nextChildren)
-
- setTree(
- "node",
- produce((draft) => {
- const removedDirs: string[] = []
-
- for (const child of prevChildren) {
- if (nextSet.has(child)) continue
- const existing = draft[child]
- if (existing?.type === "directory") removedDirs.push(child)
- delete draft[child]
- }
-
- if (removedDirs.length > 0) {
- const keys = Object.keys(draft)
- for (const key of keys) {
- for (const removed of removedDirs) {
- if (!key.startsWith(removed + "/")) continue
- delete draft[key]
- break
- }
- }
- }
-
- for (const node of nodes) {
- draft[node.path] = node
- }
- }),
- )
-
- setTree(
- "dir",
- dir,
- produce((draft) => {
- draft.loaded = true
- draft.loading = false
- draft.children = nextChildren
- }),
- )
- })
- .catch((e) => {
- if (scope() !== directory) return
- setTree(
- "dir",
- dir,
- produce((draft) => {
- draft.loading = false
- draft.error = e.message
- }),
- )
- showToast({
- variant: "error",
- title: language.t("toast.file.listFailed.title"),
- description: e.message,
- })
- })
- .finally(() => {
- treeInflight.delete(dir)
- })
-
- treeInflight.set(dir, promise)
- return promise
- }
-
- function expandDir(input: string) {
- const dir = normalizeDir(input)
- ensureDir(dir)
- setTree("dir", dir, "expanded", true)
- void listDir(dir)
- }
-
- function collapseDir(input: string) {
- const dir = normalizeDir(input)
- ensureDir(dir)
- setTree("dir", dir, "expanded", false)
- }
-
- function dirState(input: string) {
- const dir = normalizeDir(input)
- return tree.dir[dir]
- }
-
- function children(input: string) {
- const dir = normalizeDir(input)
- const ids = tree.dir[dir]?.children
- if (!ids) return []
- const out: FileNode[] = []
- for (const id of ids) {
- const node = tree.node[id]
- if (node) out.push(node)
- }
- return out
- }
-
const stop = sdk.event.listen((e) => {
- const event = e.details
- if (event.type !== "file.watcher.updated") return
- const path = normalize(event.properties.file)
- if (!path) return
- if (path.startsWith(".git/")) return
-
- if (store.file[path]) {
- load(path, { force: true })
- }
-
- const kind = event.properties.event
- if (kind === "change") {
- const dir = (() => {
- if (path === "") return ""
- const node = tree.node[path]
- if (node?.type !== "directory") return
- return path
- })()
- if (dir === undefined) return
- if (!tree.dir[dir]?.loaded) return
- listDir(dir, { force: true })
- return
- }
- if (kind !== "add" && kind !== "unlink") return
-
- const parent = path.split("/").slice(0, -1).join("/")
- if (!tree.dir[parent]?.loaded) return
-
- listDir(parent, { force: true })
+ invalidateFromWatcher(e.details, {
+ normalize: path.normalize,
+ hasFile: (file) => Boolean(store.file[file]),
+ loadFile: (file) => {
+ void load(file, { force: true })
+ },
+ node: tree.node,
+ isDirLoaded: tree.isLoaded,
+ refreshDir: (dir) => {
+ void tree.listDir(dir, { force: true })
+ },
+ })
})
const get = (input: string) => {
- const path = normalize(input)
- const file = store.file[path]
- const content = file?.content
- if (!content) return file
- if (contentLru.has(path)) {
- touchContent(path)
- return file
+ const file = path.normalize(input)
+ const state = store.file[file]
+ const content = state?.content
+ if (!content) return state
+ if (hasFileContent(file)) {
+ touchFileContent(file)
+ return state
}
- touchContent(path, approxBytes(content))
- return file
+ touchFileContent(file, approxBytes(content))
+ return state
}
- const scrollTop = (input: string) => view().scrollTop(normalize(input))
- const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
- const selectedLines = (input: string) => view().selectedLines(normalize(input))
+ const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
+ const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
+ const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
const setScrollTop = (input: string, top: number) => {
- const path = normalize(input)
- view().setScrollTop(path, top)
+ view().setScrollTop(path.normalize(input), top)
}
const setScrollLeft = (input: string, left: number) => {
- const path = normalize(input)
- view().setScrollLeft(path, left)
+ view().setScrollLeft(path.normalize(input), left)
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
- const path = normalize(input)
- view().setSelectedLines(path, range)
+ view().setSelectedLines(path.normalize(input), range)
}
onCleanup(() => {
@@ -718,22 +230,22 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return {
ready: () => view().ready(),
- normalize,
- tab,
- pathFromTab,
+ normalize: path.normalize,
+ tab: path.tab,
+ pathFromTab: path.pathFromTab,
tree: {
- list: listDir,
- refresh: (input: string) => listDir(input, { force: true }),
- state: dirState,
- children,
- expand: expandDir,
- collapse: collapseDir,
+ list: tree.listDir,
+ refresh: (input: string) => tree.listDir(input, { force: true }),
+ state: tree.dirState,
+ children: tree.children,
+ expand: tree.expandDir,
+ collapse: tree.collapseDir,
toggle(input: string) {
- if (dirState(input)?.expanded) {
- collapseDir(input)
+ if (tree.dirState(input)?.expanded) {
+ tree.collapseDir(input)
return
}
- expandDir(input)
+ tree.expandDir(input)
},
},
get,
diff --git a/packages/app/src/context/file/content-cache.ts b/packages/app/src/context/file/content-cache.ts
new file mode 100644
index 000000000..4b7240688
--- /dev/null
+++ b/packages/app/src/context/file/content-cache.ts
@@ -0,0 +1,88 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+const MAX_FILE_CONTENT_ENTRIES = 40
+const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
+
+const lru = new Map<string, number>()
+let total = 0
+
+export function approxBytes(content: FileContent) {
+ const patchBytes =
+ content.patch?.hunks.reduce((sum, hunk) => {
+ return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0)
+ }, 0) ?? 0
+
+ return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
+}
+
+function setBytes(path: string, nextBytes: number) {
+ const prev = lru.get(path)
+ if (prev !== undefined) total -= prev
+ lru.delete(path)
+ lru.set(path, nextBytes)
+ total += nextBytes
+}
+
+function touch(path: string, bytes?: number) {
+ const prev = lru.get(path)
+ if (prev === undefined && bytes === undefined) return
+ setBytes(path, bytes ?? prev ?? 0)
+}
+
+function remove(path: string) {
+ const prev = lru.get(path)
+ if (prev === undefined) return
+ lru.delete(path)
+ total -= prev
+}
+
+function reset() {
+ lru.clear()
+ total = 0
+}
+
+export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) {
+ const set = keep ?? new Set<string>()
+
+ while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) {
+ const path = lru.keys().next().value
+ if (!path) return
+
+ if (set.has(path)) {
+ touch(path)
+ if (lru.size <= set.size) return
+ continue
+ }
+
+ remove(path)
+ evict(path)
+ }
+}
+
+export function resetFileContentLru() {
+ reset()
+}
+
+export function setFileContentBytes(path: string, bytes: number) {
+ setBytes(path, bytes)
+}
+
+export function removeFileContentBytes(path: string) {
+ remove(path)
+}
+
+export function touchFileContent(path: string, bytes?: number) {
+ touch(path, bytes)
+}
+
+export function getFileContentBytesTotal() {
+ return total
+}
+
+export function getFileContentEntryCount() {
+ return lru.size
+}
+
+export function hasFileContent(path: string) {
+ return lru.has(path)
+}
diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts
new file mode 100644
index 000000000..dba9ae06d
--- /dev/null
+++ b/packages/app/src/context/file/path.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, test } from "bun:test"
+import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path"
+
+describe("file path helpers", () => {
+ test("normalizes file inputs against workspace root", () => {
+ const path = createPathHelpers(() => "/repo")
+ expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts")
+ expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts")
+ expect(path.normalize("./src/app.ts")).toBe("src/app.ts")
+ expect(path.normalizeDir("src/components///")).toBe("src/components")
+ expect(path.tab("src/app.ts")).toBe("file://src/app.ts")
+ expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts")
+ expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
+ })
+
+ test("keeps query/hash stripping behavior stable", () => {
+ expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
+ expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
+ expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts")
+ })
+
+ test("unquotes git escaped octal path strings", () => {
+ expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt")
+ expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname")
+ expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts")
+ })
+})
diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts
new file mode 100644
index 000000000..ced30d0fd
--- /dev/null
+++ b/packages/app/src/context/file/path.ts
@@ -0,0 +1,119 @@
+export function stripFileProtocol(input: string) {
+ if (!input.startsWith("file://")) return input
+ return input.slice("file://".length)
+}
+
+export function stripQueryAndHash(input: string) {
+ const hashIndex = input.indexOf("#")
+ const queryIndex = input.indexOf("?")
+
+ if (hashIndex !== -1 && queryIndex !== -1) {
+ return input.slice(0, Math.min(hashIndex, queryIndex))
+ }
+
+ if (hashIndex !== -1) return input.slice(0, hashIndex)
+ if (queryIndex !== -1) return input.slice(0, queryIndex)
+ return input
+}
+
+export function unquoteGitPath(input: string) {
+ if (!input.startsWith('"')) return input
+ if (!input.endsWith('"')) return input
+ const body = input.slice(1, -1)
+ const bytes: number[] = []
+
+ for (let i = 0; i < body.length; i++) {
+ const char = body[i]!
+ if (char !== "\\") {
+ bytes.push(char.charCodeAt(0))
+ continue
+ }
+
+ const next = body[i + 1]
+ if (!next) {
+ bytes.push("\\".charCodeAt(0))
+ continue
+ }
+
+ if (next >= "0" && next <= "7") {
+ const chunk = body.slice(i + 1, i + 4)
+ const match = chunk.match(/^[0-7]{1,3}/)
+ if (!match) {
+ bytes.push(next.charCodeAt(0))
+ i++
+ continue
+ }
+ bytes.push(parseInt(match[0], 8))
+ i += match[0].length
+ continue
+ }
+
+ const escaped =
+ next === "n"
+ ? "\n"
+ : next === "r"
+ ? "\r"
+ : next === "t"
+ ? "\t"
+ : next === "b"
+ ? "\b"
+ : next === "f"
+ ? "\f"
+ : next === "v"
+ ? "\v"
+ : next === "\\" || next === '"'
+ ? next
+ : undefined
+
+ bytes.push((escaped ?? next).charCodeAt(0))
+ i++
+ }
+
+ return new TextDecoder().decode(new Uint8Array(bytes))
+}
+
+export function createPathHelpers(scope: () => string) {
+ const normalize = (input: string) => {
+ const root = scope()
+ const prefix = root.endsWith("/") ? root : root + "/"
+
+ let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
+
+ if (path.startsWith(prefix)) {
+ path = path.slice(prefix.length)
+ }
+
+ if (path.startsWith(root)) {
+ path = path.slice(root.length)
+ }
+
+ if (path.startsWith("./")) {
+ path = path.slice(2)
+ }
+
+ if (path.startsWith("/")) {
+ path = path.slice(1)
+ }
+
+ return path
+ }
+
+ const tab = (input: string) => {
+ const path = normalize(input)
+ return `file://${path}`
+ }
+
+ const pathFromTab = (tabValue: string) => {
+ if (!tabValue.startsWith("file://")) return
+ return normalize(tabValue)
+ }
+
+ const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
+
+ return {
+ normalize,
+ tab,
+ pathFromTab,
+ normalizeDir,
+ }
+}
diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts
new file mode 100644
index 000000000..a86051d28
--- /dev/null
+++ b/packages/app/src/context/file/tree-store.ts
@@ -0,0 +1,170 @@
+import { createStore, produce, reconcile } from "solid-js/store"
+import type { FileNode } from "@opencode-ai/sdk/v2"
+
+type DirectoryState = {
+ expanded: boolean
+ loaded?: boolean
+ loading?: boolean
+ error?: string
+ children?: string[]
+}
+
+type TreeStoreOptions = {
+ scope: () => string
+ normalizeDir: (input: string) => string
+ list: (input: string) => Promise<FileNode[]>
+ onError: (message: string) => void
+}
+
+export function createFileTreeStore(options: TreeStoreOptions) {
+ const [tree, setTree] = createStore<{
+ node: Record<string, FileNode>
+ dir: Record<string, DirectoryState>
+ }>({
+ node: {},
+ dir: { "": { expanded: true } },
+ })
+
+ const inflight = new Map<string, Promise<void>>()
+
+ const reset = () => {
+ inflight.clear()
+ setTree("node", reconcile({}))
+ setTree("dir", reconcile({}))
+ setTree("dir", "", { expanded: true })
+ }
+
+ const ensureDir = (path: string) => {
+ if (tree.dir[path]) return
+ setTree("dir", path, { expanded: false })
+ }
+
+ const listDir = (input: string, opts?: { force?: boolean }) => {
+ const dir = options.normalizeDir(input)
+ ensureDir(dir)
+
+ const current = tree.dir[dir]
+ if (!opts?.force && current?.loaded) return Promise.resolve()
+
+ const pending = inflight.get(dir)
+ if (pending) return pending
+
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loading = true
+ draft.error = undefined
+ }),
+ )
+
+ const directory = options.scope()
+
+ const promise = options
+ .list(dir)
+ .then((nodes) => {
+ if (options.scope() !== directory) return
+ const prevChildren = tree.dir[dir]?.children ?? []
+ const nextChildren = nodes.map((node) => node.path)
+ const nextSet = new Set(nextChildren)
+
+ setTree(
+ "node",
+ produce((draft) => {
+ const removedDirs: string[] = []
+
+ for (const child of prevChildren) {
+ if (nextSet.has(child)) continue
+ const existing = draft[child]
+ if (existing?.type === "directory") removedDirs.push(child)
+ delete draft[child]
+ }
+
+ if (removedDirs.length > 0) {
+ const keys = Object.keys(draft)
+ for (const key of keys) {
+ for (const removed of removedDirs) {
+ if (!key.startsWith(removed + "/")) continue
+ delete draft[key]
+ break
+ }
+ }
+ }
+
+ for (const node of nodes) {
+ draft[node.path] = node
+ }
+ }),
+ )
+
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loaded = true
+ draft.loading = false
+ draft.children = nextChildren
+ }),
+ )
+ })
+ .catch((e) => {
+ if (options.scope() !== directory) return
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loading = false
+ draft.error = e.message
+ }),
+ )
+ options.onError(e.message)
+ })
+ .finally(() => {
+ inflight.delete(dir)
+ })
+
+ inflight.set(dir, promise)
+ return promise
+ }
+
+ const expandDir = (input: string) => {
+ const dir = options.normalizeDir(input)
+ ensureDir(dir)
+ setTree("dir", dir, "expanded", true)
+ void listDir(dir)
+ }
+
+ const collapseDir = (input: string) => {
+ const dir = options.normalizeDir(input)
+ ensureDir(dir)
+ setTree("dir", dir, "expanded", false)
+ }
+
+ const dirState = (input: string) => {
+ const dir = options.normalizeDir(input)
+ return tree.dir[dir]
+ }
+
+ const children = (input: string) => {
+ const dir = options.normalizeDir(input)
+ const ids = tree.dir[dir]?.children
+ if (!ids) return []
+ const out: FileNode[] = []
+ for (const id of ids) {
+ const node = tree.node[id]
+ if (node) out.push(node)
+ }
+ return out
+ }
+
+ return {
+ listDir,
+ expandDir,
+ collapseDir,
+ dirState,
+ children,
+ node: (path: string) => tree.node[path],
+ isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded),
+ reset,
+ }
+}
diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts
new file mode 100644
index 000000000..7ce8a37c2
--- /dev/null
+++ b/packages/app/src/context/file/types.ts
@@ -0,0 +1,41 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+export type FileSelection = {
+ startLine: number
+ startChar: number
+ endLine: number
+ endChar: number
+}
+
+export type SelectedLineRange = {
+ start: number
+ end: number
+ side?: "additions" | "deletions"
+ endSide?: "additions" | "deletions"
+}
+
+export type FileViewState = {
+ scrollTop?: number
+ scrollLeft?: number
+ selectedLines?: SelectedLineRange | null
+}
+
+export type FileState = {
+ path: string
+ name: string
+ loaded?: boolean
+ loading?: boolean
+ error?: string
+ content?: FileContent
+}
+
+export function selectionFromLines(range: SelectedLineRange): FileSelection {
+ const startLine = Math.min(range.start, range.end)
+ const endLine = Math.max(range.start, range.end)
+ return {
+ startLine,
+ endLine,
+ startChar: 0,
+ endChar: 0,
+ }
+}
diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts
new file mode 100644
index 000000000..2614b2fb5
--- /dev/null
+++ b/packages/app/src/context/file/view-cache.ts
@@ -0,0 +1,136 @@
+import { createEffect, createRoot } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
+import type { FileViewState, SelectedLineRange } from "./types"
+
+const WORKSPACE_KEY = "__workspace__"
+const MAX_FILE_VIEW_SESSIONS = 20
+const MAX_VIEW_FILES = 500
+
+function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
+ if (range.start <= range.end) return range
+
+ const startSide = range.side
+ const endSide = range.endSide ?? startSide
+
+ return {
+ ...range,
+ start: range.end,
+ end: range.start,
+ side: endSide,
+ endSide: startSide !== endSide ? startSide : undefined,
+ }
+}
+
+function createViewSession(dir: string, id: string | undefined) {
+ const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
+
+ const [view, setView, _, ready] = persisted(
+ Persist.scoped(dir, id, "file-view", [legacyViewKey]),
+ createStore<{
+ file: Record<string, FileViewState>
+ }>({
+ file: {},
+ }),
+ )
+
+ const meta = { pruned: false }
+
+ const pruneView = (keep?: string) => {
+ const keys = Object.keys(view.file)
+ if (keys.length <= MAX_VIEW_FILES) return
+
+ const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
+ if (drop.length === 0) return
+
+ setView(
+ produce((draft) => {
+ for (const key of drop) {
+ delete draft.file[key]
+ }
+ }),
+ )
+ }
+
+ createEffect(() => {
+ if (!ready()) return
+ if (meta.pruned) return
+ meta.pruned = true
+ pruneView()
+ })
+
+ const scrollTop = (path: string) => view.file[path]?.scrollTop
+ const scrollLeft = (path: string) => view.file[path]?.scrollLeft
+ const selectedLines = (path: string) => view.file[path]?.selectedLines
+
+ const setScrollTop = (path: string, top: number) => {
+ setView("file", path, (current) => {
+ if (current?.scrollTop === top) return current
+ return {
+ ...(current ?? {}),
+ scrollTop: top,
+ }
+ })
+ pruneView(path)
+ }
+
+ const setScrollLeft = (path: string, left: number) => {
+ setView("file", path, (current) => {
+ if (current?.scrollLeft === left) return current
+ return {
+ ...(current ?? {}),
+ scrollLeft: left,
+ }
+ })
+ pruneView(path)
+ }
+
+ const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
+ const next = range ? normalizeSelectedLines(range) : null
+ setView("file", path, (current) => {
+ if (current?.selectedLines === next) return current
+ return {
+ ...(current ?? {}),
+ selectedLines: next,
+ }
+ })
+ pruneView(path)
+ }
+
+ return {
+ ready,
+ scrollTop,
+ scrollLeft,
+ selectedLines,
+ setScrollTop,
+ setScrollLeft,
+ setSelectedLines,
+ }
+}
+
+export function createFileViewCache() {
+ const cache = createScopedCache(
+ (key) => {
+ const split = key.lastIndexOf("\n")
+ const dir = split >= 0 ? key.slice(0, split) : key
+ const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
+ return createRoot((dispose) => ({
+ value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
+ dispose,
+ }))
+ },
+ {
+ maxEntries: MAX_FILE_VIEW_SESSIONS,
+ dispose: (entry) => entry.dispose(),
+ },
+ )
+
+ return {
+ load: (dir: string, id: string | undefined) => {
+ const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+ return cache.get(key).value
+ },
+ clear: () => cache.clear(),
+ }
+}
diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts
new file mode 100644
index 000000000..653e0aa75
--- /dev/null
+++ b/packages/app/src/context/file/watcher.test.ts
@@ -0,0 +1,118 @@
+import { describe, expect, test } from "bun:test"
+import { invalidateFromWatcher } from "./watcher"
+
+describe("file watcher invalidation", () => {
+ test("reloads open files and refreshes loaded parent on add", () => {
+ const loads: string[] = []
+ const refresh: string[] = []
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: "src/new.ts",
+ event: "add",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: (path) => path === "src/new.ts",
+ loadFile: (path) => loads.push(path),
+ node: () => undefined,
+ isDirLoaded: (path) => path === "src",
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ expect(loads).toEqual(["src/new.ts"])
+ expect(refresh).toEqual(["src"])
+ })
+
+ test("refreshes only changed loaded directory nodes", () => {
+ const refresh: string[] = []
+
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: "src",
+ event: "change",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => false,
+ loadFile: () => {},
+ node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
+ isDirLoaded: (path) => path === "src",
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: "src/file.ts",
+ event: "change",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => false,
+ loadFile: () => {},
+ node: () => ({
+ path: "src/file.ts",
+ type: "file",
+ name: "file.ts",
+ absolute: "/repo/src/file.ts",
+ ignored: false,
+ }),
+ isDirLoaded: () => true,
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ expect(refresh).toEqual(["src"])
+ })
+
+ test("ignores invalid or git watcher updates", () => {
+ const refresh: string[] = []
+
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: ".git/index.lock",
+ event: "change",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => true,
+ loadFile: () => {
+ throw new Error("should not load")
+ },
+ node: () => undefined,
+ isDirLoaded: () => true,
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ invalidateFromWatcher(
+ {
+ type: "project.updated",
+ properties: {},
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => false,
+ loadFile: () => {},
+ node: () => undefined,
+ isDirLoaded: () => true,
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ expect(refresh).toEqual([])
+ })
+})
diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts
new file mode 100644
index 000000000..a3a98eae4
--- /dev/null
+++ b/packages/app/src/context/file/watcher.ts
@@ -0,0 +1,52 @@
+import type { FileNode } from "@opencode-ai/sdk/v2"
+
+type WatcherEvent = {
+ type: string
+ properties: unknown
+}
+
+type WatcherOps = {
+ normalize: (input: string) => string
+ hasFile: (path: string) => boolean
+ loadFile: (path: string) => void
+ node: (path: string) => FileNode | undefined
+ isDirLoaded: (path: string) => boolean
+ refreshDir: (path: string) => void
+}
+
+export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
+ if (event.type !== "file.watcher.updated") return
+ const props =
+ typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined
+ const rawPath = typeof props?.file === "string" ? props.file : undefined
+ const kind = typeof props?.event === "string" ? props.event : undefined
+ if (!rawPath) return
+ if (!kind) return
+
+ const path = ops.normalize(rawPath)
+ if (!path) return
+ if (path.startsWith(".git/")) return
+
+ if (ops.hasFile(path)) {
+ ops.loadFile(path)
+ }
+
+ if (kind === "change") {
+ const dir = (() => {
+ if (path === "") return ""
+ const node = ops.node(path)
+ if (node?.type !== "directory") return
+ return path
+ })()
+ if (dir === undefined) return
+ if (!ops.isDirLoaded(dir)) return
+ ops.refreshDir(dir)
+ return
+ }
+ if (kind !== "add" && kind !== "unlink") return
+
+ const parent = path.split("/").slice(0, -1).join("/")
+ if (!ops.isDirLoaded(parent)) return
+
+ ops.refreshDir(parent)
+}
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 0d6b5dfff..e2bf44980 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -1,41 +1,22 @@
import {
- type Message,
- type Agent,
- type Session,
- type Part,
type Config,
type Path,
type Project,
- type FileDiff,
- type Todo,
- type SessionStatus,
- type ProviderListResponse,
type ProviderAuthResponse,
- type Command,
- type McpStatus,
- type LspStatus,
- type VcsInfo,
- type PermissionRequest,
- type QuestionRequest,
+ type ProviderListResponse,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
-import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
-import { Binary } from "@opencode-ai/util/binary"
-import { retry } from "@opencode-ai/util/retry"
+import { createStore, produce, reconcile } from "solid-js/store"
import { useGlobalSDK } from "./global-sdk"
import type { InitError } from "../pages/error"
import {
- batch,
createContext,
- createRoot,
createEffect,
untrack,
getOwner,
- runWithOwner,
useContext,
onCleanup,
onMount,
- type Accessor,
type ParentProps,
Switch,
Match,
@@ -45,181 +26,25 @@ import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
-
-type ProjectMeta = {
- name?: string
- icon?: {
- override?: string
- color?: string
- }
- commands?: {
- start?: string
- }
-}
-
-type State = {
- status: "loading" | "partial" | "complete"
- agent: Agent[]
- command: Command[]
- project: string
- projectMeta: ProjectMeta | undefined
- icon: string | undefined
+import { createRefreshQueue } from "./global-sync/queue"
+import { createChildStoreManager } from "./global-sync/child-store"
+import { trimSessions } from "./global-sync/session-trim"
+import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
+import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
+import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
+import { sanitizeProject } from "./global-sync/utils"
+import type { ProjectMeta } from "./global-sync/types"
+import { SESSION_RECENT_LIMIT } from "./global-sync/types"
+
+type GlobalStore = {
+ ready: boolean
+ error?: InitError
+ path: Path
+ project: Project[]
provider: ProviderListResponse
+ provider_auth: ProviderAuthResponse
config: Config
- path: Path
- session: Session[]
- sessionTotal: number
- session_status: {
- [sessionID: string]: SessionStatus
- }
- session_diff: {
- [sessionID: string]: FileDiff[]
- }
- todo: {
- [sessionID: string]: Todo[]
- }
- permission: {
- [sessionID: string]: PermissionRequest[]
- }
- question: {
- [sessionID: string]: QuestionRequest[]
- }
- mcp: {
- [name: string]: McpStatus
- }
- lsp: LspStatus[]
- vcs: VcsInfo | undefined
- limit: number
- message: {
- [sessionID: string]: Message[]
- }
- part: {
- [messageID: string]: Part[]
- }
-}
-
-type VcsCache = {
- store: Store<{ value: VcsInfo | undefined }>
- setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
- ready: Accessor<boolean>
-}
-
-type MetaCache = {
- store: Store<{ value: ProjectMeta | undefined }>
- setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
- ready: Accessor<boolean>
-}
-
-type IconCache = {
- store: Store<{ value: string | undefined }>
- setStore: SetStoreFunction<{ value: string | undefined }>
- ready: Accessor<boolean>
-}
-
-type ChildOptions = {
- bootstrap?: boolean
-}
-
-const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
-
-function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
- return {
- ...input,
- all: input.all.map((provider) => ({
- ...provider,
- models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
- })),
- }
-}
-
-const MAX_DIR_STORES = 30
-const DIR_IDLE_TTL_MS = 20 * 60 * 1000
-
-type DirState = {
- lastAccessAt: number
-}
-
-type EvictPlan = {
- stores: string[]
- state: Map<string, DirState>
- pins: Set<string>
- max: number
- ttl: number
- now: number
-}
-
-export function pickDirectoriesToEvict(input: EvictPlan) {
- const overflow = Math.max(0, input.stores.length - input.max)
- let pendingOverflow = overflow
- const sorted = input.stores
- .filter((dir) => !input.pins.has(dir))
- .slice()
- .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
-
- const output: string[] = []
- for (const dir of sorted) {
- const last = input.state.get(dir)?.lastAccessAt ?? 0
- const idle = input.now - last >= input.ttl
- if (!idle && pendingOverflow <= 0) continue
- output.push(dir)
- if (pendingOverflow > 0) pendingOverflow -= 1
- }
- return output
-}
-
-type RootLoadArgs = {
- directory: string
- limit: number
- list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
- onFallback: () => void
-}
-
-type RootLoadResult = {
- data?: Session[]
- limit: number
- limited: boolean
-}
-
-export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
- try {
- const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
- return {
- data: result.data,
- limit: input.limit,
- limited: true,
- } satisfies RootLoadResult
- } catch {
- input.onFallback()
- const result = await input.list({ directory: input.directory, roots: true })
- return {
- data: result.data,
- limit: input.limit,
- limited: false,
- } satisfies RootLoadResult
- }
-}
-
-export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
- if (!input.limited) return input.count
- if (input.count < input.limit) return input.count
- return input.count + 1
-}
-
-type DisposeCheck = {
- directory: string
- hasStore: boolean
- pinned: boolean
- booting: boolean
- loadingSessions: boolean
-}
-
-export function canDisposeDirectory(input: DisposeCheck) {
- if (!input.directory) return false
- if (!input.hasStore) return false
- if (input.pinned) return false
- if (input.booting) return false
- if (input.loadingSessions) return false
- return true
+ reload: undefined | "pending" | "complete"
}
function createGlobalSync() {
@@ -228,21 +53,33 @@ function createGlobalSync() {
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
- const vcsCache = new Map<string, VcsCache>()
- const metaCache = new Map<string, MetaCache>()
- const iconCache = new Map<string, IconCache>()
- const lifecycle = new Map<string, DirState>()
- const pins = new Map<string, number>()
- const ownerPins = new WeakMap<object, Set<string>>()
- const disposers = new Map<string, () => void>()
+
const stats = {
evictions: 0,
loadSessionsFallback: 0,
}
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
+ const booting = new Map<string, Promise<void>>()
+ const sessionLoads = new Map<string, Promise<void>>()
+ const sessionMeta = new Map<string, { limit: number }>()
+
+ const [projectCache, setProjectCache, , projectCacheReady] = persisted(
+ Persist.global("globalSync.project", ["globalSync.project.v1"]),
+ createStore({ value: [] as Project[] }),
+ )
+
+ const [globalStore, setGlobalStore] = createStore<GlobalStore>({
+ ready: false,
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ project: projectCache.value,
+ provider: { all: [], connected: [], default: {} },
+ provider_auth: {},
+ config: {},
+ reload: undefined,
+ })
- const updateStats = () => {
+ const updateStats = (activeDirectoryStores: number) => {
if (!import.meta.env.DEV) return
;(
globalThis as {
@@ -253,115 +90,42 @@ function createGlobalSync() {
}
}
).__OPENCODE_GLOBAL_SYNC_STATS = {
- activeDirectoryStores: Object.keys(children).length,
+ activeDirectoryStores,
evictions: stats.evictions,
loadSessionsFullFetchFallback: stats.loadSessionsFallback,
}
}
- const mark = (directory: string) => {
- if (!directory) return
- lifecycle.set(directory, { lastAccessAt: Date.now() })
- runEviction()
- }
-
- const pin = (directory: string) => {
- if (!directory) return
- pins.set(directory, (pins.get(directory) ?? 0) + 1)
- mark(directory)
- }
-
- const unpin = (directory: string) => {
- if (!directory) return
- const next = (pins.get(directory) ?? 0) - 1
- if (next > 0) {
- pins.set(directory, next)
- return
- }
- pins.delete(directory)
- runEviction()
- }
-
- const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
-
- const pinForOwner = (directory: string) => {
- const current = getOwner()
- if (!current) return
- if (current === owner) return
- const key = current as object
- const set = ownerPins.get(key)
- if (set?.has(directory)) return
- if (set) set.add(directory)
- else ownerPins.set(key, new Set([directory]))
- pin(directory)
- onCleanup(() => {
- const set = ownerPins.get(key)
- if (set) {
- set.delete(directory)
- if (set.size === 0) ownerPins.delete(key)
- }
- unpin(directory)
- })
- }
-
- function disposeDirectory(directory: string) {
- if (
- !canDisposeDirectory({
- directory,
- hasStore: !!children[directory],
- pinned: pinned(directory),
- booting: booting.has(directory),
- loadingSessions: sessionLoads.has(directory),
- })
- ) {
- return false
- }
-
- queued.delete(directory)
- sessionMeta.delete(directory)
- sdkCache.delete(directory)
- vcsCache.delete(directory)
- metaCache.delete(directory)
- iconCache.delete(directory)
- lifecycle.delete(directory)
-
- const dispose = disposers.get(directory)
- if (dispose) {
- dispose()
- disposers.delete(directory)
- }
-
- delete children[directory]
- updateStats()
- return true
- }
+ const paused = () => untrack(() => globalStore.reload) !== undefined
- function runEviction() {
- const stores = Object.keys(children)
- if (stores.length === 0) return
- const list = pickDirectoriesToEvict({
- stores,
- state: lifecycle,
- pins: new Set(stores.filter(pinned)),
- max: MAX_DIR_STORES,
- ttl: DIR_IDLE_TTL_MS,
- now: Date.now(),
- })
+ const queue = createRefreshQueue({
+ paused,
+ bootstrap,
+ bootstrapInstance,
+ })
- if (list.length === 0) return
- let changed = false
- for (const directory of list) {
- if (!disposeDirectory(directory)) continue
+ const children = createChildStoreManager({
+ owner,
+ markStats: updateStats,
+ incrementEvictions: () => {
stats.evictions += 1
- changed = true
- }
- if (changed) updateStats()
- }
+ updateStats(Object.keys(children.children).length)
+ },
+ isBooting: (directory) => booting.has(directory),
+ isLoadingSessions: (directory) => sessionLoads.has(directory),
+ onBootstrap: (directory) => {
+ void bootstrapInstance(directory)
+ },
+ onDispose: (directory) => {
+ queue.clear(directory)
+ sessionMeta.delete(directory)
+ sdkCache.delete(directory)
+ },
+ })
const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory)
if (cached) return cached
-
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
@@ -372,109 +136,6 @@ function createGlobalSync() {
return sdk
}
- const [projectCache, setProjectCache, , projectCacheReady] = persisted(
- Persist.global("globalSync.project", ["globalSync.project.v1"]),
- createStore({ value: [] as Project[] }),
- )
-
- const sanitizeProject = (project: Project) => {
- if (!project.icon?.url && !project.icon?.override) return project
- return {
- ...project,
- icon: {
- ...project.icon,
- url: undefined,
- override: undefined,
- },
- }
- }
- const [globalStore, setGlobalStore] = createStore<{
- ready: boolean
- error?: InitError
- path: Path
- project: Project[]
- provider: ProviderListResponse
- provider_auth: ProviderAuthResponse
- config: Config
- reload: undefined | "pending" | "complete"
- }>({
- ready: false,
- path: { state: "", config: "", worktree: "", directory: "", home: "" },
- project: projectCache.value,
- provider: { all: [], connected: [], default: {} },
- provider_auth: {},
- config: {},
- reload: undefined,
- })
-
- const queued = new Set<string>()
- let root = false
- let running = false
- let timer: ReturnType<typeof setTimeout> | undefined
-
- const paused = () => untrack(() => globalStore.reload) !== undefined
-
- const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
-
- const take = (count: number) => {
- if (queued.size === 0) return [] as string[]
- const items: string[] = []
- for (const item of queued) {
- queued.delete(item)
- items.push(item)
- if (items.length >= count) break
- }
- return items
- }
-
- const schedule = () => {
- if (timer) return
- timer = setTimeout(() => {
- timer = undefined
- void drain()
- }, 0)
- }
-
- const push = (directory: string) => {
- if (!directory) return
- queued.add(directory)
- if (paused()) return
- schedule()
- }
-
- const refresh = () => {
- root = true
- if (paused()) return
- schedule()
- }
-
- async function drain() {
- if (running) return
- running = true
- try {
- while (true) {
- if (paused()) return
-
- if (root) {
- root = false
- await bootstrap()
- await tick()
- continue
- }
-
- const dirs = take(2)
- if (dirs.length === 0) return
-
- await Promise.all(dirs.map((dir) => bootstrapInstance(dir)))
- await tick()
- }
- } finally {
- running = false
- if (paused()) return
- if (root || queued.size) schedule()
- }
- }
-
createEffect(() => {
if (!projectCacheReady()) return
if (globalStore.project.length !== 0) return
@@ -496,212 +157,43 @@ function createGlobalSync() {
createEffect(() => {
if (globalStore.reload !== "complete") return
setGlobalStore("reload", undefined)
- refresh()
+ queue.refresh()
})
- const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
- const booting = new Map<string, Promise<void>>()
- const sessionLoads = new Map<string, Promise<void>>()
- const sessionMeta = new Map<string, { limit: number }>()
-
- const sessionRecentWindow = 4 * 60 * 60 * 1000
- const sessionRecentLimit = 50
-
- function sessionUpdatedAt(session: Session) {
- return session.time.updated ?? session.time.created
- }
-
- function compareSessionRecent(a: Session, b: Session) {
- const aUpdated = sessionUpdatedAt(a)
- const bUpdated = sessionUpdatedAt(b)
- if (aUpdated !== bUpdated) return bUpdated - aUpdated
- return cmp(a.id, b.id)
- }
-
- function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
- if (limit <= 0) return [] as Session[]
- const selected: Session[] = []
- const seen = new Set<string>()
- for (const session of sessions) {
- if (!session?.id) continue
- if (seen.has(session.id)) continue
- seen.add(session.id)
-
- if (sessionUpdatedAt(session) <= cutoff) continue
-
- const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
- if (index === -1) selected.push(session)
- if (index !== -1) selected.splice(index, 0, session)
- if (selected.length > limit) selected.pop()
- }
- return selected
- }
-
- function trimSessions(input: Session[], options: { limit: number; permission: Record<string, PermissionRequest[]> }) {
- const limit = Math.max(0, options.limit)
- const cutoff = Date.now() - sessionRecentWindow
- const all = input
- .filter((s) => !!s?.id)
- .filter((s) => !s.time?.archived)
- .sort((a, b) => cmp(a.id, b.id))
-
- const roots = all.filter((s) => !s.parentID)
- const children = all.filter((s) => !!s.parentID)
-
- const base = roots.slice(0, limit)
- const recent = takeRecentSessions(roots.slice(limit), sessionRecentLimit, cutoff)
- const keepRoots = [...base, ...recent]
-
- const keepRootIds = new Set(keepRoots.map((s) => s.id))
- const keepChildren = children.filter((s) => {
- if (s.parentID && keepRootIds.has(s.parentID)) return true
- const perms = options.permission[s.id] ?? []
- if (perms.length > 0) return true
- return sessionUpdatedAt(s) > cutoff
- })
-
- return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
- }
-
- function ensureChild(directory: string) {
- if (!directory) console.error("No directory provided")
- if (!children[directory]) {
- const vcs = runWithOwner(owner, () =>
- persisted(
- Persist.workspace(directory, "vcs", ["vcs.v1"]),
- createStore({ value: undefined as VcsInfo | undefined }),
- ),
- )
- if (!vcs) throw new Error("Failed to create persisted cache")
- const vcsStore = vcs[0]
- const vcsReady = vcs[3]
- vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
-
- const meta = runWithOwner(owner, () =>
- persisted(
- Persist.workspace(directory, "project", ["project.v1"]),
- createStore({ value: undefined as ProjectMeta | undefined }),
- ),
- )
- if (!meta) throw new Error("Failed to create persisted project metadata")
- metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
-
- const icon = runWithOwner(owner, () =>
- persisted(
- Persist.workspace(directory, "icon", ["icon.v1"]),
- createStore({ value: undefined as string | undefined }),
- ),
- )
- if (!icon) throw new Error("Failed to create persisted project icon")
- iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
-
- const init = () =>
- createRoot((dispose) => {
- const child = createStore<State>({
- project: "",
- projectMeta: meta[0].value,
- icon: icon[0].value,
- provider: { all: [], connected: [], default: {} },
- config: {},
- path: { state: "", config: "", worktree: "", directory: "", home: "" },
- status: "loading" as const,
- agent: [],
- command: [],
- session: [],
- sessionTotal: 0,
- session_status: {},
- session_diff: {},
- todo: {},
- permission: {},
- question: {},
- mcp: {},
- lsp: [],
- vcs: vcsStore.value,
- limit: 5,
- message: {},
- part: {},
- })
-
- children[directory] = child
- disposers.set(directory, dispose)
-
- createEffect(() => {
- if (!vcsReady()) return
- const cached = vcsStore.value
- if (!cached?.branch) return
- child[1]("vcs", (value) => value ?? cached)
- })
-
- createEffect(() => {
- child[1]("projectMeta", meta[0].value)
- })
-
- createEffect(() => {
- child[1]("icon", icon[0].value)
- })
- })
-
- runWithOwner(owner, init)
- updateStats()
- }
- mark(directory)
- const childStore = children[directory]
- if (!childStore) throw new Error("Failed to create store")
- return childStore
- }
-
- function child(directory: string, options: ChildOptions = {}) {
- const childStore = ensureChild(directory)
- pinForOwner(directory)
- const shouldBootstrap = options.bootstrap ?? true
- if (shouldBootstrap && childStore[0].status === "loading") {
- void bootstrapInstance(directory)
- }
- return childStore
- }
-
async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory)
if (pending) return pending
- pin(directory)
- const [store, setStore] = child(directory, { bootstrap: false })
+ children.pin(directory)
+ const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
}
- unpin(directory)
+ children.unpin(directory)
return
}
- const limit = Math.max(store.limit + sessionRecentLimit, sessionRecentLimit)
+ const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = loadRootSessionsWithFallback({
directory,
limit,
list: (query) => globalSDK.client.session.list(query),
onFallback: () => {
stats.loadSessionsFallback += 1
- updateStats()
+ updateStats(Object.keys(children.children).length)
},
})
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
- .sort((a, b) => cmp(a.id, b.id))
-
- // Read the current limit at resolve-time so callers that bump the limit while
- // a request is in-flight still get the expanded result.
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit
-
- const children = store.session.filter((s) => !!s.parentID)
- const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission })
-
- // Store root session total for "load more" pagination.
- // For limited root queries, preserve has-more behavior by treating
- // full-limit responses as "potentially more".
+ const childSessions = store.session.filter((s) => !!s.parentID)
+ const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
setStore(
"sessionTotal",
estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
@@ -718,7 +210,7 @@ function createGlobalSync() {
sessionLoads.set(directory, promise)
promise.finally(() => {
sessionLoads.delete(directory)
- unpin(directory)
+ children.unpin(directory)
})
return promise
}
@@ -728,571 +220,99 @@ function createGlobalSync() {
const pending = booting.get(directory)
if (pending) return pending
- pin(directory)
+ children.pin(directory)
const promise = (async () => {
- const [store, setStore] = ensureChild(directory)
- const cache = vcsCache.get(directory)
+ const child = children.ensureChild(directory)
+ const cache = children.vcsCache.get(directory)
if (!cache) return
- const meta = metaCache.get(directory)
- if (!meta) return
const sdk = sdkFor(directory)
-
- setStore("status", "loading")
-
- // projectMeta is synced from persisted storage in ensureChild.
- // vcs is seeded from persisted storage in ensureChild.
-
- const blockingRequests = {
- project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
- provider: () =>
- sdk.provider.list().then((x) => {
- setStore("provider", normalizeProviderList(x.data!))
- }),
- agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
- config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
- }
-
- try {
- await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
- } catch (err) {
- console.error("Failed to bootstrap instance", err)
- const project = getFilename(directory)
- const message = err instanceof Error ? err.message : String(err)
- showToast({ title: `Failed to reload ${project}`, description: message })
- setStore("status", "partial")
- return
- }
-
- if (store.status !== "complete") setStore("status", "partial")
-
- Promise.all([
- sdk.path.get().then((x) => setStore("path", x.data!)),
- sdk.command.list().then((x) => setStore("command", x.data ?? [])),
- sdk.session.status().then((x) => setStore("session_status", x.data!)),
- loadSessions(directory),
- sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
- sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
- sdk.vcs.get().then((x) => {
- const next = x.data ?? store.vcs
- setStore("vcs", next)
- if (next?.branch) cache.setStore("value", next)
- }),
- sdk.permission.list().then((x) => {
- const grouped: Record<string, PermissionRequest[]> = {}
- for (const perm of x.data ?? []) {
- if (!perm?.id || !perm.sessionID) continue
- const existing = grouped[perm.sessionID]
- if (existing) {
- existing.push(perm)
- continue
- }
- grouped[perm.sessionID] = [perm]
- }
-
- batch(() => {
- for (const sessionID of Object.keys(store.permission)) {
- if (grouped[sessionID]) continue
- setStore("permission", sessionID, [])
- }
- for (const [sessionID, permissions] of Object.entries(grouped)) {
- setStore(
- "permission",
- sessionID,
- reconcile(
- permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
- { key: "id" },
- ),
- )
- }
- })
- }),
- sdk.question.list().then((x) => {
- const grouped: Record<string, QuestionRequest[]> = {}
- for (const question of x.data ?? []) {
- if (!question?.id || !question.sessionID) continue
- const existing = grouped[question.sessionID]
- if (existing) {
- existing.push(question)
- continue
- }
- grouped[question.sessionID] = [question]
- }
-
- batch(() => {
- for (const sessionID of Object.keys(store.question)) {
- if (grouped[sessionID]) continue
- setStore("question", sessionID, [])
- }
- for (const [sessionID, questions] of Object.entries(grouped)) {
- setStore(
- "question",
- sessionID,
- reconcile(
- questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
- { key: "id" },
- ),
- )
- }
- })
- }),
- ]).then(() => {
- setStore("status", "complete")
+ await bootstrapDirectory({
+ directory,
+ sdk,
+ store: child[0],
+ setStore: child[1],
+ vcsCache: cache,
+ loadSessions,
})
})()
booting.set(directory, promise)
promise.finally(() => {
booting.delete(directory)
- unpin(directory)
+ children.unpin(directory)
})
return promise
}
- function purgeMessageParts(setStore: SetStoreFunction<State>, messageID: string | undefined) {
- if (!messageID) return
- setStore(
- produce((draft) => {
- delete draft.part[messageID]
- }),
- )
- }
-
- function purgeSessionData(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string | undefined) {
- if (!sessionID) return
-
- const messages = store.message[sessionID]
- const messageIDs = (messages ?? []).map((m) => m.id).filter((id): id is string => !!id)
-
- setStore(
- produce((draft) => {
- delete draft.message[sessionID]
- delete draft.session_diff[sessionID]
- delete draft.todo[sessionID]
- delete draft.permission[sessionID]
- delete draft.question[sessionID]
- delete draft.session_status[sessionID]
-
- for (const messageID of messageIDs) {
- delete draft.part[messageID]
- }
- }),
- )
- }
-
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
if (directory === "global") {
- switch (event?.type) {
- case "global.disposed": {
- refresh()
- return
- }
- case "project.updated": {
- const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
- if (result.found) {
- setGlobalStore("project", result.index, reconcile(event.properties))
+ applyGlobalEvent({
+ event,
+ project: globalStore.project,
+ refresh: queue.refresh,
+ setGlobalProject(next) {
+ if (typeof next === "function") {
+ setGlobalStore("project", produce(next))
return
}
- setGlobalStore(
- "project",
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- }
+ setGlobalStore("project", next)
+ },
+ })
return
}
- const existing = children[directory]
+ const existing = children.children[directory]
if (!existing) return
- mark(directory)
-
+ children.mark(directory)
const [store, setStore] = existing
-
- const cleanupSessionCaches = (sessionID: string) => {
- if (!sessionID) return
-
- const hasAny =
- store.message[sessionID] !== undefined ||
- store.session_diff[sessionID] !== undefined ||
- store.todo[sessionID] !== undefined ||
- store.permission[sessionID] !== undefined ||
- store.question[sessionID] !== undefined ||
- store.session_status[sessionID] !== undefined
-
- if (!hasAny) return
-
- setStore(
- produce((draft) => {
- const messages = draft.message[sessionID]
- if (messages) {
- for (const message of messages) {
- const id = message?.id
- if (!id) continue
- delete draft.part[id]
- }
- }
-
- delete draft.message[sessionID]
- delete draft.session_diff[sessionID]
- delete draft.todo[sessionID]
- delete draft.permission[sessionID]
- delete draft.question[sessionID]
- delete draft.session_status[sessionID]
- }),
- )
- }
-
- switch (event.type) {
- case "server.instance.disposed": {
- push(directory)
- return
- }
- case "session.created": {
- const info = event.properties.info
- const result = Binary.search(store.session, info.id, (s) => s.id)
- if (result.found) {
- setStore("session", result.index, reconcile(info))
- break
- }
- const next = store.session.slice()
- next.splice(result.index, 0, info)
- const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission })
- setStore("session", reconcile(trimmed, { key: "id" }))
- if (!info.parentID) {
- setStore("sessionTotal", (value) => value + 1)
- }
- break
- }
- case "session.updated": {
- const info = event.properties.info
- const result = Binary.search(store.session, info.id, (s) => s.id)
- if (info.time.archived) {
- if (result.found) {
- setStore(
- "session",
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- }
- cleanupSessionCaches(info.id)
- if (info.parentID) break
- setStore("sessionTotal", (value) => Math.max(0, value - 1))
- break
- }
- if (result.found) {
- setStore("session", result.index, reconcile(info))
- break
- }
- const next = store.session.slice()
- next.splice(result.index, 0, info)
- const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission })
- setStore("session", reconcile(trimmed, { key: "id" }))
- break
- }
- case "session.deleted": {
- const sessionID = event.properties.info.id
- const result = Binary.search(store.session, sessionID, (s) => s.id)
- if (result.found) {
- setStore(
- "session",
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- }
- cleanupSessionCaches(sessionID)
- if (event.properties.info.parentID) break
- setStore("sessionTotal", (value) => Math.max(0, value - 1))
- break
- }
- case "session.diff":
- setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
- break
- case "todo.updated":
- setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
- break
- case "session.status": {
- setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
- break
- }
- case "message.updated": {
- const messages = store.message[event.properties.info.sessionID]
- if (!messages) {
- setStore("message", event.properties.info.sessionID, [event.properties.info])
- break
- }
- const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
- if (result.found) {
- setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
- break
- }
- setStore(
- "message",
- event.properties.info.sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties.info)
- }),
- )
- break
- }
- case "message.removed": {
- const sessionID = event.properties.sessionID
- const messageID = event.properties.messageID
-
- setStore(
- produce((draft) => {
- const messages = draft.message[sessionID]
- if (messages) {
- const result = Binary.search(messages, messageID, (m) => m.id)
- if (result.found) {
- messages.splice(result.index, 1)
- }
- }
-
- delete draft.part[messageID]
- }),
- )
- break
- }
- case "message.part.updated": {
- const part = event.properties.part
- const parts = store.part[part.messageID]
- if (!parts) {
- setStore("part", part.messageID, [part])
- break
- }
- const result = Binary.search(parts, part.id, (p) => p.id)
- if (result.found) {
- setStore("part", part.messageID, result.index, reconcile(part))
- break
- }
- setStore(
- "part",
- part.messageID,
- produce((draft) => {
- draft.splice(result.index, 0, part)
- }),
- )
- break
- }
- case "message.part.removed": {
- const messageID = event.properties.messageID
- const parts = store.part[messageID]
- if (!parts) break
- const result = Binary.search(parts, event.properties.partID, (p) => p.id)
- if (result.found) {
- setStore(
- produce((draft) => {
- const list = draft.part[messageID]
- if (!list) return
- const next = Binary.search(list, event.properties.partID, (p) => p.id)
- if (!next.found) return
- list.splice(next.index, 1)
- if (list.length === 0) delete draft.part[messageID]
- }),
- )
- }
- break
- }
- case "vcs.branch.updated": {
- const next = { branch: event.properties.branch }
- setStore("vcs", next)
- const cache = vcsCache.get(directory)
- if (cache) cache.setStore("value", next)
- break
- }
- case "permission.asked": {
- const sessionID = event.properties.sessionID
- const permissions = store.permission[sessionID]
- if (!permissions) {
- setStore("permission", sessionID, [event.properties])
- break
- }
-
- const result = Binary.search(permissions, event.properties.id, (p) => p.id)
- if (result.found) {
- setStore("permission", sessionID, result.index, reconcile(event.properties))
- break
- }
-
- setStore(
- "permission",
- sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- case "permission.replied": {
- const permissions = store.permission[event.properties.sessionID]
- if (!permissions) break
- const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
- if (!result.found) break
- setStore(
- "permission",
- event.properties.sessionID,
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- break
- }
- case "question.asked": {
- const sessionID = event.properties.sessionID
- const questions = store.question[sessionID]
- if (!questions) {
- setStore("question", sessionID, [event.properties])
- break
- }
-
- const result = Binary.search(questions, event.properties.id, (q) => q.id)
- if (result.found) {
- setStore("question", sessionID, result.index, reconcile(event.properties))
- break
- }
-
- setStore(
- "question",
- sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- case "question.replied":
- case "question.rejected": {
- const questions = store.question[event.properties.sessionID]
- if (!questions) break
- const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
- if (!result.found) break
- setStore(
- "question",
- event.properties.sessionID,
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- break
- }
- case "lsp.updated": {
+ applyDirectoryEvent({
+ event,
+ directory,
+ store,
+ setStore,
+ push: queue.push,
+ vcsCache: children.vcsCache.get(directory),
+ loadLsp: () => {
sdkFor(directory)
.lsp.status()
.then((x) => setStore("lsp", x.data ?? []))
- break
- }
- }
+ },
+ })
})
+
onCleanup(unsub)
onCleanup(() => {
- if (!timer) return
- clearTimeout(timer)
+ queue.dispose()
})
onCleanup(() => {
- for (const directory of Object.keys(children)) {
- disposeDirectory(directory)
+ for (const directory of Object.keys(children.children)) {
+ children.disposeDirectory(directory)
}
})
async function bootstrap() {
- const health = await globalSDK.client.global
- .health()
- .then((x) => x.data)
- .catch(() => undefined)
- if (!health?.healthy) {
- showToast({
- variant: "error",
- title: language.t("dialog.server.add.error"),
- description: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
- })
- setGlobalStore("ready", true)
- return
- }
-
- const tasks = [
- retry(() =>
- globalSDK.client.path.get().then((x) => {
- setGlobalStore("path", x.data!)
- }),
- ),
- retry(() =>
- globalSDK.client.global.config.get().then((x) => {
- setGlobalStore("config", x.data!)
- }),
- ),
- retry(() =>
- globalSDK.client.project.list().then(async (x) => {
- const projects = (x.data ?? [])
- .filter((p) => !!p?.id)
- .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
- .slice()
- .sort((a, b) => cmp(a.id, b.id))
- setGlobalStore("project", projects)
- }),
- ),
- retry(() =>
- globalSDK.client.provider.list().then((x) => {
- setGlobalStore("provider", normalizeProviderList(x.data!))
- }),
- ),
- retry(() =>
- globalSDK.client.provider.auth().then((x) => {
- setGlobalStore("provider_auth", x.data ?? {})
- }),
- ),
- ]
-
- const results = await Promise.allSettled(tasks)
- const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
-
- if (errors.length) {
- const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
- const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: message + more,
- })
- }
-
- setGlobalStore("ready", true)
+ await bootstrapGlobal({
+ globalSDK: globalSDK.client,
+ connectErrorTitle: language.t("dialog.server.add.error"),
+ connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
+ requestFailedTitle: language.t("common.requestFailed"),
+ setGlobalStore,
+ })
}
onMount(() => {
- bootstrap()
+ void bootstrap()
})
function projectMeta(directory: string, patch: ProjectMeta) {
- const [store, setStore] = ensureChild(directory)
- const cached = metaCache.get(directory)
- if (!cached) return
- const previous = store.projectMeta ?? {}
- const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
- const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
- const next = {
- ...previous,
- ...patch,
- icon,
- commands,
- }
- cached.setStore("value", next)
- setStore("projectMeta", next)
+ children.projectMeta(directory, patch)
}
function projectIcon(directory: string, value: string | undefined) {
- const [store, setStore] = ensureChild(directory)
- const cached = iconCache.get(directory)
- if (!cached) return
- if (store.icon === value) return
- cached.setStore("value", value)
- setStore("icon", value)
+ children.projectIcon(directory, value)
}
return {
@@ -1304,7 +324,7 @@ function createGlobalSync() {
get error() {
return globalStore.error
},
- child,
+ child: children.child,
bootstrap,
updateConfig: (config: Config) => {
setGlobalStore("reload", "pending")
@@ -1340,3 +360,6 @@ export function useGlobalSync() {
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
+
+export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
+export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
new file mode 100644
index 000000000..2137a19a8
--- /dev/null
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -0,0 +1,195 @@
+import {
+ type Config,
+ type Path,
+ type PermissionRequest,
+ type Project,
+ type ProviderAuthResponse,
+ type ProviderListResponse,
+ type QuestionRequest,
+ createOpencodeClient,
+} from "@opencode-ai/sdk/v2/client"
+import { batch } from "solid-js"
+import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import { retry } from "@opencode-ai/util/retry"
+import { getFilename } from "@opencode-ai/util/path"
+import { showToast } from "@opencode-ai/ui/toast"
+import { cmp, normalizeProviderList } from "./utils"
+import type { State, VcsCache } from "./types"
+
+type GlobalStore = {
+ ready: boolean
+ path: Path
+ project: Project[]
+ provider: ProviderListResponse
+ provider_auth: ProviderAuthResponse
+ config: Config
+ reload: undefined | "pending" | "complete"
+}
+
+export async function bootstrapGlobal(input: {
+ globalSDK: ReturnType<typeof createOpencodeClient>
+ connectErrorTitle: string
+ connectErrorDescription: string
+ requestFailedTitle: string
+ setGlobalStore: SetStoreFunction<GlobalStore>
+}) {
+ const health = await input.globalSDK.global
+ .health()
+ .then((x) => x.data)
+ .catch(() => undefined)
+ if (!health?.healthy) {
+ showToast({
+ variant: "error",
+ title: input.connectErrorTitle,
+ description: input.connectErrorDescription,
+ })
+ input.setGlobalStore("ready", true)
+ return
+ }
+
+ const tasks = [
+ retry(() =>
+ input.globalSDK.path.get().then((x) => {
+ input.setGlobalStore("path", x.data!)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.global.config.get().then((x) => {
+ input.setGlobalStore("config", x.data!)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.project.list().then((x) => {
+ const projects = (x.data ?? [])
+ .filter((p) => !!p?.id)
+ .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+ .slice()
+ .sort((a, b) => cmp(a.id, b.id))
+ input.setGlobalStore("project", projects)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.provider.list().then((x) => {
+ input.setGlobalStore("provider", normalizeProviderList(x.data!))
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.provider.auth().then((x) => {
+ input.setGlobalStore("provider_auth", x.data ?? {})
+ }),
+ ),
+ ]
+
+ const results = await Promise.allSettled(tasks)
+ const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
+ if (errors.length) {
+ const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
+ const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
+ showToast({
+ variant: "error",
+ title: input.requestFailedTitle,
+ description: message + more,
+ })
+ }
+ input.setGlobalStore("ready", true)
+}
+
+function groupBySession<T extends { id: string; sessionID: string }>(input: T[]) {
+ return input.reduce<Record<string, T[]>>((acc, item) => {
+ if (!item?.id || !item.sessionID) return acc
+ const list = acc[item.sessionID]
+ if (list) list.push(item)
+ if (!list) acc[item.sessionID] = [item]
+ return acc
+ }, {})
+}
+
+export async function bootstrapDirectory(input: {
+ directory: string
+ sdk: ReturnType<typeof createOpencodeClient>
+ store: Store<State>
+ setStore: SetStoreFunction<State>
+ vcsCache: VcsCache
+ loadSessions: (directory: string) => Promise<void> | void
+}) {
+ input.setStore("status", "loading")
+
+ const blockingRequests = {
+ project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
+ provider: () =>
+ input.sdk.provider.list().then((x) => {
+ input.setStore("provider", normalizeProviderList(x.data!))
+ }),
+ agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
+ config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
+ }
+
+ try {
+ await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
+ } catch (err) {
+ console.error("Failed to bootstrap instance", err)
+ const project = getFilename(input.directory)
+ const message = err instanceof Error ? err.message : String(err)
+ showToast({ title: `Failed to reload ${project}`, description: message })
+ input.setStore("status", "partial")
+ return
+ }
+
+ if (input.store.status !== "complete") input.setStore("status", "partial")
+
+ Promise.all([
+ input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
+ input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
+ input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
+ input.loadSessions(input.directory),
+ input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
+ input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
+ input.sdk.vcs.get().then((x) => {
+ const next = x.data ?? input.store.vcs
+ input.setStore("vcs", next)
+ if (next?.branch) input.vcsCache.setStore("value", next)
+ }),
+ input.sdk.permission.list().then((x) => {
+ const grouped = groupBySession(
+ (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+ )
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.permission)) {
+ if (grouped[sessionID]) continue
+ input.setStore("permission", sessionID, [])
+ }
+ for (const [sessionID, permissions] of Object.entries(grouped)) {
+ input.setStore(
+ "permission",
+ sessionID,
+ reconcile(
+ permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ input.sdk.question.list().then((x) => {
+ const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.question)) {
+ if (grouped[sessionID]) continue
+ input.setStore("question", sessionID, [])
+ }
+ for (const [sessionID, questions] of Object.entries(grouped)) {
+ input.setStore(
+ "question",
+ sessionID,
+ reconcile(
+ questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ ]).then(() => {
+ input.setStore("status", "complete")
+ })
+}
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
new file mode 100644
index 000000000..2feb7fe08
--- /dev/null
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -0,0 +1,263 @@
+import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
+import {
+ DIR_IDLE_TTL_MS,
+ MAX_DIR_STORES,
+ type ChildOptions,
+ type DirState,
+ type IconCache,
+ type MetaCache,
+ type ProjectMeta,
+ type State,
+ type VcsCache,
+} from "./types"
+import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
+
+export function createChildStoreManager(input: {
+ owner: Owner
+ markStats: (activeDirectoryStores: number) => void
+ incrementEvictions: () => void
+ isBooting: (directory: string) => boolean
+ isLoadingSessions: (directory: string) => boolean
+ onBootstrap: (directory: string) => void
+ onDispose: (directory: string) => void
+}) {
+ const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
+ const vcsCache = new Map<string, VcsCache>()
+ const metaCache = new Map<string, MetaCache>()
+ const iconCache = new Map<string, IconCache>()
+ const lifecycle = new Map<string, DirState>()
+ const pins = new Map<string, number>()
+ const ownerPins = new WeakMap<object, Set<string>>()
+ const disposers = new Map<string, () => void>()
+
+ const mark = (directory: string) => {
+ if (!directory) return
+ lifecycle.set(directory, { lastAccessAt: Date.now() })
+ runEviction()
+ }
+
+ const pin = (directory: string) => {
+ if (!directory) return
+ pins.set(directory, (pins.get(directory) ?? 0) + 1)
+ mark(directory)
+ }
+
+ const unpin = (directory: string) => {
+ if (!directory) return
+ const next = (pins.get(directory) ?? 0) - 1
+ if (next > 0) {
+ pins.set(directory, next)
+ return
+ }
+ pins.delete(directory)
+ runEviction()
+ }
+
+ const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
+
+ const pinForOwner = (directory: string) => {
+ const current = getOwner()
+ if (!current) return
+ if (current === input.owner) return
+ const key = current as object
+ const set = ownerPins.get(key)
+ if (set?.has(directory)) return
+ if (set) set.add(directory)
+ if (!set) ownerPins.set(key, new Set([directory]))
+ pin(directory)
+ onCleanup(() => {
+ const set = ownerPins.get(key)
+ if (set) {
+ set.delete(directory)
+ if (set.size === 0) ownerPins.delete(key)
+ }
+ unpin(directory)
+ })
+ }
+
+ function disposeDirectory(directory: string) {
+ if (
+ !canDisposeDirectory({
+ directory,
+ hasStore: !!children[directory],
+ pinned: pinned(directory),
+ booting: input.isBooting(directory),
+ loadingSessions: input.isLoadingSessions(directory),
+ })
+ ) {
+ return false
+ }
+
+ vcsCache.delete(directory)
+ metaCache.delete(directory)
+ iconCache.delete(directory)
+ lifecycle.delete(directory)
+ const dispose = disposers.get(directory)
+ if (dispose) {
+ dispose()
+ disposers.delete(directory)
+ }
+ delete children[directory]
+ input.onDispose(directory)
+ input.markStats(Object.keys(children).length)
+ return true
+ }
+
+ function runEviction() {
+ const stores = Object.keys(children)
+ if (stores.length === 0) return
+ const list = pickDirectoriesToEvict({
+ stores,
+ state: lifecycle,
+ pins: new Set(stores.filter(pinned)),
+ max: MAX_DIR_STORES,
+ ttl: DIR_IDLE_TTL_MS,
+ now: Date.now(),
+ })
+ if (list.length === 0) return
+ for (const directory of list) {
+ if (!disposeDirectory(directory)) continue
+ input.incrementEvictions()
+ }
+ }
+
+ function ensureChild(directory: string) {
+ if (!directory) console.error("No directory provided")
+ if (!children[directory]) {
+ const vcs = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "vcs", ["vcs.v1"]),
+ createStore({ value: undefined as VcsInfo | undefined }),
+ ),
+ )
+ if (!vcs) throw new Error("Failed to create persisted cache")
+ const vcsStore = vcs[0]
+ const vcsReady = vcs[3]
+ vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
+
+ const meta = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "project", ["project.v1"]),
+ createStore({ value: undefined as ProjectMeta | undefined }),
+ ),
+ )
+ if (!meta) throw new Error("Failed to create persisted project metadata")
+ metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
+
+ const icon = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "icon", ["icon.v1"]),
+ createStore({ value: undefined as string | undefined }),
+ ),
+ )
+ if (!icon) throw new Error("Failed to create persisted project icon")
+ iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
+
+ const init = () =>
+ createRoot((dispose) => {
+ const child = createStore<State>({
+ project: "",
+ projectMeta: meta[0].value,
+ icon: icon[0].value,
+ provider: { all: [], connected: [], default: {} },
+ config: {},
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ status: "loading" as const,
+ agent: [],
+ command: [],
+ session: [],
+ sessionTotal: 0,
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ permission: {},
+ question: {},
+ mcp: {},
+ lsp: [],
+ vcs: vcsStore.value,
+ limit: 5,
+ message: {},
+ part: {},
+ })
+ children[directory] = child
+ disposers.set(directory, dispose)
+
+ createEffect(() => {
+ if (!vcsReady()) return
+ const cached = vcsStore.value
+ if (!cached?.branch) return
+ child[1]("vcs", (value) => value ?? cached)
+ })
+ createEffect(() => {
+ child[1]("projectMeta", meta[0].value)
+ })
+ createEffect(() => {
+ child[1]("icon", icon[0].value)
+ })
+ })
+
+ runWithOwner(input.owner, init)
+ input.markStats(Object.keys(children).length)
+ }
+ mark(directory)
+ const childStore = children[directory]
+ if (!childStore) throw new Error("Failed to create store")
+ return childStore
+ }
+
+ function child(directory: string, options: ChildOptions = {}) {
+ const childStore = ensureChild(directory)
+ pinForOwner(directory)
+ const shouldBootstrap = options.bootstrap ?? true
+ if (shouldBootstrap && childStore[0].status === "loading") {
+ input.onBootstrap(directory)
+ }
+ return childStore
+ }
+
+ function projectMeta(directory: string, patch: ProjectMeta) {
+ const [store, setStore] = ensureChild(directory)
+ const cached = metaCache.get(directory)
+ if (!cached) return
+ const previous = store.projectMeta ?? {}
+ const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
+ const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
+ const next = {
+ ...previous,
+ ...patch,
+ icon,
+ commands,
+ }
+ cached.setStore("value", next)
+ setStore("projectMeta", next)
+ }
+
+ function projectIcon(directory: string, value: string | undefined) {
+ const [store, setStore] = ensureChild(directory)
+ const cached = iconCache.get(directory)
+ if (!cached) return
+ if (store.icon === value) return
+ cached.setStore("value", value)
+ setStore("icon", value)
+ }
+
+ return {
+ children,
+ ensureChild,
+ child,
+ projectMeta,
+ projectIcon,
+ mark,
+ pin,
+ unpin,
+ pinned,
+ disposeDirectory,
+ runEviction,
+ vcsCache,
+ metaCache,
+ iconCache,
+ }
+}
diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts
new file mode 100644
index 000000000..f79b9fc95
--- /dev/null
+++ b/packages/app/src/context/global-sync/event-reducer.test.ts
@@ -0,0 +1,201 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client"
+import { createStore } from "solid-js/store"
+import type { State } from "./types"
+import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer"
+
+const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
+ ({
+ id: input.id,
+ parentID: input.parentID,
+ time: {
+ created: 1,
+ updated: 1,
+ archived: input.archived,
+ },
+ }) as Session
+
+const userMessage = (id: string, sessionID: string) =>
+ ({
+ id,
+ sessionID,
+ role: "user",
+ time: { created: 1 },
+ agent: "assistant",
+ model: { providerID: "openai", modelID: "gpt" },
+ }) as Message
+
+const textPart = (id: string, sessionID: string, messageID: string) =>
+ ({
+ id,
+ sessionID,
+ messageID,
+ type: "text",
+ text: id,
+ }) as Part
+
+const baseState = (input: Partial<State> = {}) =>
+ ({
+ status: "complete",
+ agent: [],
+ command: [],
+ project: "",
+ projectMeta: undefined,
+ icon: undefined,
+ provider: {} as State["provider"],
+ config: {} as State["config"],
+ path: { directory: "/tmp" } as State["path"],
+ session: [],
+ sessionTotal: 0,
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ permission: {},
+ question: {},
+ mcp: {},
+ lsp: [],
+ vcs: undefined,
+ limit: 10,
+ message: {},
+ part: {},
+ ...input,
+ }) as State
+
+describe("applyGlobalEvent", () => {
+ test("upserts project.updated in sorted position", () => {
+ const project = [{ id: "a" }, { id: "c" }] as Project[]
+ let refreshCount = 0
+ applyGlobalEvent({
+ event: { type: "project.updated", properties: { id: "b" } },
+ project,
+ refresh: () => {
+ refreshCount += 1
+ },
+ setGlobalProject(next) {
+ if (typeof next === "function") next(project)
+ },
+ })
+
+ expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
+ expect(refreshCount).toBe(0)
+ })
+
+ test("handles global.disposed by triggering refresh", () => {
+ let refreshCount = 0
+ applyGlobalEvent({
+ event: { type: "global.disposed" },
+ project: [],
+ refresh: () => {
+ refreshCount += 1
+ },
+ setGlobalProject() {},
+ })
+
+ expect(refreshCount).toBe(1)
+ })
+})
+
+describe("applyDirectoryEvent", () => {
+ test("inserts root sessions in sorted order and updates sessionTotal", () => {
+ const [store, setStore] = createStore(
+ baseState({
+ session: [rootSession({ id: "b" })],
+ sessionTotal: 1,
+ }),
+ )
+
+ applyDirectoryEvent({
+ event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
+ expect(store.sessionTotal).toBe(2)
+
+ applyDirectoryEvent({
+ event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.sessionTotal).toBe(2)
+ })
+
+ test("cleans session caches when archived", () => {
+ const message = userMessage("msg_1", "ses_1")
+ const [store, setStore] = createStore(
+ baseState({
+ session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
+ sessionTotal: 2,
+ message: { ses_1: [message] },
+ part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
+ session_diff: { ses_1: [] },
+ todo: { ses_1: [] },
+ permission: { ses_1: [] },
+ question: { ses_1: [] },
+ session_status: { ses_1: { type: "busy" } },
+ }),
+ )
+
+ applyDirectoryEvent({
+ event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
+ expect(store.sessionTotal).toBe(1)
+ expect(store.message.ses_1).toBeUndefined()
+ expect(store.part[message.id]).toBeUndefined()
+ expect(store.session_diff.ses_1).toBeUndefined()
+ expect(store.todo.ses_1).toBeUndefined()
+ expect(store.permission.ses_1).toBeUndefined()
+ expect(store.question.ses_1).toBeUndefined()
+ expect(store.session_status.ses_1).toBeUndefined()
+ })
+
+ test("routes disposal and lsp events to side-effect handlers", () => {
+ const [store, setStore] = createStore(baseState())
+ const pushes: string[] = []
+ let lspLoads = 0
+
+ applyDirectoryEvent({
+ event: { type: "server.instance.disposed" },
+ store,
+ setStore,
+ push(directory) {
+ pushes.push(directory)
+ },
+ directory: "/tmp",
+ loadLsp() {
+ lspLoads += 1
+ },
+ })
+
+ applyDirectoryEvent({
+ event: { type: "lsp.updated" },
+ store,
+ setStore,
+ push(directory) {
+ pushes.push(directory)
+ },
+ directory: "/tmp",
+ loadLsp() {
+ lspLoads += 1
+ },
+ })
+
+ expect(pushes).toEqual(["/tmp"])
+ expect(lspLoads).toBe(1)
+ })
+})
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
new file mode 100644
index 000000000..c658d82c8
--- /dev/null
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -0,0 +1,319 @@
+import { Binary } from "@opencode-ai/util/binary"
+import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import type {
+ FileDiff,
+ Message,
+ Part,
+ PermissionRequest,
+ Project,
+ QuestionRequest,
+ Session,
+ SessionStatus,
+ Todo,
+} from "@opencode-ai/sdk/v2/client"
+import type { State, VcsCache } from "./types"
+import { trimSessions } from "./session-trim"
+
+export function applyGlobalEvent(input: {
+ event: { type: string; properties?: unknown }
+ project: Project[]
+ setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
+ refresh: () => void
+}) {
+ if (input.event.type === "global.disposed") {
+ input.refresh()
+ return
+ }
+
+ if (input.event.type !== "project.updated") return
+ const properties = input.event.properties as Project
+ const result = Binary.search(input.project, properties.id, (s) => s.id)
+ if (result.found) {
+ input.setGlobalProject((draft) => {
+ draft[result.index] = { ...draft[result.index], ...properties }
+ })
+ return
+ }
+ input.setGlobalProject((draft) => {
+ draft.splice(result.index, 0, properties)
+ })
+}
+
+function cleanupSessionCaches(store: Store<State>, setStore: SetStoreFunction<State>, sessionID: string) {
+ if (!sessionID) return
+ const hasAny =
+ store.message[sessionID] !== undefined ||
+ store.session_diff[sessionID] !== undefined ||
+ store.todo[sessionID] !== undefined ||
+ store.permission[sessionID] !== undefined ||
+ store.question[sessionID] !== undefined ||
+ store.session_status[sessionID] !== undefined
+ if (!hasAny) return
+ setStore(
+ produce((draft) => {
+ const messages = draft.message[sessionID]
+ if (messages) {
+ for (const message of messages) {
+ const id = message?.id
+ if (!id) continue
+ delete draft.part[id]
+ }
+ }
+ delete draft.message[sessionID]
+ delete draft.session_diff[sessionID]
+ delete draft.todo[sessionID]
+ delete draft.permission[sessionID]
+ delete draft.question[sessionID]
+ delete draft.session_status[sessionID]
+ }),
+ )
+}
+
+export function applyDirectoryEvent(input: {
+ event: { type: string; properties?: unknown }
+ store: Store<State>
+ setStore: SetStoreFunction<State>
+ push: (directory: string) => void
+ directory: string
+ loadLsp: () => void
+ vcsCache?: VcsCache
+}) {
+ const event = input.event
+ switch (event.type) {
+ case "server.instance.disposed": {
+ input.push(input.directory)
+ return
+ }
+ case "session.created": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (result.found) {
+ input.setStore("session", result.index, reconcile(info))
+ break
+ }
+ const next = input.store.session.slice()
+ next.splice(result.index, 0, info)
+ const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+ input.setStore("session", reconcile(trimmed, { key: "id" }))
+ if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
+ break
+ }
+ case "session.updated": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (info.time.archived) {
+ if (result.found) {
+ input.setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ cleanupSessionCaches(input.store, input.setStore, info.id)
+ if (info.parentID) break
+ input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+ break
+ }
+ if (result.found) {
+ input.setStore("session", result.index, reconcile(info))
+ break
+ }
+ const next = input.store.session.slice()
+ next.splice(result.index, 0, info)
+ const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+ input.setStore("session", reconcile(trimmed, { key: "id" }))
+ break
+ }
+ case "session.deleted": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (result.found) {
+ input.setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ cleanupSessionCaches(input.store, input.setStore, info.id)
+ if (info.parentID) break
+ input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+ break
+ }
+ case "session.diff": {
+ const props = event.properties as { sessionID: string; diff: FileDiff[] }
+ input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
+ break
+ }
+ case "todo.updated": {
+ const props = event.properties as { sessionID: string; todos: Todo[] }
+ input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
+ break
+ }
+ case "session.status": {
+ const props = event.properties as { sessionID: string; status: SessionStatus }
+ input.setStore("session_status", props.sessionID, reconcile(props.status))
+ break
+ }
+ case "message.updated": {
+ const info = (event.properties as { info: Message }).info
+ const messages = input.store.message[info.sessionID]
+ if (!messages) {
+ input.setStore("message", info.sessionID, [info])
+ break
+ }
+ const result = Binary.search(messages, info.id, (m) => m.id)
+ if (result.found) {
+ input.setStore("message", info.sessionID, result.index, reconcile(info))
+ break
+ }
+ input.setStore(
+ "message",
+ info.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, info)
+ }),
+ )
+ break
+ }
+ case "message.removed": {
+ const props = event.properties as { sessionID: string; messageID: string }
+ input.setStore(
+ produce((draft) => {
+ const messages = draft.message[props.sessionID]
+ if (messages) {
+ const result = Binary.search(messages, props.messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[props.messageID]
+ }),
+ )
+ break
+ }
+ case "message.part.updated": {
+ const part = (event.properties as { part: Part }).part
+ const parts = input.store.part[part.messageID]
+ if (!parts) {
+ input.setStore("part", part.messageID, [part])
+ break
+ }
+ const result = Binary.search(parts, part.id, (p) => p.id)
+ if (result.found) {
+ input.setStore("part", part.messageID, result.index, reconcile(part))
+ break
+ }
+ input.setStore(
+ "part",
+ part.messageID,
+ produce((draft) => {
+ draft.splice(result.index, 0, part)
+ }),
+ )
+ break
+ }
+ case "message.part.removed": {
+ const props = event.properties as { messageID: string; partID: string }
+ const parts = input.store.part[props.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, props.partID, (p) => p.id)
+ if (result.found) {
+ input.setStore(
+ produce((draft) => {
+ const list = draft.part[props.messageID]
+ if (!list) return
+ const next = Binary.search(list, props.partID, (p) => p.id)
+ if (!next.found) return
+ list.splice(next.index, 1)
+ if (list.length === 0) delete draft.part[props.messageID]
+ }),
+ )
+ }
+ break
+ }
+ case "vcs.branch.updated": {
+ const props = event.properties as { branch: string }
+ const next = { branch: props.branch }
+ input.setStore("vcs", next)
+ if (input.vcsCache) input.vcsCache.setStore("value", next)
+ break
+ }
+ case "permission.asked": {
+ const permission = event.properties as PermissionRequest
+ const permissions = input.store.permission[permission.sessionID]
+ if (!permissions) {
+ input.setStore("permission", permission.sessionID, [permission])
+ break
+ }
+ const result = Binary.search(permissions, permission.id, (p) => p.id)
+ if (result.found) {
+ input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
+ break
+ }
+ input.setStore(
+ "permission",
+ permission.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, permission)
+ }),
+ )
+ break
+ }
+ case "permission.replied": {
+ const props = event.properties as { sessionID: string; requestID: string }
+ const permissions = input.store.permission[props.sessionID]
+ if (!permissions) break
+ const result = Binary.search(permissions, props.requestID, (p) => p.id)
+ if (!result.found) break
+ input.setStore(
+ "permission",
+ props.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
+ case "question.asked": {
+ const question = event.properties as QuestionRequest
+ const questions = input.store.question[question.sessionID]
+ if (!questions) {
+ input.setStore("question", question.sessionID, [question])
+ break
+ }
+ const result = Binary.search(questions, question.id, (q) => q.id)
+ if (result.found) {
+ input.setStore("question", question.sessionID, result.index, reconcile(question))
+ break
+ }
+ input.setStore(
+ "question",
+ question.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, question)
+ }),
+ )
+ break
+ }
+ case "question.replied":
+ case "question.rejected": {
+ const props = event.properties as { sessionID: string; requestID: string }
+ const questions = input.store.question[props.sessionID]
+ if (!questions) break
+ const result = Binary.search(questions, props.requestID, (q) => q.id)
+ if (!result.found) break
+ input.setStore(
+ "question",
+ props.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
+ case "lsp.updated": {
+ input.loadLsp()
+ break
+ }
+ }
+}
diff --git a/packages/app/src/context/global-sync/eviction.ts b/packages/app/src/context/global-sync/eviction.ts
new file mode 100644
index 000000000..676a6ee17
--- /dev/null
+++ b/packages/app/src/context/global-sync/eviction.ts
@@ -0,0 +1,28 @@
+import type { DisposeCheck, EvictPlan } from "./types"
+
+export function pickDirectoriesToEvict(input: EvictPlan) {
+ const overflow = Math.max(0, input.stores.length - input.max)
+ let pendingOverflow = overflow
+ const sorted = input.stores
+ .filter((dir) => !input.pins.has(dir))
+ .slice()
+ .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
+ const output: string[] = []
+ for (const dir of sorted) {
+ const last = input.state.get(dir)?.lastAccessAt ?? 0
+ const idle = input.now - last >= input.ttl
+ if (!idle && pendingOverflow <= 0) continue
+ output.push(dir)
+ if (pendingOverflow > 0) pendingOverflow -= 1
+ }
+ return output
+}
+
+export function canDisposeDirectory(input: DisposeCheck) {
+ if (!input.directory) return false
+ if (!input.hasStore) return false
+ if (input.pinned) return false
+ if (input.booting) return false
+ if (input.loadingSessions) return false
+ return true
+}
diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts
new file mode 100644
index 000000000..c3468583b
--- /dev/null
+++ b/packages/app/src/context/global-sync/queue.ts
@@ -0,0 +1,83 @@
+type QueueInput = {
+ paused: () => boolean
+ bootstrap: () => Promise<void>
+ bootstrapInstance: (directory: string) => Promise<void> | void
+}
+
+export function createRefreshQueue(input: QueueInput) {
+ const queued = new Set<string>()
+ let root = false
+ let running = false
+ let timer: ReturnType<typeof setTimeout> | undefined
+
+ const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
+
+ const take = (count: number) => {
+ if (queued.size === 0) return [] as string[]
+ const items: string[] = []
+ for (const item of queued) {
+ queued.delete(item)
+ items.push(item)
+ if (items.length >= count) break
+ }
+ return items
+ }
+
+ const schedule = () => {
+ if (timer) return
+ timer = setTimeout(() => {
+ timer = undefined
+ void drain()
+ }, 0)
+ }
+
+ const push = (directory: string) => {
+ if (!directory) return
+ queued.add(directory)
+ if (input.paused()) return
+ schedule()
+ }
+
+ const refresh = () => {
+ root = true
+ if (input.paused()) return
+ schedule()
+ }
+
+ async function drain() {
+ if (running) return
+ running = true
+ try {
+ while (true) {
+ if (input.paused()) return
+ if (root) {
+ root = false
+ await input.bootstrap()
+ await tick()
+ continue
+ }
+ const dirs = take(2)
+ if (dirs.length === 0) return
+ await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir)))
+ await tick()
+ }
+ } finally {
+ running = false
+ if (input.paused()) return
+ if (root || queued.size) schedule()
+ }
+ }
+
+ return {
+ push,
+ refresh,
+ clear(directory: string) {
+ queued.delete(directory)
+ },
+ dispose() {
+ if (!timer) return
+ clearTimeout(timer)
+ timer = undefined
+ },
+ }
+}
diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts
new file mode 100644
index 000000000..443aa8450
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-load.ts
@@ -0,0 +1,26 @@
+import type { RootLoadArgs } from "./types"
+
+export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
+ try {
+ const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: true,
+ } as const
+ } catch {
+ input.onFallback()
+ const result = await input.list({ directory: input.directory, roots: true })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: false,
+ } as const
+ }
+}
+
+export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
+ if (!input.limited) return input.count
+ if (input.count < input.limit) return input.count
+ return input.count + 1
+}
diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts
new file mode 100644
index 000000000..be12c074b
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-trim.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { trimSessions } from "./session-trim"
+
+const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
+ ({
+ id: input.id,
+ parentID: input.parentID,
+ time: {
+ created: input.created,
+ updated: input.updated,
+ archived: input.archived,
+ },
+ }) as Session
+
+describe("trimSessions", () => {
+ test("keeps base roots and recent roots beyond the limit", () => {
+ const now = 1_000_000
+ const list = [
+ session({ id: "a", created: now - 100_000 }),
+ session({ id: "b", created: now - 90_000 }),
+ session({ id: "c", created: now - 80_000 }),
+ session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
+ session({ id: "e", created: now - 60_000, archived: now - 10 }),
+ ]
+
+ const result = trimSessions(list, { limit: 2, permission: {}, now })
+ expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
+ })
+
+ test("keeps children when root is kept, permission exists, or child is recent", () => {
+ const now = 1_000_000
+ const list = [
+ session({ id: "root-1", created: now - 1000 }),
+ session({ id: "root-2", created: now - 2000 }),
+ session({ id: "z-root", created: now - 30_000_000 }),
+ session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
+ session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
+ session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
+ session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
+ ]
+
+ const result = trimSessions(list, {
+ limit: 2,
+ permission: {
+ "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
+ },
+ now,
+ })
+
+ expect(result.map((x) => x.id)).toEqual([
+ "child-kept-by-permission",
+ "child-kept-by-recency",
+ "child-kept-by-root",
+ "root-1",
+ "root-2",
+ ])
+ })
+})
diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts
new file mode 100644
index 000000000..800ba74a6
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-trim.ts
@@ -0,0 +1,56 @@
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { cmp } from "./utils"
+import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
+
+export function sessionUpdatedAt(session: Session) {
+ return session.time.updated ?? session.time.created
+}
+
+export function compareSessionRecent(a: Session, b: Session) {
+ const aUpdated = sessionUpdatedAt(a)
+ const bUpdated = sessionUpdatedAt(b)
+ if (aUpdated !== bUpdated) return bUpdated - aUpdated
+ return cmp(a.id, b.id)
+}
+
+export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
+ if (limit <= 0) return [] as Session[]
+ const selected: Session[] = []
+ const seen = new Set<string>()
+ for (const session of sessions) {
+ if (!session?.id) continue
+ if (seen.has(session.id)) continue
+ seen.add(session.id)
+ if (sessionUpdatedAt(session) <= cutoff) continue
+ const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
+ if (index === -1) selected.push(session)
+ if (index !== -1) selected.splice(index, 0, session)
+ if (selected.length > limit) selected.pop()
+ }
+ return selected
+}
+
+export function trimSessions(
+ input: Session[],
+ options: { limit: number; permission: Record<string, PermissionRequest[]>; now?: number },
+) {
+ const limit = Math.max(0, options.limit)
+ const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
+ const all = input
+ .filter((s) => !!s?.id)
+ .filter((s) => !s.time?.archived)
+ .sort((a, b) => cmp(a.id, b.id))
+ const roots = all.filter((s) => !s.parentID)
+ const children = all.filter((s) => !!s.parentID)
+ const base = roots.slice(0, limit)
+ const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
+ const keepRoots = [...base, ...recent]
+ const keepRootIds = new Set(keepRoots.map((s) => s.id))
+ const keepChildren = children.filter((s) => {
+ if (s.parentID && keepRootIds.has(s.parentID)) return true
+ const perms = options.permission[s.id] ?? []
+ if (perms.length > 0) return true
+ return sessionUpdatedAt(s) > cutoff
+ })
+ return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
+}
diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts
new file mode 100644
index 000000000..ade0b973a
--- /dev/null
+++ b/packages/app/src/context/global-sync/types.ts
@@ -0,0 +1,134 @@
+import type {
+ Agent,
+ Command,
+ Config,
+ FileDiff,
+ LspStatus,
+ McpStatus,
+ Message,
+ Part,
+ Path,
+ PermissionRequest,
+ Project,
+ ProviderListResponse,
+ QuestionRequest,
+ Session,
+ SessionStatus,
+ Todo,
+ VcsInfo,
+} from "@opencode-ai/sdk/v2/client"
+import type { Accessor } from "solid-js"
+import type { SetStoreFunction, Store } from "solid-js/store"
+
+export type ProjectMeta = {
+ name?: string
+ icon?: {
+ override?: string
+ color?: string
+ }
+ commands?: {
+ start?: string
+ }
+}
+
+export type State = {
+ status: "loading" | "partial" | "complete"
+ agent: Agent[]
+ command: Command[]
+ project: string
+ projectMeta: ProjectMeta | undefined
+ icon: string | undefined
+ provider: ProviderListResponse
+ config: Config
+ path: Path
+ session: Session[]
+ sessionTotal: number
+ session_status: {
+ [sessionID: string]: SessionStatus
+ }
+ session_diff: {
+ [sessionID: string]: FileDiff[]
+ }
+ todo: {
+ [sessionID: string]: Todo[]
+ }
+ permission: {
+ [sessionID: string]: PermissionRequest[]
+ }
+ question: {
+ [sessionID: string]: QuestionRequest[]
+ }
+ mcp: {
+ [name: string]: McpStatus
+ }
+ lsp: LspStatus[]
+ vcs: VcsInfo | undefined
+ limit: number
+ message: {
+ [sessionID: string]: Message[]
+ }
+ part: {
+ [messageID: string]: Part[]
+ }
+}
+
+export type VcsCache = {
+ store: Store<{ value: VcsInfo | undefined }>
+ setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
+ ready: Accessor<boolean>
+}
+
+export type MetaCache = {
+ store: Store<{ value: ProjectMeta | undefined }>
+ setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
+ ready: Accessor<boolean>
+}
+
+export type IconCache = {
+ store: Store<{ value: string | undefined }>
+ setStore: SetStoreFunction<{ value: string | undefined }>
+ ready: Accessor<boolean>
+}
+
+export type ChildOptions = {
+ bootstrap?: boolean
+}
+
+export type DirState = {
+ lastAccessAt: number
+}
+
+export type EvictPlan = {
+ stores: string[]
+ state: Map<string, DirState>
+ pins: Set<string>
+ max: number
+ ttl: number
+ now: number
+}
+
+export type DisposeCheck = {
+ directory: string
+ hasStore: boolean
+ pinned: boolean
+ booting: boolean
+ loadingSessions: boolean
+}
+
+export type RootLoadArgs = {
+ directory: string
+ limit: number
+ list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
+ onFallback: () => void
+}
+
+export type RootLoadResult = {
+ data?: Session[]
+ limit: number
+ limited: boolean
+}
+
+export const MAX_DIR_STORES = 30
+export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
+export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
+export const SESSION_RECENT_LIMIT = 50
diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts
new file mode 100644
index 000000000..6b78134a6
--- /dev/null
+++ b/packages/app/src/context/global-sync/utils.ts
@@ -0,0 +1,25 @@
+import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+
+export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+
+export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
+ return {
+ ...input,
+ all: input.all.map((provider) => ({
+ ...provider,
+ models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
+ })),
+ }
+}
+
+export function sanitizeProject(project: Project) {
+ if (!project.icon?.url && !project.icon?.override) return project
+ return {
+ ...project,
+ icon: {
+ ...project.icon,
+ url: undefined,
+ override: undefined,
+ },
+ }
+}
diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx
index bf081996b..22f7bcca1 100644
--- a/packages/app/src/context/language.tsx
+++ b/packages/app/src/context/language.tsx
@@ -76,6 +76,26 @@ const LOCALES: readonly Locale[] = [
"th",
]
+type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
+const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
+ zh,
+ zht,
+ ko,
+ de,
+ es,
+ fr,
+ da,
+ ja,
+ pl,
+ ru,
+ ar,
+ no,
+ br,
+ th,
+ bs,
+}
+void PARITY_CHECK
+
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts
index c56565385..c421a58b6 100644
--- a/packages/app/src/context/layout-scroll.test.ts
+++ b/packages/app/src/context/layout-scroll.test.ts
@@ -1,73 +1,36 @@
import { describe, expect, test } from "bun:test"
-import { createRoot } from "solid-js"
-import { createStore } from "solid-js/store"
-import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
import { createScrollPersistence } from "./layout-scroll"
describe("createScrollPersistence", () => {
- test.skip("debounces persisted scroll writes", async () => {
- const key = "layout-scroll.test"
- const data = new Map<string, string>()
- const writes: string[] = []
- const stats = { flushes: 0 }
-
- const storage = {
- getItem: (k: string) => data.get(k) ?? null,
- setItem: (k: string, v: string) => {
- data.set(k, v)
- if (k === key) writes.push(v)
+ test("debounces persisted scroll writes", async () => {
+ const snapshot = {
+ session: {
+ review: { x: 0, y: 0 },
},
- removeItem: (k: string) => {
- data.delete(k)
+ } as Record<string, Record<string, { x: number; y: number }>>
+ const writes: Array<Record<string, { x: number; y: number }>> = []
+ const scroll = createScrollPersistence({
+ debounceMs: 10,
+ getSnapshot: (sessionKey) => snapshot[sessionKey],
+ onFlush: (sessionKey, next) => {
+ snapshot[sessionKey] = next
+ writes.push(next)
},
- } as SyncStorage
-
- await new Promise<void>((resolve, reject) => {
- createRoot((dispose) => {
- const [raw, setRaw] = createStore({
- sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
- })
-
- const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
-
- const scroll = createScrollPersistence({
- debounceMs: 30,
- getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
- onFlush: (sessionKey, next) => {
- stats.flushes += 1
-
- const current = store.sessionView[sessionKey]
- if (!current) {
- setStore("sessionView", sessionKey, { scroll: next })
- return
- }
- setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
- },
- })
+ })
- const run = async () => {
- await new Promise((r) => setTimeout(r, 0))
- writes.length = 0
+ for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
+ scroll.setScroll("session", "review", { x: 0, y: i })
+ }
- for (const i of Array.from({ length: 100 }, (_, n) => n)) {
- scroll.setScroll("session", "review", { x: 0, y: i })
- }
+ await new Promise((resolve) => setTimeout(resolve, 40))
- await new Promise((r) => setTimeout(r, 120))
+ expect(writes).toHaveLength(1)
+ expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
- expect(stats.flushes).toBeGreaterThanOrEqual(1)
- expect(writes.length).toBeGreaterThanOrEqual(1)
- expect(writes.length).toBeLessThanOrEqual(2)
- }
+ scroll.setScroll("session", "review", { x: 0, y: 30 })
+ await new Promise((resolve) => setTimeout(resolve, 20))
- void run()
- .then(resolve)
- .catch(reject)
- .finally(() => {
- scroll.dispose()
- dispose()
- })
- })
- })
+ expect(writes).toHaveLength(1)
+ scroll.dispose()
})
})
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
index c307f6e72..72693e6ef 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -1,9 +1,9 @@
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
+import { checkServerHealth } from "@/utils/server-health"
type StoredProject = { worktree: string; expanded: boolean }
@@ -94,18 +94,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!state.active)
- const check = (url: string) => {
- const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
- const sdk = createOpencodeClient({
- baseUrl: url,
- fetch: platform.fetch,
- signal,
- })
- return sdk.global
- .health()
- .then((x) => x.data?.healthy === true)
- .catch(() => false)
- }
+ const fetcher = platform.fetch ?? globalThis.fetch
+ const check = (url: string) => checkServerHealth(url, fetcher).then((x) => x.healthy)
createEffect(() => {
const url = state.active
diff --git a/packages/app/src/context/sync-optimistic.test.ts b/packages/app/src/context/sync-optimistic.test.ts
new file mode 100644
index 000000000..7deeddd6e
--- /dev/null
+++ b/packages/app/src/context/sync-optimistic.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, test } from "bun:test"
+import type { Message, Part } from "@opencode-ai/sdk/v2/client"
+import { applyOptimisticAdd, applyOptimisticRemove } from "./sync"
+
+const userMessage = (id: string, sessionID: string): Message => ({
+ id,
+ sessionID,
+ role: "user",
+ time: { created: 1 },
+ agent: "assistant",
+ model: { providerID: "openai", modelID: "gpt" },
+})
+
+const textPart = (id: string, sessionID: string, messageID: string): Part => ({
+ id,
+ sessionID,
+ messageID,
+ type: "text",
+ text: id,
+})
+
+describe("sync optimistic reducers", () => {
+ test("applyOptimisticAdd inserts message in sorted order and stores parts", () => {
+ const sessionID = "ses_1"
+ const draft = {
+ message: { [sessionID]: [userMessage("msg_2", sessionID)] },
+ part: {} as Record<string, Part[] | undefined>,
+ }
+
+ applyOptimisticAdd(draft, {
+ sessionID,
+ message: userMessage("msg_1", sessionID),
+ parts: [textPart("prt_2", sessionID, "msg_1"), textPart("prt_1", sessionID, "msg_1")],
+ })
+
+ expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2"])
+ expect(draft.part.msg_1?.map((x) => x.id)).toEqual(["prt_1", "prt_2"])
+ })
+
+ test("applyOptimisticRemove removes message and part entries", () => {
+ const sessionID = "ses_1"
+ const draft = {
+ message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_2", sessionID)] },
+ part: {
+ msg_1: [textPart("prt_1", sessionID, "msg_1")],
+ msg_2: [textPart("prt_2", sessionID, "msg_2")],
+ } as Record<string, Part[] | undefined>,
+ }
+
+ applyOptimisticRemove(draft, { sessionID, messageID: "msg_1" })
+
+ expect(draft.message[sessionID]?.map((x) => x.id)).toEqual(["msg_2"])
+ expect(draft.part.msg_1).toBeUndefined()
+ expect(draft.part.msg_2).toHaveLength(1)
+ })
+})
diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx
index 0c6365245..66c53dc80 100644
--- a/packages/app/src/context/sync.tsx
+++ b/packages/app/src/context/sync.tsx
@@ -11,6 +11,43 @@ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+type OptimisticStore = {
+ message: Record<string, Message[] | undefined>
+ part: Record<string, Part[] | undefined>
+}
+
+type OptimisticAddInput = {
+ sessionID: string
+ message: Message
+ parts: Part[]
+}
+
+type OptimisticRemoveInput = {
+ sessionID: string
+ messageID: string
+}
+
+export function applyOptimisticAdd(draft: OptimisticStore, input: OptimisticAddInput) {
+ const messages = draft.message[input.sessionID]
+ if (!messages) {
+ draft.message[input.sessionID] = [input.message]
+ }
+ if (messages) {
+ const result = Binary.search(messages, input.message.id, (m) => m.id)
+ messages.splice(result.index, 0, input.message)
+ }
+ draft.part[input.message.id] = input.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
+}
+
+export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticRemoveInput) {
+ const messages = draft.message[input.sessionID]
+ if (messages) {
+ const result = Binary.search(messages, input.messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[input.messageID]
+}
+
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@@ -21,6 +58,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
type Setter = Child[1]
const current = createMemo(() => globalSync.child(sdk.directory))
+ const target = (directory?: string) => {
+ if (!directory || directory === sdk.directory) return current()
+ return globalSync.child(directory)
+ }
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const chunk = 400
const inflight = new Map<string, Promise<void>>()
@@ -107,6 +148,24 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
session: {
get: getSession,
+ optimistic: {
+ add(input: { directory?: string; sessionID: string; message: Message; parts: Part[] }) {
+ const [, setStore] = target(input.directory)
+ setStore(
+ produce((draft) => {
+ applyOptimisticAdd(draft as OptimisticStore, input)
+ }),
+ )
+ },
+ remove(input: { directory?: string; sessionID: string; messageID: string }) {
+ const [, setStore] = target(input.directory)
+ setStore(
+ produce((draft) => {
+ applyOptimisticRemove(draft as OptimisticStore, input)
+ }),
+ )
+ },
+ },
addOptimisticMessage(input: {
sessionID: string
messageID: string
@@ -122,16 +181,14 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
agent: input.agent,
model: input.model,
}
- current()[1](
+ const [, setStore] = target()
+ setStore(
produce((draft) => {
- const messages = draft.message[input.sessionID]
- if (!messages) {
- draft.message[input.sessionID] = [message]
- } else {
- const result = Binary.search(messages, input.messageID, (m) => m.id)
- messages.splice(result.index, 0, message)
- }
- draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
+ applyOptimisticAdd(draft as OptimisticStore, {
+ sessionID: input.sessionID,
+ message,
+ parts: input.parts,
+ })
}),
)
},