Add depth-limited filesystem browsing
This commit is contained in:
@@ -13,7 +13,14 @@ export class FileSystemBrowser {
|
|||||||
this.root = path.resolve(options.rootDir)
|
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 resolved = this.toAbsolute(relativePath)
|
||||||
const entries = fs.readdirSync(resolved, { withFileTypes: true })
|
const entries = fs.readdirSync(resolved, { withFileTypes: true })
|
||||||
|
|
||||||
@@ -30,8 +37,8 @@ export class FileSystemBrowser {
|
|||||||
modifiedAt: stats.mtime.toISOString(),
|
modifiedAt: stats.mtime.toISOString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory() && remainingDepth > 1) {
|
||||||
const nested = this.list(entryPath)
|
const nested = this.walk(entryPath, remainingDepth - 1)
|
||||||
return [current, ...nested]
|
return [current, ...nested]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface RouteDeps {
|
|||||||
|
|
||||||
const FilesystemQuerySchema = z.object({
|
const FilesystemQuerySchema = z.object({
|
||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
|
depth: z.coerce.number().int().min(1).max(10).default(2),
|
||||||
})
|
})
|
||||||
|
|
||||||
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
|
||||||
@@ -16,7 +17,7 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps)
|
|||||||
const targetPath = query.path ?? "."
|
const targetPath = query.path ?? "."
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return deps.fileSystemBrowser.list(targetPath)
|
return deps.fileSystemBrowser.list(targetPath, query.depth)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
reply.code(400)
|
reply.code(400)
|
||||||
return { error: (error as Error).message }
|
return { error: (error as Error).message }
|
||||||
|
|||||||
@@ -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 { Folder as FolderIcon, File as FileIcon, Loader2, Search, X } from "lucide-solid"
|
||||||
import type { FileSystemEntry } from "../../../cli/src/api-types"
|
import type { FileSystemEntry } from "../../../cli/src/api-types"
|
||||||
import { cliApi } from "../lib/api-client"
|
import { cliApi } from "../lib/api-client"
|
||||||
import { getServerMeta } from "../lib/server-meta"
|
import { getServerMeta } from "../lib/server-meta"
|
||||||
|
|
||||||
const MAX_RESULTS = 200
|
const MAX_RESULTS = 200
|
||||||
|
const DEFAULT_DEPTH = 2
|
||||||
|
|
||||||
let cachedEntries: FileSystemEntry[] | null = null
|
type CacheListener = (entries: FileSystemEntry[]) => void
|
||||||
let entriesPromise: Promise<FileSystemEntry[]> | null = null
|
|
||||||
|
|
||||||
async function loadFileSystemEntries(): Promise<FileSystemEntry[]> {
|
interface FileSystemCacheState {
|
||||||
if (cachedEntries) {
|
entriesMap: Map<string, FileSystemEntry>
|
||||||
return cachedEntries
|
entriesList: FileSystemEntry[]
|
||||||
|
loadedDirectories: Set<string>
|
||||||
|
loadingPromises: Map<string, Promise<void>>
|
||||||
|
pendingDirectories: string[]
|
||||||
|
listeners: Set<CacheListener>
|
||||||
|
queueActive: boolean
|
||||||
}
|
}
|
||||||
if (entriesPromise) {
|
|
||||||
return entriesPromise
|
const fileSystemCache: FileSystemCacheState = {
|
||||||
|
entriesMap: new Map(),
|
||||||
|
entriesList: [],
|
||||||
|
loadedDirectories: new Set(),
|
||||||
|
loadingPromises: new Map(),
|
||||||
|
pendingDirectories: [],
|
||||||
|
listeners: new Set(),
|
||||||
|
queueActive: false,
|
||||||
}
|
}
|
||||||
entriesPromise = cliApi
|
|
||||||
.listFileSystem(".")
|
let cacheWorkspaceRoot: string | null = null
|
||||||
|
|
||||||
|
function normalizeEntryPath(path: string): string {
|
||||||
|
if (!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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
.then((entries) => {
|
||||||
cachedEntries = entries.slice().sort((a, b) => a.path.localeCompare(b.path))
|
const changed = updateCache(entries)
|
||||||
entriesPromise = null
|
fileSystemCache.loadedDirectories.add(normalized)
|
||||||
return cachedEntries
|
for (const entry of entries) {
|
||||||
|
if (entry.type === "directory") {
|
||||||
|
enqueueDirectory(entry.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
notifyCacheListeners()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.finally(() => {
|
||||||
entriesPromise = null
|
fileSystemCache.loadingPromises.delete(normalized)
|
||||||
throw error
|
|
||||||
})
|
})
|
||||||
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 {
|
function resolveAbsolutePath(root: string, relativePath: string): string {
|
||||||
@@ -68,13 +233,26 @@ const FileSystemBrowserDialog: Component<FileSystemBrowserDialogProps> = (props)
|
|||||||
|
|
||||||
let searchInputRef: HTMLInputElement | undefined
|
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() {
|
async function refreshEntries() {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const [items, meta] = await Promise.all([loadFileSystemEntries(), getServerMeta()])
|
const meta = await getServerMeta()
|
||||||
setEntries(items)
|
|
||||||
setRootPath(meta.workspaceRoot)
|
setRootPath(meta.workspaceRoot)
|
||||||
|
await ensureWorkspaceFilesystemLoaded(meta.workspaceRoot)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
const message = err instanceof Error ? err.message : "Unable to load filesystem"
|
||||||
setError(message)
|
setError(message)
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import type {
|
|||||||
WorkspaceEventType,
|
WorkspaceEventType,
|
||||||
} from "../../../cli/src/api-types"
|
} 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 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 API_BASE = import.meta.env.VITE_CODENOMAD_API_BASE ?? DEFAULT_BASE
|
||||||
const EVENTS_URL = API_BASE ? `${API_BASE}${DEFAULT_EVENTS_URL}` : DEFAULT_EVENTS_URL
|
const EVENTS_URL = API_BASE ? `${API_BASE}${DEFAULT_EVENTS_URL}` : DEFAULT_EVENTS_URL
|
||||||
@@ -129,8 +130,11 @@ export const cliApi = {
|
|||||||
body: JSON.stringify({ path }),
|
body: JSON.stringify({ path }),
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
listFileSystem(relativePath = "."): Promise<FileSystemEntry[]> {
|
listFileSystem(relativePath = ".", options?: { depth?: number }): Promise<FileSystemEntry[]> {
|
||||||
const params = new URLSearchParams({ path: relativePath })
|
const params = new URLSearchParams({ path: relativePath })
|
||||||
|
if (options?.depth) {
|
||||||
|
params.set("depth", String(options.depth))
|
||||||
|
}
|
||||||
return request<FileSystemEntry[]>(`/api/filesystem?${params.toString()}`)
|
return request<FileSystemEntry[]>(`/api/filesystem?${params.toString()}`)
|
||||||
},
|
},
|
||||||
readInstanceData(id: string): Promise<InstanceData> {
|
readInstanceData(id: string): Promise<InstanceData> {
|
||||||
|
|||||||
Reference in New Issue
Block a user