From f53564bb064435a4b3c335346fb97dd02f7d101d Mon Sep 17 00:00:00 2001 From: Shantur Rathore Date: Mon, 17 Nov 2025 21:16:10 +0000 Subject: [PATCH] Add depth-limited filesystem browsing --- packages/cli/src/filesystem/browser.ts | 13 +- packages/cli/src/server/routes/filesystem.ts | 3 +- .../components/filesystem-browser-dialog.tsx | 216 ++++++++++++++++-- packages/ui/src/lib/api-client.ts | 8 +- 4 files changed, 215 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/filesystem/browser.ts b/packages/cli/src/filesystem/browser.ts index f6803d75..b9e29ded 100644 --- a/packages/cli/src/filesystem/browser.ts +++ b/packages/cli/src/filesystem/browser.ts @@ -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] } diff --git a/packages/cli/src/server/routes/filesystem.ts b/packages/cli/src/server/routes/filesystem.ts index d3a3d705..02246725 100644 --- a/packages/cli/src/server/routes/filesystem.ts +++ b/packages/cli/src/server/routes/filesystem.ts @@ -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 } diff --git a/packages/ui/src/components/filesystem-browser-dialog.tsx b/packages/ui/src/components/filesystem-browser-dialog.tsx index db0d8a2c..ac1fe08c 100644 --- a/packages/ui/src/components/filesystem-browser-dialog.tsx +++ b/packages/ui/src/components/filesystem-browser-dialog.tsx @@ -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 | null = null +type CacheListener = (entries: FileSystemEntry[]) => void -async function loadFileSystemEntries(): Promise { - if (cachedEntries) { - return cachedEntries +interface FileSystemCacheState { + entriesMap: Map + entriesList: FileSystemEntry[] + loadedDirectories: Set + loadingPromises: Map> + pendingDirectories: string[] + listeners: Set + 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 { + 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 = (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) diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index a65c0812..c591626f 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -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 { + listFileSystem(relativePath = ".", options?: { depth?: number }): Promise { const params = new URLSearchParams({ path: relativePath }) + if (options?.depth) { + params.set("depth", String(options.depth)) + } return request(`/api/filesystem?${params.toString()}`) }, readInstanceData(id: string): Promise {