Add depth-limited filesystem browsing

This commit is contained in:
Shantur Rathore
2025-11-17 21:16:10 +00:00
parent 719a9c9c74
commit f53564bb06
4 changed files with 215 additions and 25 deletions

View File

@@ -13,7 +13,14 @@ export class FileSystemBrowser {
this.root = path.resolve(options.rootDir)
}
list(relativePath: string): FileSystemEntry[] {
list(relativePath: string, depth = 2): FileSystemEntry[] {
if (depth < 1) {
throw new Error("Depth must be at least 1")
}
return this.walk(relativePath, depth)
}
private walk(relativePath: string, remainingDepth: number): FileSystemEntry[] {
const resolved = this.toAbsolute(relativePath)
const entries = fs.readdirSync(resolved, { withFileTypes: true })
@@ -30,8 +37,8 @@ export class FileSystemBrowser {
modifiedAt: stats.mtime.toISOString(),
}
if (entry.isDirectory()) {
const nested = this.list(entryPath)
if (entry.isDirectory() && remainingDepth > 1) {
const nested = this.walk(entryPath, remainingDepth - 1)
return [current, ...nested]
}

View File

@@ -8,6 +8,7 @@ interface RouteDeps {
const FilesystemQuerySchema = z.object({
path: z.string().optional(),
depth: z.coerce.number().int().min(1).max(10).default(2),
})
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
@@ -16,7 +17,7 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
const targetPath = query.path ?? "."
try {
return deps.fileSystemBrowser.list(targetPath)
return deps.fileSystemBrowser.list(targetPath, query.depth)
} catch (error) {
reply.code(400)
return { error: (error as Error).message }

View File

@@ -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)

View File

@@ -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> {