mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-04 16:13:11 +00:00
268 lines
7.3 KiB
TypeScript
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"),
|
|
}
|
|
},
|
|
})
|