Add depth-limited filesystem browsing
This commit is contained in:
@@ -1,33 +1,198 @@
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
|
||||
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup, onMount } from "solid-js"
|
||||
import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid"
|
||||
import type { FileSystemEntry } from "../../../cli/src/api-types"
|
||||
import { cliApi } from "../lib/api-client"
|
||||
import { getServerMeta } from "../lib/server-meta"
|
||||
|
||||
const MAX_RESULTS = 200
|
||||
const DEFAULT_DEPTH = 2
|
||||
|
||||
let cachedEntries: FileSystemEntry[] | null = null
|
||||
let entriesPromise: Promise<FileSystemEntry[]> | null = null
|
||||
type CacheListener = (entries: FileSystemEntry[]) => void
|
||||
|
||||
async function loadFileSystemEntries(): Promise<FileSystemEntry[]> {
|
||||
if (cachedEntries) {
|
||||
return cachedEntries
|
||||
interface FileSystemCacheState {
|
||||
entriesMap: Map<string, FileSystemEntry>
|
||||
entriesList: FileSystemEntry[]
|
||||
loadedDirectories: Set<string>
|
||||
loadingPromises: Map<string, Promise<void>>
|
||||
pendingDirectories: string[]
|
||||
listeners: Set<CacheListener>
|
||||
queueActive: boolean
|
||||
}
|
||||
|
||||
const fileSystemCache: FileSystemCacheState = {
|
||||
entriesMap: new Map(),
|
||||
entriesList: [],
|
||||
loadedDirectories: new Set(),
|
||||
loadingPromises: new Map(),
|
||||
pendingDirectories: [],
|
||||
listeners: new Set(),
|
||||
queueActive: false,
|
||||
}
|
||||
|
||||
let cacheWorkspaceRoot: string | null = null
|
||||
|
||||
function normalizeEntryPath(path: string): string {
|
||||
if (!path || path === ".") {
|
||||
return "."
|
||||
}
|
||||
if (entriesPromise) {
|
||||
return entriesPromise
|
||||
const cleaned = path.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/\/+/g, "/")
|
||||
return cleaned || "."
|
||||
}
|
||||
|
||||
function updateCache(entries: FileSystemEntry[]): boolean {
|
||||
let changed = false
|
||||
for (const entry of entries) {
|
||||
const normalizedPath = normalizeEntryPath(entry.path)
|
||||
const normalizedEntry = normalizedPath === entry.path ? entry : { ...entry, path: normalizedPath }
|
||||
const existing = fileSystemCache.entriesMap.get(normalizedPath)
|
||||
|
||||
if (
|
||||
!existing ||
|
||||
existing.name !== normalizedEntry.name ||
|
||||
existing.type !== normalizedEntry.type ||
|
||||
existing.size !== normalizedEntry.size ||
|
||||
existing.modifiedAt !== normalizedEntry.modifiedAt
|
||||
) {
|
||||
fileSystemCache.entriesMap.set(normalizedPath, normalizedEntry)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
entriesPromise = cliApi
|
||||
.listFileSystem(".")
|
||||
|
||||
if (changed) {
|
||||
fileSystemCache.entriesList = Array.from(fileSystemCache.entriesMap.values()).sort((a, b) =>
|
||||
a.path.localeCompare(b.path),
|
||||
)
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
function notifyCacheListeners() {
|
||||
for (const listener of fileSystemCache.listeners) {
|
||||
listener(fileSystemCache.entriesList)
|
||||
}
|
||||
}
|
||||
|
||||
function subscribeToCache(listener: CacheListener) {
|
||||
fileSystemCache.listeners.add(listener)
|
||||
listener(fileSystemCache.entriesList)
|
||||
return () => fileSystemCache.listeners.delete(listener)
|
||||
}
|
||||
|
||||
function resetFileSystemCache() {
|
||||
fileSystemCache.entriesMap.clear()
|
||||
fileSystemCache.entriesList = []
|
||||
fileSystemCache.loadedDirectories.clear()
|
||||
fileSystemCache.loadingPromises.clear()
|
||||
fileSystemCache.pendingDirectories = []
|
||||
fileSystemCache.queueActive = false
|
||||
notifyCacheListeners()
|
||||
}
|
||||
|
||||
function enqueueDirectory(path: string, priority = false) {
|
||||
const normalized = normalizeEntryPath(path)
|
||||
if (normalized === "." || fileSystemCache.loadedDirectories.has(normalized) || fileSystemCache.loadingPromises.has(normalized)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingIndex = fileSystemCache.pendingDirectories.indexOf(normalized)
|
||||
if (existingIndex !== -1) {
|
||||
if (priority) {
|
||||
fileSystemCache.pendingDirectories.splice(existingIndex, 1)
|
||||
fileSystemCache.pendingDirectories.unshift(normalized)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
fileSystemCache.pendingDirectories.unshift(normalized)
|
||||
} else {
|
||||
fileSystemCache.pendingDirectories.push(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDirectory(path: string): Promise<void> {
|
||||
const normalized = normalizeEntryPath(path)
|
||||
if (fileSystemCache.loadedDirectories.has(normalized)) {
|
||||
return
|
||||
}
|
||||
|
||||
const existing = fileSystemCache.loadingPromises.get(normalized)
|
||||
if (existing) {
|
||||
await existing
|
||||
return
|
||||
}
|
||||
|
||||
const promise = cliApi
|
||||
.listFileSystem(normalized === "." ? "." : normalized, { depth: DEFAULT_DEPTH })
|
||||
.then((entries) => {
|
||||
cachedEntries = entries.slice().sort((a, b) => a.path.localeCompare(b.path))
|
||||
entriesPromise = null
|
||||
return cachedEntries
|
||||
const changed = updateCache(entries)
|
||||
fileSystemCache.loadedDirectories.add(normalized)
|
||||
for (const entry of entries) {
|
||||
if (entry.type === "directory") {
|
||||
enqueueDirectory(entry.path)
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
notifyCacheListeners()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
entriesPromise = null
|
||||
throw error
|
||||
.finally(() => {
|
||||
fileSystemCache.loadingPromises.delete(normalized)
|
||||
})
|
||||
return entriesPromise
|
||||
|
||||
fileSystemCache.loadingPromises.set(normalized, promise)
|
||||
await promise
|
||||
}
|
||||
|
||||
async function processDirectoryQueue() {
|
||||
if (fileSystemCache.queueActive) {
|
||||
return
|
||||
}
|
||||
fileSystemCache.queueActive = true
|
||||
try {
|
||||
while (fileSystemCache.pendingDirectories.length > 0) {
|
||||
const next = fileSystemCache.pendingDirectories.shift()
|
||||
if (!next) continue
|
||||
try {
|
||||
await loadDirectory(next)
|
||||
} catch (error) {
|
||||
console.warn("Failed to load directory", next, error)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
fileSystemCache.queueActive = false
|
||||
}
|
||||
}
|
||||
|
||||
function startBackgroundLoading() {
|
||||
void processDirectoryQueue()
|
||||
}
|
||||
|
||||
function prioritizeDirectoriesForQuery(query: string) {
|
||||
const normalized = query.replace(/\\/g, "/").trim()
|
||||
if (!normalized) {
|
||||
return
|
||||
}
|
||||
const segments = normalized.split("/").filter(Boolean)
|
||||
let prefix = ""
|
||||
for (const segment of segments) {
|
||||
prefix = prefix ? `${prefix}/${segment}` : segment
|
||||
enqueueDirectory(prefix, true)
|
||||
}
|
||||
startBackgroundLoading()
|
||||
}
|
||||
|
||||
async function ensureWorkspaceFilesystemLoaded(workspaceRoot: string) {
|
||||
if (cacheWorkspaceRoot && cacheWorkspaceRoot !== workspaceRoot) {
|
||||
cacheWorkspaceRoot = workspaceRoot
|
||||
resetFileSystemCache()
|
||||
} else if (!cacheWorkspaceRoot) {
|
||||
cacheWorkspaceRoot = workspaceRoot
|
||||
}
|
||||
|
||||
await loadDirectory(".")
|
||||
startBackgroundLoading()
|
||||
}
|
||||
|
||||
function resolveAbsolutePath(root: string, relativePath: string): string {
|
||||
@@ -68,13 +233,26 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
||||
|
||||
let searchInputRef: HTMLInputElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = subscribeToCache((items) => setEntries(items))
|
||||
onCleanup(unsubscribe)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const query = searchQuery().trim()
|
||||
if (!query) {
|
||||
return
|
||||
}
|
||||
prioritizeDirectoriesForQuery(query)
|
||||
})
|
||||
|
||||
async function refreshEntries() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [items, meta] = await Promise.all([loadFileSystemEntries(), getServerMeta()])
|
||||
setEntries(items)
|
||||
const meta = await getServerMeta()
|
||||
setRootPath(meta.workspaceRoot)
|
||||
await ensureWorkspaceFilesystemLoaded(meta.workspaceRoot)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||
setError(message)
|
||||
|
||||
@@ -17,7 +17,8 @@ import type {
|
||||
WorkspaceEventType,
|
||||
} from "../../../cli/src/api-types"
|
||||
|
||||
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? "" : ""
|
||||
const FALLBACK_API_BASE = "http://127.0.0.1:9898"
|
||||
const DEFAULT_BASE = typeof window !== "undefined" ? window.__CODENOMAD_API_BASE__ ?? FALLBACK_API_BASE : FALLBACK_API_BASE
|
||||
const DEFAULT_EVENTS_URL = typeof window !== "undefined" ? window.__CODENOMAD_EVENTS_URL__ ?? "/api/events" : "/api/events"
|
||||
const API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||
const EVENTS_URL = API_BASE ? `${API_BASE}${DEFAULT_EVENTS_URL}` : DEFAULT_EVENTS_URL
|
||||
@@ -129,8 +130,11 @@ export const cliApi = {
|
||||
body: JSON.stringify({ path }),
|
||||
})
|
||||
},
|
||||
listFileSystem(relativePath = "."): Promise<FileSystemEntry[]> {
|
||||
listFileSystem(relativePath = ".", options?: { depth?: number }): Promise<FileSystemEntry[]> {
|
||||
const params = new URLSearchParams({ path: relativePath })
|
||||
if (options?.depth) {
|
||||
params.set("depth", String(options.depth))
|
||||
}
|
||||
return request<FileSystemEntry[]>(`/api/filesystem?${params.toString()}`)
|
||||
},
|
||||
readInstanceData(id: string): Promise<InstanceData> {
|
||||
|
||||
Reference in New Issue
Block a user