import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" 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 { useLayout } from "@/context/layout" import { createPathHelpers } from "./file/path" import { approxBytes, evictContentLru, getFileContentBytesTotal, getFileContentEntryCount, hasFileContent, removeFileContentBytes, resetFileContentLru, setFileContentBytes, touchFileContent, } from "./file/content-cache" import { createFileViewCache } from "./file/view-cache" import { createFileTreeStore } from "./file/tree-store" import { invalidateFromWatcher } from "./file/watcher" import { selectionFromLines, type FileState, type FileSelection, type FileViewState, type SelectedLineRange, } from "./file/types" export type { FileSelection, SelectedLineRange, FileViewState, FileState } export { selectionFromLines } export { evictContentLru, getFileContentBytesTotal, getFileContentEntryCount, removeFileContentBytes, resetFileContentLru, setFileContentBytes, touchFileContent, } export const { use: useFile, provider: FileProvider } = createSimpleContext({ name: "File", gate: false, init: () => { const sdk = useSDK() useSync() const params = useParams() const language = useLanguage() const layout = useLayout() const scope = createMemo(() => sdk.directory) const path = createPathHelpers(scope) const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const inflight = new Map>() const [store, setStore] = createStore<{ file: Record }>({ file: {}, }) 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) => { evictContentLru(keep, (target) => { if (!store.file[target]) return setStore( "file", target, produce((draft) => { draft.content = undefined draft.loaded = false }), ) }) } createEffect(() => { scope() inflight.clear() resetFileContentLru() batch(() => { setStore("file", reconcile({})) tree.reset() }) }) const viewCache = createFileViewCache() const view = createMemo(() => viewCache.load(scope(), params.id)) const ensure = (file: string) => { if (!file) return if (store.file[file]) return setStore("file", file, { path: file, name: getFilename(file) }) } const load = (input: string, options?: { force?: boolean }) => { const file = path.normalize(input) if (!file) return Promise.resolve() const directory = scope() const key = `${directory}\n${file}` ensure(file) const current = store.file[file] if (!options?.force && current?.loaded) return Promise.resolve() const pending = inflight.get(key) if (pending) return pending setStore( "file", file, produce((draft) => { draft.loading = true draft.error = undefined }), ) const promise = sdk.client.file .read({ path: file }) .then((x) => { if (scope() !== directory) return const content = x.data setStore( "file", file, produce((draft) => { draft.loaded = true draft.loading = false draft.content = content }), ) if (!content) return touchFileContent(file, approxBytes(content)) evictContent(new Set([file])) }) .catch((e) => { if (scope() !== directory) return setStore( "file", file, produce((draft) => { draft.loading = false draft.error = e.message }), ) showToast({ variant: "error", title: language.t("toast.file.loadFailed.title"), description: e.message, }) }) .finally(() => { inflight.delete(key) }) inflight.set(key, promise) return promise } const search = (query: string, dirs: "true" | "false") => sdk.client.find.files({ query, dirs }).then( (x) => (x.data ?? []).map(path.normalize), () => [], ) const stop = sdk.event.listen((e) => { invalidateFromWatcher(e.details, { normalize: path.normalize, hasFile: (file) => Boolean(store.file[file]), isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === 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 file = path.normalize(input) const state = store.file[file] const content = state?.content if (!content) return state if (hasFileContent(file)) { touchFileContent(file) return state } touchFileContent(file, approxBytes(content)) return state } 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) => { view().setScrollTop(path.normalize(input), top) } const setScrollLeft = (input: string, left: number) => { view().setScrollLeft(path.normalize(input), left) } const setSelectedLines = (input: string, range: SelectedLineRange | null) => { view().setSelectedLines(path.normalize(input), range) } onCleanup(() => { stop() viewCache.clear() }) return { ready: () => view().ready(), normalize: path.normalize, tab: path.tab, pathFromTab: path.pathFromTab, tree: { 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 (tree.dirState(input)?.expanded) { tree.collapseDir(input) return } tree.expandDir(input) }, }, get, load, scrollTop, scrollLeft, setScrollTop, setScrollLeft, selectedLines, setSelectedLines, searchFiles: (query: string) => search(query, "false"), searchFilesAndDirectories: (query: string) => search(query, "true"), } }, })