summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context/file
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 10:02:31 -0600
committerGitHub <[email protected]>2026-02-06 10:02:31 -0600
commit2c58dd6203df7806f57ef6b29672091cb764e871 (patch)
tree10fca96d3098465b497f78e29de8d0a585c4dac3 /packages/app/src/context/file
parenta4bc883595df9ea0f752079519081bc602408553 (diff)
downloadopencode-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.ts88
-rw-r--r--packages/app/src/context/file/path.test.ts27
-rw-r--r--packages/app/src/context/file/path.ts119
-rw-r--r--packages/app/src/context/file/tree-store.ts170
-rw-r--r--packages/app/src/context/file/types.ts41
-rw-r--r--packages/app/src/context/file/view-cache.ts136
-rw-r--r--packages/app/src/context/file/watcher.test.ts118
-rw-r--r--packages/app/src/context/file/watcher.ts52
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)
+}