Files
tf_code/packages/app/src/context/file.tsx

268 lines
7.3 KiB
TypeScript

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<string, Promise<void>>()
const [store, setStore] = createStore<{
file: Record<string, FileState>
}>({
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<string>) => {
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"),
}
},
})