diff options
| author | Adam <[email protected]> | 2026-02-06 10:02:31 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-06 10:02:31 -0600 |
| commit | 2c58dd6203df7806f57ef6b29672091cb764e871 (patch) | |
| tree | 10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/context/file | |
| parent | a4bc883595df9ea0f752079519081bc602408553 (diff) | |
| download | opencode-2c58dd6203df7806f57ef6b29672091cb764e871.tar.gz opencode-2c58dd6203df7806f57ef6b29672091cb764e871.zip | |
chore: refactoring and tests, splitting up files (#12495)
Diffstat (limited to 'packages/app/src/context/file')
| -rw-r--r-- | packages/app/src/context/file/content-cache.ts | 88 | ||||
| -rw-r--r-- | packages/app/src/context/file/path.test.ts | 27 | ||||
| -rw-r--r-- | packages/app/src/context/file/path.ts | 119 | ||||
| -rw-r--r-- | packages/app/src/context/file/tree-store.ts | 170 | ||||
| -rw-r--r-- | packages/app/src/context/file/types.ts | 41 | ||||
| -rw-r--r-- | packages/app/src/context/file/view-cache.ts | 136 | ||||
| -rw-r--r-- | packages/app/src/context/file/watcher.test.ts | 118 | ||||
| -rw-r--r-- | packages/app/src/context/file/watcher.ts | 52 |
8 files changed, 751 insertions, 0 deletions
diff --git a/packages/app/src/context/file/content-cache.ts b/packages/app/src/context/file/content-cache.ts new file mode 100644 index 000000000..4b7240688 --- /dev/null +++ b/packages/app/src/context/file/content-cache.ts @@ -0,0 +1,88 @@ +import type { FileContent } from "@opencode-ai/sdk/v2" + +const MAX_FILE_CONTENT_ENTRIES = 40 +const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024 + +const lru = new Map<string, number>() +let total = 0 + +export function approxBytes(content: FileContent) { + const patchBytes = + content.patch?.hunks.reduce((sum, hunk) => { + return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0) + }, 0) ?? 0 + + return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2 +} + +function setBytes(path: string, nextBytes: number) { + const prev = lru.get(path) + if (prev !== undefined) total -= prev + lru.delete(path) + lru.set(path, nextBytes) + total += nextBytes +} + +function touch(path: string, bytes?: number) { + const prev = lru.get(path) + if (prev === undefined && bytes === undefined) return + setBytes(path, bytes ?? prev ?? 0) +} + +function remove(path: string) { + const prev = lru.get(path) + if (prev === undefined) return + lru.delete(path) + total -= prev +} + +function reset() { + lru.clear() + total = 0 +} + +export function evictContentLru(keep: Set<string> | undefined, evict: (path: string) => void) { + const set = keep ?? new Set<string>() + + while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) { + const path = lru.keys().next().value + if (!path) return + + if (set.has(path)) { + touch(path) + if (lru.size <= set.size) return + continue + } + + remove(path) + evict(path) + } +} + +export function resetFileContentLru() { + reset() +} + +export function setFileContentBytes(path: string, bytes: number) { + setBytes(path, bytes) +} + +export function removeFileContentBytes(path: string) { + remove(path) +} + +export function touchFileContent(path: string, bytes?: number) { + touch(path, bytes) +} + +export function getFileContentBytesTotal() { + return total +} + +export function getFileContentEntryCount() { + return lru.size +} + +export function hasFileContent(path: string) { + return lru.has(path) +} diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts new file mode 100644 index 000000000..dba9ae06d --- /dev/null +++ b/packages/app/src/context/file/path.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path" + +describe("file path helpers", () => { + test("normalizes file inputs against workspace root", () => { + const path = createPathHelpers(() => "/repo") + expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts") + expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts") + expect(path.normalize("./src/app.ts")).toBe("src/app.ts") + expect(path.normalizeDir("src/components///")).toBe("src/components") + expect(path.tab("src/app.ts")).toBe("file://src/app.ts") + expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts") + expect(path.pathFromTab("other://src/app.ts")).toBeUndefined() + }) + + test("keeps query/hash stripping behavior stable", () => { + expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") + expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts") + }) + + test("unquotes git escaped octal path strings", () => { + expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt") + expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname") + expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts") + }) +}) diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts new file mode 100644 index 000000000..ced30d0fd --- /dev/null +++ b/packages/app/src/context/file/path.ts @@ -0,0 +1,119 @@ +export function stripFileProtocol(input: string) { + if (!input.startsWith("file://")) return input + return input.slice("file://".length) +} + +export function stripQueryAndHash(input: string) { + const hashIndex = input.indexOf("#") + const queryIndex = input.indexOf("?") + + if (hashIndex !== -1 && queryIndex !== -1) { + return input.slice(0, Math.min(hashIndex, queryIndex)) + } + + if (hashIndex !== -1) return input.slice(0, hashIndex) + if (queryIndex !== -1) return input.slice(0, queryIndex) + return input +} + +export function unquoteGitPath(input: string) { + if (!input.startsWith('"')) return input + if (!input.endsWith('"')) return input + const body = input.slice(1, -1) + const bytes: number[] = [] + + for (let i = 0; i < body.length; i++) { + const char = body[i]! + if (char !== "\\") { + bytes.push(char.charCodeAt(0)) + continue + } + + const next = body[i + 1] + if (!next) { + bytes.push("\\".charCodeAt(0)) + continue + } + + if (next >= "0" && next <= "7") { + const chunk = body.slice(i + 1, i + 4) + const match = chunk.match(/^[0-7]{1,3}/) + if (!match) { + bytes.push(next.charCodeAt(0)) + i++ + continue + } + bytes.push(parseInt(match[0], 8)) + i += match[0].length + continue + } + + const escaped = + next === "n" + ? "\n" + : next === "r" + ? "\r" + : next === "t" + ? "\t" + : next === "b" + ? "\b" + : next === "f" + ? "\f" + : next === "v" + ? "\v" + : next === "\\" || next === '"' + ? next + : undefined + + bytes.push((escaped ?? next).charCodeAt(0)) + i++ + } + + return new TextDecoder().decode(new Uint8Array(bytes)) +} + +export function createPathHelpers(scope: () => string) { + const normalize = (input: string) => { + const root = scope() + const prefix = root.endsWith("/") ? root : root + "/" + + let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input))) + + if (path.startsWith(prefix)) { + path = path.slice(prefix.length) + } + + if (path.startsWith(root)) { + path = path.slice(root.length) + } + + if (path.startsWith("./")) { + path = path.slice(2) + } + + if (path.startsWith("/")) { + path = path.slice(1) + } + + return path + } + + const tab = (input: string) => { + const path = normalize(input) + return `file://${path}` + } + + const pathFromTab = (tabValue: string) => { + if (!tabValue.startsWith("file://")) return + return normalize(tabValue) + } + + const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "") + + return { + normalize, + tab, + pathFromTab, + normalizeDir, + } +} diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts new file mode 100644 index 000000000..a86051d28 --- /dev/null +++ b/packages/app/src/context/file/tree-store.ts @@ -0,0 +1,170 @@ +import { createStore, produce, reconcile } from "solid-js/store" +import type { FileNode } from "@opencode-ai/sdk/v2" + +type DirectoryState = { + expanded: boolean + loaded?: boolean + loading?: boolean + error?: string + children?: string[] +} + +type TreeStoreOptions = { + scope: () => string + normalizeDir: (input: string) => string + list: (input: string) => Promise<FileNode[]> + onError: (message: string) => void +} + +export function createFileTreeStore(options: TreeStoreOptions) { + const [tree, setTree] = createStore<{ + node: Record<string, FileNode> + dir: Record<string, DirectoryState> + }>({ + node: {}, + dir: { "": { expanded: true } }, + }) + + const inflight = new Map<string, Promise<void>>() + + const reset = () => { + inflight.clear() + setTree("node", reconcile({})) + setTree("dir", reconcile({})) + setTree("dir", "", { expanded: true }) + } + + const ensureDir = (path: string) => { + if (tree.dir[path]) return + setTree("dir", path, { expanded: false }) + } + + const listDir = (input: string, opts?: { force?: boolean }) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + + const current = tree.dir[dir] + if (!opts?.force && current?.loaded) return Promise.resolve() + + const pending = inflight.get(dir) + if (pending) return pending + + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = true + draft.error = undefined + }), + ) + + const directory = options.scope() + + const promise = options + .list(dir) + .then((nodes) => { + if (options.scope() !== directory) return + const prevChildren = tree.dir[dir]?.children ?? [] + const nextChildren = nodes.map((node) => node.path) + const nextSet = new Set(nextChildren) + + setTree( + "node", + produce((draft) => { + const removedDirs: string[] = [] + + for (const child of prevChildren) { + if (nextSet.has(child)) continue + const existing = draft[child] + if (existing?.type === "directory") removedDirs.push(child) + delete draft[child] + } + + if (removedDirs.length > 0) { + const keys = Object.keys(draft) + for (const key of keys) { + for (const removed of removedDirs) { + if (!key.startsWith(removed + "/")) continue + delete draft[key] + break + } + } + } + + for (const node of nodes) { + draft[node.path] = node + } + }), + ) + + setTree( + "dir", + dir, + produce((draft) => { + draft.loaded = true + draft.loading = false + draft.children = nextChildren + }), + ) + }) + .catch((e) => { + if (options.scope() !== directory) return + setTree( + "dir", + dir, + produce((draft) => { + draft.loading = false + draft.error = e.message + }), + ) + options.onError(e.message) + }) + .finally(() => { + inflight.delete(dir) + }) + + inflight.set(dir, promise) + return promise + } + + const expandDir = (input: string) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", true) + void listDir(dir) + } + + const collapseDir = (input: string) => { + const dir = options.normalizeDir(input) + ensureDir(dir) + setTree("dir", dir, "expanded", false) + } + + const dirState = (input: string) => { + const dir = options.normalizeDir(input) + return tree.dir[dir] + } + + const children = (input: string) => { + const dir = options.normalizeDir(input) + const ids = tree.dir[dir]?.children + if (!ids) return [] + const out: FileNode[] = [] + for (const id of ids) { + const node = tree.node[id] + if (node) out.push(node) + } + return out + } + + return { + listDir, + expandDir, + collapseDir, + dirState, + children, + node: (path: string) => tree.node[path], + isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded), + reset, + } +} diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts new file mode 100644 index 000000000..7ce8a37c2 --- /dev/null +++ b/packages/app/src/context/file/types.ts @@ -0,0 +1,41 @@ +import type { FileContent } from "@opencode-ai/sdk/v2" + +export type FileSelection = { + startLine: number + startChar: number + endLine: number + endChar: number +} + +export type SelectedLineRange = { + start: number + end: number + side?: "additions" | "deletions" + endSide?: "additions" | "deletions" +} + +export type FileViewState = { + scrollTop?: number + scrollLeft?: number + selectedLines?: SelectedLineRange | null +} + +export type FileState = { + path: string + name: string + loaded?: boolean + loading?: boolean + error?: string + content?: FileContent +} + +export function selectionFromLines(range: SelectedLineRange): FileSelection { + const startLine = Math.min(range.start, range.end) + const endLine = Math.max(range.start, range.end) + return { + startLine, + endLine, + startChar: 0, + endChar: 0, + } +} diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts new file mode 100644 index 000000000..2614b2fb5 --- /dev/null +++ b/packages/app/src/context/file/view-cache.ts @@ -0,0 +1,136 @@ +import { createEffect, createRoot } from "solid-js" +import { createStore, produce } from "solid-js/store" +import { Persist, persisted } from "@/utils/persist" +import { createScopedCache } from "@/utils/scoped-cache" +import type { FileViewState, SelectedLineRange } from "./types" + +const WORKSPACE_KEY = "__workspace__" +const MAX_FILE_VIEW_SESSIONS = 20 +const MAX_VIEW_FILES = 500 + +function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange { + if (range.start <= range.end) return range + + const startSide = range.side + const endSide = range.endSide ?? startSide + + return { + ...range, + start: range.end, + end: range.start, + side: endSide, + endSide: startSide !== endSide ? startSide : undefined, + } +} + +function createViewSession(dir: string, id: string | undefined) { + const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1` + + const [view, setView, _, ready] = persisted( + Persist.scoped(dir, id, "file-view", [legacyViewKey]), + createStore<{ + file: Record<string, FileViewState> + }>({ + file: {}, + }), + ) + + const meta = { pruned: false } + + const pruneView = (keep?: string) => { + const keys = Object.keys(view.file) + if (keys.length <= MAX_VIEW_FILES) return + + const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES) + if (drop.length === 0) return + + setView( + produce((draft) => { + for (const key of drop) { + delete draft.file[key] + } + }), + ) + } + + createEffect(() => { + if (!ready()) return + if (meta.pruned) return + meta.pruned = true + pruneView() + }) + + const scrollTop = (path: string) => view.file[path]?.scrollTop + const scrollLeft = (path: string) => view.file[path]?.scrollLeft + const selectedLines = (path: string) => view.file[path]?.selectedLines + + const setScrollTop = (path: string, top: number) => { + setView("file", path, (current) => { + if (current?.scrollTop === top) return current + return { + ...(current ?? {}), + scrollTop: top, + } + }) + pruneView(path) + } + + const setScrollLeft = (path: string, left: number) => { + setView("file", path, (current) => { + if (current?.scrollLeft === left) return current + return { + ...(current ?? {}), + scrollLeft: left, + } + }) + pruneView(path) + } + + const setSelectedLines = (path: string, range: SelectedLineRange | null) => { + const next = range ? normalizeSelectedLines(range) : null + setView("file", path, (current) => { + if (current?.selectedLines === next) return current + return { + ...(current ?? {}), + selectedLines: next, + } + }) + pruneView(path) + } + + return { + ready, + scrollTop, + scrollLeft, + selectedLines, + setScrollTop, + setScrollLeft, + setSelectedLines, + } +} + +export function createFileViewCache() { + const cache = createScopedCache( + (key) => { + const split = key.lastIndexOf("\n") + const dir = split >= 0 ? key.slice(0, split) : key + const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY + return createRoot((dispose) => ({ + value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id), + dispose, + })) + }, + { + maxEntries: MAX_FILE_VIEW_SESSIONS, + dispose: (entry) => entry.dispose(), + }, + ) + + return { + load: (dir: string, id: string | undefined) => { + const key = `${dir}\n${id ?? WORKSPACE_KEY}` + return cache.get(key).value + }, + clear: () => cache.clear(), + } +} diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts new file mode 100644 index 000000000..653e0aa75 --- /dev/null +++ b/packages/app/src/context/file/watcher.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test" +import { invalidateFromWatcher } from "./watcher" + +describe("file watcher invalidation", () => { + test("reloads open files and refreshes loaded parent on add", () => { + const loads: string[] = [] + const refresh: string[] = [] + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/new.ts", + event: "add", + }, + }, + { + normalize: (input) => input, + hasFile: (path) => path === "src/new.ts", + loadFile: (path) => loads.push(path), + node: () => undefined, + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(loads).toEqual(["src/new.ts"]) + expect(refresh).toEqual(["src"]) + }) + + test("refreshes only changed loaded directory nodes", () => { + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }), + isDirLoaded: (path) => path === "src", + refreshDir: (path) => refresh.push(path), + }, + ) + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/file.ts", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => ({ + path: "src/file.ts", + type: "file", + name: "file.ts", + absolute: "/repo/src/file.ts", + ignored: false, + }), + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(refresh).toEqual(["src"]) + }) + + test("ignores invalid or git watcher updates", () => { + const refresh: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: ".git/index.lock", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => true, + loadFile: () => { + throw new Error("should not load") + }, + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + invalidateFromWatcher( + { + type: "project.updated", + properties: {}, + }, + { + normalize: (input) => input, + hasFile: () => false, + loadFile: () => {}, + node: () => undefined, + isDirLoaded: () => true, + refreshDir: (path) => refresh.push(path), + }, + ) + + expect(refresh).toEqual([]) + }) +}) diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts new file mode 100644 index 000000000..a3a98eae4 --- /dev/null +++ b/packages/app/src/context/file/watcher.ts @@ -0,0 +1,52 @@ +import type { FileNode } from "@opencode-ai/sdk/v2" + +type WatcherEvent = { + type: string + properties: unknown +} + +type WatcherOps = { + normalize: (input: string) => string + hasFile: (path: string) => boolean + loadFile: (path: string) => void + node: (path: string) => FileNode | undefined + isDirLoaded: (path: string) => boolean + refreshDir: (path: string) => void +} + +export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { + if (event.type !== "file.watcher.updated") return + const props = + typeof event.properties === "object" && event.properties ? (event.properties as Record<string, unknown>) : undefined + const rawPath = typeof props?.file === "string" ? props.file : undefined + const kind = typeof props?.event === "string" ? props.event : undefined + if (!rawPath) return + if (!kind) return + + const path = ops.normalize(rawPath) + if (!path) return + if (path.startsWith(".git/")) return + + if (ops.hasFile(path)) { + ops.loadFile(path) + } + + if (kind === "change") { + const dir = (() => { + if (path === "") return "" + const node = ops.node(path) + if (node?.type !== "directory") return + return path + })() + if (dir === undefined) return + if (!ops.isDirLoaded(dir)) return + ops.refreshDir(dir) + return + } + if (kind !== "add" && kind !== "unlink") return + + const parent = path.split("/").slice(0, -1).join("/") + if (!ops.isDirLoaded(parent)) return + + ops.refreshDir(parent) +} |
