diff --git a/packages/ui/src/components/filesystem-browser-dialog.tsx b/packages/ui/src/components/filesystem-browser-dialog.tsx index 3313a842..4d2847df 100644 --- a/packages/ui/src/components/filesystem-browser-dialog.tsx +++ b/packages/ui/src/components/filesystem-browser-dialog.tsx @@ -1,197 +1,23 @@ -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 { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" +import { Folder as FolderIcon, File as FileIcon, Loader2, Search, X, ArrowUpLeft } from "lucide-solid" +import type { FileSystemEntry, FileSystemListingMetadata } from "../../../cli/src/api-types" import { cliApi } from "../lib/api-client" -import { getServerMeta } from "../lib/server-meta" const MAX_RESULTS = 200 -type CacheListener = (entries: FileSystemEntry[]) => void - -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 === ".") { +function normalizeEntryPath(path: string | undefined): string { + if (!path || path === "." || path === "./") { return "." } - 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 - } + let cleaned = path.replace(/\\/g, "/") + if (cleaned.startsWith("./")) { + cleaned = cleaned.replace(/^\.\/+/, "") } - - if (changed) { - fileSystemCache.entriesList = Array.from(fileSystemCache.entriesMap.values()).sort((a, b) => - a.path.localeCompare(b.path), - ) + if (cleaned.startsWith("/")) { + cleaned = cleaned.replace(/^\/+/, "") } - - 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) - .then(({ entries }) => { - const changed = updateCache(entries) - fileSystemCache.loadedDirectories.add(normalized) - for (const entry of entries) { - if (entry.type === "directory") { - enqueueDirectory(entry.path) - } - } - if (changed) { - notifyCacheListeners() - } - }) - .finally(() => { - fileSystemCache.loadingPromises.delete(normalized) - }) - - 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() + cleaned = cleaned.replace(/\/+/g, "/") + return cleaned === "" ? "." : cleaned } function resolveAbsolutePath(root: string, relativePath: string): string { @@ -207,11 +33,6 @@ function resolveAbsolutePath(root: string, relativePath: string): string { return `${trimmedRoot}${normalized}` } -function formatRootLabel(root: string): string { - if (!root) return "Workspace Root" - const parts = root.split(/[/\\]/).filter(Boolean) - return parts[parts.length - 1] || root || "Workspace Root" -} interface FileSystemBrowserDialogProps { open: boolean @@ -222,73 +43,174 @@ interface FileSystemBrowserDialogProps { onClose: () => void } +type FolderRow = { type: "up"; path: string } | { type: "entry"; entry: FileSystemEntry } + const FileSystemBrowserDialog: Component = (props) => { - const [entries, setEntries] = createSignal([]) const [rootPath, setRootPath] = createSignal("") - const [loading, setLoading] = createSignal(false) + const [entries, setEntries] = createSignal([]) + const [currentMetadata, setCurrentMetadata] = createSignal(null) + const [loadingPath, setLoadingPath] = createSignal(null) const [error, setError] = createSignal(null) const [searchQuery, setSearchQuery] = createSignal("") const [selectedIndex, setSelectedIndex] = createSignal(0) let searchInputRef: HTMLInputElement | undefined - onMount(() => { - const unsubscribe = subscribeToCache((items) => setEntries(items)) - onCleanup(unsubscribe) - }) + const directoryCache = new Map() + const metadataCache = new Map() + const inFlightLoads = new Map>() - createEffect(() => { - const query = searchQuery().trim() - if (!query) { - return + function resetDialogState() { + directoryCache.clear() + metadataCache.clear() + inFlightLoads.clear() + setEntries([]) + setCurrentMetadata(null) + setLoadingPath(null) + } + + async function fetchDirectory(path: string, makeCurrent = false): Promise { + const normalized = normalizeEntryPath(path) + + if (directoryCache.has(normalized) && metadataCache.has(normalized)) { + if (makeCurrent) { + setCurrentMetadata(metadataCache.get(normalized) ?? null) + setEntries(directoryCache.get(normalized) ?? []) + } + return metadataCache.get(normalized) as FileSystemListingMetadata } - prioritizeDirectoriesForQuery(query) - }) + + if (inFlightLoads.has(normalized)) { + const metadata = await inFlightLoads.get(normalized)! + if (makeCurrent) { + setCurrentMetadata(metadata) + setEntries(directoryCache.get(normalized) ?? []) + } + return metadata + } + + const loadPromise = (async () => { + setLoadingPath(normalized) + const response = await cliApi.listFileSystem(normalized === "." ? "." : normalized, { + includeFiles: props.mode === "files", + }) + directoryCache.set(normalized, response.entries) + metadataCache.set(normalized, response.metadata) + if (!rootPath()) { + setRootPath(response.metadata.rootPath) + } + if (loadingPath() === normalized) { + setLoadingPath(null) + } + return response.metadata + })().catch((err) => { + if (loadingPath() === normalized) { + setLoadingPath(null) + } + throw err + }) + + inFlightLoads.set(normalized, loadPromise) + try { + const metadata = await loadPromise + if (makeCurrent) { + const key = normalizeEntryPath(metadata.currentPath) + setCurrentMetadata(metadata) + setEntries(directoryCache.get(key) ?? directoryCache.get(normalized) ?? []) + } + return metadata + } finally { + inFlightLoads.delete(normalized) + } + } async function refreshEntries() { - setLoading(true) setError(null) + resetDialogState() try { - const meta = await getServerMeta() - setRootPath(meta.workspaceRoot) - await ensureWorkspaceFilesystemLoaded(meta.workspaceRoot) + const metadata = await fetchDirectory(".", true) + setRootPath(metadata.rootPath) + setEntries(directoryCache.get(normalizeEntryPath(metadata.currentPath)) ?? []) } catch (err) { const message = err instanceof Error ? err.message : "Unable to load filesystem" setError(message) - } finally { - setLoading(false) } } + function describeLoadingPath() { + const path = loadingPath() + if (!path) { + return "filesystem" + } + if (path === ".") { + return rootPath() || "workspace root" + } + return resolveAbsolutePath(rootPath(), path) + } + + function currentAbsolutePath(): string { + const metadata = currentMetadata() + if (!metadata) { + return rootPath() + } + if (metadata.pathKind === "relative") { + return resolveAbsolutePath(rootPath(), metadata.currentPath) + } + return metadata.displayPath + } + + function handleOverlayClick(event: MouseEvent) { + if (event.target === event.currentTarget) { + props.onClose() + } + } + + function handleEntrySelect(entry: FileSystemEntry) { + const absolute = resolveAbsolutePath(rootPath(), entry.path) + props.onSelect(absolute) + } + + function handleNavigateTo(path: string) { + void fetchDirectory(path, true).catch((err) => { + console.error("Failed to open directory", err) + setError(err instanceof Error ? err.message : "Unable to open directory") + }) + } + + function handleNavigateUp() { + const parent = currentMetadata()?.parentPath + if (!parent) { + return + } + handleNavigateTo(parent) + } + const filteredEntries = createMemo(() => { const query = searchQuery().trim().toLowerCase() - const mode = props.mode - const root = rootPath() - const matchesType = entries().filter((entry) => (mode === "directories" ? entry.type === "directory" : entry.type === "file")) - - const baseEntries = mode === "directories" && root - ? [ - { - name: formatRootLabel(root), - path: ".", - type: "directory" as const, - }, - ...matchesType, - ] - : matchesType - + const subset = entries().filter((entry) => (props.mode === "directories" ? entry.type === "directory" : true)) if (!query) { - return baseEntries + return subset } - - return baseEntries.filter((entry) => { - const absolute = resolveAbsolutePath(root, entry.path) + return subset.filter((entry) => { + const absolute = resolveAbsolutePath(rootPath(), entry.path) return absolute.toLowerCase().includes(query) || entry.name.toLowerCase().includes(query) }) }) const visibleEntries = createMemo(() => filteredEntries().slice(0, MAX_RESULTS)) + const folderRows = createMemo(() => { + const rows: FolderRow[] = [] + const metadata = currentMetadata() + if (metadata?.parentPath) { + rows.push({ type: "up", path: metadata.parentPath }) + } + for (const entry of visibleEntries()) { + rows.push({ type: "entry", entry }) + } + return rows + }) + createEffect(() => { const list = visibleEntries() if (list.length === 0) { @@ -338,20 +260,12 @@ const FileSystemBrowserDialog: Component = (props) window.addEventListener("keydown", handleKeyDown) onCleanup(() => { window.removeEventListener("keydown", handleKeyDown) + resetDialogState() + setRootPath("") + setError(null) }) }) - function handleEntrySelect(entry: FileSystemEntry) { - const absolute = resolveAbsolutePath(rootPath(), entry.path) - props.onSelect(absolute) - } - - function handleOverlayClick(event: MouseEvent) { - if (event.target === event.currentTarget) { - props.onClose() - } - } - return (
@@ -360,9 +274,7 @@ const FileSystemBrowserDialog: Component = (props)

{props.title}

-

- {props.description || "Search for a path under the configured workspace root."} -

+

{props.description || "Search for a path under the configured workspace root."}

Root: {rootPath()}

@@ -392,56 +304,117 @@ const FileSystemBrowserDialog: Component = (props)
+ +
+
+
+

Current folder

+

{currentAbsolutePath()}

+
+ +
+
+
+
0} fallback={
{error()}} >
- Loading filesystem… + Loading {describeLoadingPath()}…
} > + +
+ + Loading {describeLoadingPath()}… +
+
0} + when={folderRows().length > 0} fallback={
-

No matches.

- - - +

No entries found.

+
} > - - {(entry, index) => ( - +
+
+ ) + } + + const entry = row.entry + const selectEntry = () => handleEntrySelect(entry) + const activateEntry = () => { + if (entry.type === "directory") { + handleNavigateTo(entry.path) + } else { + selectEntry() + } + } + + return ( +
+
+ + +
-
- {entry.name || entry.path} - {resolveAbsolutePath(rootPath(), entry.path)} -
- - )} + ) + }}
@@ -472,3 +445,4 @@ const FileSystemBrowserDialog: Component = (props) } export default FileSystemBrowserDialog +