chore: refactoring and tests, splitting up files (#12495)

This commit is contained in:
Adam
2026-02-06 10:02:31 -06:00
committed by GitHub
parent a4bc883595
commit 2c58dd6203
117 changed files with 9457 additions and 5827 deletions

View 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)
}

View 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")
})
})

View 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,
}
}

View 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,
}
}

View 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,
}
}

View 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(),
}
}

View 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([])
})
})

View 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)
}