chore: refactoring and tests, splitting up files (#12495)
This commit is contained in:
43
packages/app/src/context/command-keybind.test.ts
Normal file
43
packages/app/src/context/command-keybind.test.ts
Normal file
@@ -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("")
|
||||
})
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"
|
||||
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 = {
|
||||
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,
|
||||
}
|
||||
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 viewCache = createFileViewCache()
|
||||
const view = createMemo(() => viewCache.load(scope(), params.id))
|
||||
|
||||
const loadView = (dir: string, id: string | undefined) => {
|
||||
const key = `${dir}\n${id ?? WORKSPACE_KEY}`
|
||||
return viewCache.get(key).value
|
||||
const ensure = (file: string) => {
|
||||
if (!file) return
|
||||
if (store.file[file]) return
|
||||
setStore("file", file, { path: file, name: getFilename(file) })
|
||||
}
|
||||
|
||||
const view = createMemo(() => loadView(scope(), params.id))
|
||||
|
||||
function ensure(path: string) {
|
||||
if (!path) return
|
||||
if (store.file[path]) return
|
||||
setStore("file", path, { path, name: getFilename(path) })
|
||||
}
|
||||
|
||||
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
|
||||
const key = `${directory}\n${file}`
|
||||
ensure(file)
|
||||
|
||||
ensure(path)
|
||||
|
||||
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,
|
||||
|
||||
88
packages/app/src/context/file/content-cache.ts
Normal file
88
packages/app/src/context/file/content-cache.ts
Normal file
@@ -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)
|
||||
}
|
||||
27
packages/app/src/context/file/path.test.ts
Normal file
27
packages/app/src/context/file/path.test.ts
Normal file
@@ -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")
|
||||
})
|
||||
})
|
||||
119
packages/app/src/context/file/path.ts
Normal file
119
packages/app/src/context/file/path.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
170
packages/app/src/context/file/tree-store.ts
Normal file
170
packages/app/src/context/file/tree-store.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
41
packages/app/src/context/file/types.ts
Normal file
41
packages/app/src/context/file/types.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
136
packages/app/src/context/file/view-cache.ts
Normal file
136
packages/app/src/context/file/view-cache.ts
Normal file
@@ -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(),
|
||||
}
|
||||
}
|
||||
118
packages/app/src/context/file/watcher.test.ts
Normal file
118
packages/app/src/context/file/watcher.test.ts
Normal file
@@ -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([])
|
||||
})
|
||||
})
|
||||
52
packages/app/src/context/file/watcher.ts
Normal file
52
packages/app/src/context/file/watcher.ts
Normal file
@@ -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)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
195
packages/app/src/context/global-sync/bootstrap.ts
Normal file
195
packages/app/src/context/global-sync/bootstrap.ts
Normal file
@@ -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")
|
||||
})
|
||||
}
|
||||
263
packages/app/src/context/global-sync/child-store.ts
Normal file
263
packages/app/src/context/global-sync/child-store.ts
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
201
packages/app/src/context/global-sync/event-reducer.test.ts
Normal file
201
packages/app/src/context/global-sync/event-reducer.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
319
packages/app/src/context/global-sync/event-reducer.ts
Normal file
319
packages/app/src/context/global-sync/event-reducer.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
28
packages/app/src/context/global-sync/eviction.ts
Normal file
28
packages/app/src/context/global-sync/eviction.ts
Normal file
@@ -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
|
||||
}
|
||||
83
packages/app/src/context/global-sync/queue.ts
Normal file
83
packages/app/src/context/global-sync/queue.ts
Normal file
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
26
packages/app/src/context/global-sync/session-load.ts
Normal file
26
packages/app/src/context/global-sync/session-load.ts
Normal file
@@ -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
|
||||
}
|
||||
59
packages/app/src/context/global-sync/session-trim.test.ts
Normal file
59
packages/app/src/context/global-sync/session-trim.test.ts
Normal file
@@ -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",
|
||||
])
|
||||
})
|
||||
})
|
||||
56
packages/app/src/context/global-sync/session-trim.ts
Normal file
56
packages/app/src/context/global-sync/session-trim.ts
Normal file
@@ -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))
|
||||
}
|
||||
134
packages/app/src/context/global-sync/types.ts
Normal file
134
packages/app/src/context/global-sync/types.ts
Normal file
@@ -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
|
||||
25
packages/app/src/context/global-sync/utils.ts
Normal file
25
packages/app/src/context/global-sync/utils.ts
Normal file
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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: 100 }, (_, n) => n)) {
|
||||
scroll.setScroll("session", "review", { x: 0, y: i })
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 120))
|
||||
|
||||
expect(stats.flushes).toBeGreaterThanOrEqual(1)
|
||||
expect(writes.length).toBeGreaterThanOrEqual(1)
|
||||
expect(writes.length).toBeLessThanOrEqual(2)
|
||||
}
|
||||
|
||||
void run()
|
||||
.then(resolve)
|
||||
.catch(reject)
|
||||
.finally(() => {
|
||||
scroll.dispose()
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) {
|
||||
scroll.setScroll("session", "review", { x: 0, y: i })
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40))
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(writes[0]?.review).toEqual({ x: 0, y: 30 })
|
||||
|
||||
scroll.setScroll("session", "review", { x: 0, y: 30 })
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
|
||||
expect(writes).toHaveLength(1)
|
||||
scroll.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
56
packages/app/src/context/sync-optimistic.test.ts
Normal file
56
packages/app/src/context/sync-optimistic.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user