diff options
| author | Adam <[email protected]> | 2026-02-06 10:02:31 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 10:02:31 -0600 |
| commit | 2c58dd6203df7806f57ef6b29672091cb764e871 (patch) | |
| tree | 10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/context | |
| parent | a4bc883595df9ea0f752079519081bc602408553 (diff) | |
| download | opencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip | |
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/context')
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, + }) }), ) }, |
