diff --git a/packages/cli/src/filesystem/browser.ts b/packages/cli/src/filesystem/browser.ts index b9e29ded..67fbe3f6 100644 --- a/packages/cli/src/filesystem/browser.ts +++ b/packages/cli/src/filesystem/browser.ts @@ -13,14 +13,17 @@ export class FileSystemBrowser { this.root = path.resolve(options.rootDir) } - list(relativePath: string, depth = 2): FileSystemEntry[] { + list(relativePath: string, options: { depth?: number; includeFiles?: boolean } = {}): FileSystemEntry[] { + const depth = options.depth ?? 2 + const includeFiles = options.includeFiles ?? true if (depth < 1) { throw new Error("Depth must be at least 1") } - return this.walk(relativePath, depth) + const normalizedPath = this.normalizeRelativePath(relativePath) + return this.walk(normalizedPath, depth, includeFiles) } - private walk(relativePath: string, remainingDepth: number): FileSystemEntry[] { + private walk(relativePath: string, remainingDepth: number, includeFiles: boolean): FileSystemEntry[] { const resolved = this.toAbsolute(relativePath) const entries = fs.readdirSync(resolved, { withFileTypes: true }) @@ -31,21 +34,39 @@ export class FileSystemBrowser { const current: FileSystemEntry = { name: entry.name, - path: entryPath, + path: this.normalizeRelativePath(entryPath), type: entry.isDirectory() ? "directory" : "file", size: entry.isDirectory() ? undefined : stats.size, modifiedAt: stats.mtime.toISOString(), } if (entry.isDirectory() && remainingDepth > 1) { - const nested = this.walk(entryPath, remainingDepth - 1) + const nested = this.walk(entryPath, remainingDepth - 1, includeFiles) return [current, ...nested] } + if (!entry.isDirectory() && !includeFiles) { + return [] + } + return [current] }) } + private normalizeRelativePath(input: string | undefined) { + if (!input || input === "." || input === "./" || input === "/") { + return "." + } + let normalized = input.replace(/\\+/g, "/") + if (normalized.startsWith("./")) { + normalized = normalized.replace(/^\.\/+/, "") + } + if (normalized.startsWith("/")) { + normalized = normalized.replace(/^\/+/g, "") + } + return normalized === "" ? "." : normalized + } + readFile(relativePath: string): string { const resolved = this.toAbsolute(relativePath) return fs.readFileSync(resolved, "utf-8") diff --git a/packages/cli/src/server/routes/filesystem.ts b/packages/cli/src/server/routes/filesystem.ts index 02246725..8f766f31 100644 --- a/packages/cli/src/server/routes/filesystem.ts +++ b/packages/cli/src/server/routes/filesystem.ts @@ -9,6 +9,7 @@ interface RouteDeps { const FilesystemQuerySchema = z.object({ path: z.string().optional(), depth: z.coerce.number().int().min(1).max(10).default(2), + includeFiles: z.coerce.boolean().default(true), }) export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) { @@ -17,7 +18,10 @@ export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) const targetPath = query.path ?? "." try { - return deps.fileSystemBrowser.list(targetPath, query.depth) + return deps.fileSystemBrowser.list(targetPath, { + depth: query.depth, + includeFiles: query.includeFiles, + }) } catch (error) { reply.code(400) return { error: (error as Error).message } diff --git a/packages/ui/src/components/directory-browser-dialog.tsx b/packages/ui/src/components/directory-browser-dialog.tsx new file mode 100644 index 00000000..12287cea --- /dev/null +++ b/packages/ui/src/components/directory-browser-dialog.tsx @@ -0,0 +1,315 @@ +import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" +import { ArrowUpLeft, Folder as FolderIcon, Loader2, 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 ROOT_KEY = "." +const ROOT_REQUEST_PATH = "/" +const DEFAULT_DEPTH = 2 + +interface DirectoryBrowserDialogProps { + open: boolean + title: string + description?: string + onSelect: (absolutePath: string) => void + onClose: () => void +} + +function normalizeRelativePath(input?: string) { + if (!input || input === "." || input === "./" || input === "/") { + return "." + } + let normalized = input.replace(/\\+/g, "/") + if (normalized.startsWith("./")) { + normalized = normalized.replace(/^\.\/+/, "") + } + if (normalized.startsWith("/")) { + normalized = normalized.replace(/^\/+/g, "") + } + return normalized === "" ? "." : normalized +} + +function getParentPath(relativePath: string) { + const normalized = normalizeRelativePath(relativePath) + if (normalized === ".") { + return "." + } + const segments = normalized.split("/") + segments.pop() + return segments.length === 0 ? "." : segments.join("/") +} + +function resolveAbsolutePath(root: string, relativePath: string) { + if (!root) { + return relativePath + } + if (!relativePath || relativePath === "." || relativePath === "./" || relativePath === "/") { + return root + } + const separator = root.includes("\\") ? "\\" : "/" + const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}` + const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "") + return `${trimmedRoot}${normalized}` +} + +type FolderRow = + | { type: "up"; path: string } + | { type: "folder"; entry: FileSystemEntry } + +const DirectoryBrowserDialog: Component = (props) => { + const [rootPath, setRootPath] = createSignal("") + const [loading, setLoading] = createSignal(false) + const [error, setError] = createSignal(null) + const [directoryChildren, setDirectoryChildren] = createSignal>(new Map()) + const [loadingPaths, setLoadingPaths] = createSignal>(new Set()) + const [loadedPaths, setLoadedPaths] = createSignal>(new Set()) + const [currentPath, setCurrentPath] = createSignal(ROOT_KEY) + + function resetState() { + setDirectoryChildren(new Map()) + setLoadingPaths(new Set()) + setLoadedPaths(new Set()) + setCurrentPath(ROOT_KEY) + setError(null) + } + + createEffect(() => { + if (!props.open) { + return + } + resetState() + void initialize() + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault() + props.onClose() + } + } + + window.addEventListener("keydown", handleKeyDown) + onCleanup(() => { + window.removeEventListener("keydown", handleKeyDown) + }) + }) + + async function initialize() { + setLoading(true) + try { + const meta = await getServerMeta() + setRootPath(meta.workspaceRoot) + await ensureDirectoryLoaded(ROOT_KEY) + } catch (err) { + const message = err instanceof Error ? err.message : "Unable to load filesystem" + setError(message) + } finally { + setLoading(false) + } + } + + async function ensureDirectoryLoaded(path: string) { + const normalized = normalizeRelativePath(path) + if (loadedPaths().has(normalized)) { + return + } + await loadDirectory(normalized) + } + + async function loadDirectory(path: string) { + const normalized = normalizeRelativePath(path) + if (loadingPaths().has(normalized)) { + return + } + + setLoadingPaths((prev) => { + const next = new Set(prev) + next.add(normalized) + return next + }) + + try { + const requestPath = normalized === ROOT_KEY ? ROOT_REQUEST_PATH : normalized + const entries = await cliApi.listFileSystem(requestPath, { depth: DEFAULT_DEPTH, includeFiles: false }) + mergeDirectoryEntries(normalized, entries) + setLoadedPaths((prev) => { + const next = new Set(prev) + next.add(normalized) + return next + }) + } catch (err) { + const message = err instanceof Error ? err.message : "Unable to load filesystem" + setError(message) + throw err + } finally { + setLoadingPaths((prev) => { + const next = new Set(prev) + next.delete(normalized) + return next + }) + } + } + + function mergeDirectoryEntries(basePath: string, entries: FileSystemEntry[]) { + const grouped = new Map([[basePath, []]]) + for (const entry of entries) { + if (entry.type !== "directory") { + continue + } + const normalizedEntryPath = normalizeRelativePath(entry.path) + const parentPath = getParentPath(normalizedEntryPath) + const siblings = grouped.get(parentPath) ?? [] + siblings.push({ ...entry, path: normalizedEntryPath }) + grouped.set(parentPath, siblings) + } + + setDirectoryChildren((prev) => { + const next = new Map(prev) + for (const [parent, children] of grouped.entries()) { + const sorted = children.slice().sort((a, b) => a.name.localeCompare(b.name)) + next.set(parent, sorted) + } + return next + }) + } + + function handleEntrySelect(relativePath: string) { + const absolute = resolveAbsolutePath(rootPath(), relativePath) + props.onSelect(absolute) + } + + function isPathLoading(path: string) { + return loadingPaths().has(normalizeRelativePath(path)) + } + + const folderRows = createMemo(() => { + const rows: FolderRow[] = [] + if (currentPath() !== ROOT_KEY) { + rows.push({ type: "up", path: getParentPath(currentPath()) }) + } + const children = directoryChildren().get(currentPath()) ?? [] + for (const entry of children) { + rows.push({ type: "folder", entry }) + } + return rows + }) + + function handleNavigateTo(path: string) { + const normalized = normalizeRelativePath(path) + setCurrentPath(normalized) + void ensureDirectoryLoaded(normalized) + } + + function handleNavigateUp() { + handleNavigateTo(getParentPath(currentPath())) + } + + const currentAbsolutePath = createMemo(() => resolveAbsolutePath(rootPath(), currentPath())) + + function handleOverlayClick(event: MouseEvent) { + if (event.target === event.currentTarget) { + props.onClose() + } + } + + return ( + +
+ +
+ +
+ ) +} + +export default DirectoryBrowserDialog diff --git a/packages/ui/src/components/folder-selection-view.tsx b/packages/ui/src/components/folder-selection-view.tsx index 46021220..cf1e199b 100644 --- a/packages/ui/src/components/folder-selection-view.tsx +++ b/packages/ui/src/components/folder-selection-view.tsx @@ -2,7 +2,7 @@ import { Component, createSignal, Show, For, onMount, onCleanup, createEffect } import { Folder, Clock, Trash2, FolderPlus, Settings, ChevronRight } from "lucide-solid" import { useConfig } from "../stores/preferences" import AdvancedSettingsModal from "./advanced-settings-modal" -import FileSystemBrowserDialog from "./filesystem-browser-dialog" +import DirectoryBrowserDialog from "./directory-browser-dialog" import Kbd from "./kbd" const codeNomadLogo = new URL("../images/CodeNomad-Icon.png", import.meta.url).href @@ -386,11 +386,10 @@ const FolderSelectionView: Component = (props) => { isLoading={props.isLoading} /> - setIsFolderBrowserOpen(false)} onSelect={handleBrowserSelect} /> diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index c591626f..59c7989e 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -130,11 +130,14 @@ export const cliApi = { body: JSON.stringify({ path }), }) }, - listFileSystem(relativePath = ".", options?: { depth?: number }): Promise { + listFileSystem(relativePath = ".", options?: { depth?: number; includeFiles?: boolean }): Promise { const params = new URLSearchParams({ path: relativePath }) if (options?.depth) { params.set("depth", String(options.depth)) } + if (options?.includeFiles !== undefined) { + params.set("includeFiles", String(options.includeFiles)) + } return request(`/api/filesystem?${params.toString()}`) }, readInstanceData(id: string): Promise { diff --git a/packages/ui/src/styles/components/directory-browser.css b/packages/ui/src/styles/components/directory-browser.css new file mode 100644 index 00000000..635d4d7c --- /dev/null +++ b/packages/ui/src/styles/components/directory-browser.css @@ -0,0 +1,166 @@ +.directory-browser-modal { + width: min(960px, 90vw); + height: min(85vh, 900px); + max-height: 90vh; + border-radius: var(--radius-xl); +} + +.directory-browser-panel { + display: flex; + flex-direction: column; + height: 100%; +} + +.directory-browser-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-xl); + padding: 1.5rem; + border-bottom: 1px solid var(--border-base); + background-color: var(--surface-secondary); +} + +.directory-browser-heading { + display: flex; + flex-direction: column; + gap: var(--space-sm); +} + +.directory-browser-title { + font-size: var(--font-size-2xl); + line-height: var(--line-height-tight); + font-weight: var(--font-weight-semibold); + color: var(--text-primary); +} + +.directory-browser-description { + font-size: var(--font-size-xl); + line-height: var(--line-height-relaxed); + color: var(--text-secondary); +} + +.directory-browser-body { + flex: 1; + min-height: 0; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: var(--space-lg); + background-color: var(--surface-base); +} + +.directory-browser-current { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + width: 100%; +} + +.directory-browser-current-meta { + display: flex; + flex-direction: column; + gap: var(--space-2xs); +} + +.directory-browser-current-label { + font-size: var(--font-size-sm); + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); +} + +.directory-browser-current-path { + font-family: var(--font-family-mono); + font-size: var(--font-size-base); + color: var(--text-primary); +} + +.directory-browser-current-select { + width: auto; +} + +.directory-browser-close { + + display: inline-flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: var(--radius-full); + border: 1px solid var(--border-base); + background-color: var(--surface-base); + color: var(--text-primary); + transition: background-color 0.15s ease; +} + +.directory-browser-close:hover { + background-color: var(--surface-hover); +} + +.directory-browser-list { + + flex: 1; + min-height: 0; +} + +.directory-browser-row { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.directory-browser-row-main { + flex: 1; + display: flex; + align-items: center; + gap: var(--space-md); + text-align: left; + background: transparent; + border: none; + color: var(--text-primary); + padding: 0; + cursor: pointer; +} + +.directory-browser-row-icon { + width: 36px; + height: 36px; + border-radius: var(--radius-lg); + background-color: var(--surface-secondary); + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--text-muted); +} + +.directory-browser-row-name { + font-size: var(--font-size-lg); + font-weight: var(--font-weight-medium); +} + +.directory-browser-row-spinner { + width: 18px; + height: 18px; + color: var(--text-muted); +} + +.directory-browser-select { + width: auto; + min-width: 90px; +} + + +.directory-browser-select:hover { + background-color: var(--selection-highlight-bg); + border-color: var(--accent-primary); + color: var(--accent-primary); +} + +.directory-browser-loading { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + color: var(--text-secondary); +} diff --git a/packages/ui/src/styles/components/selector.css b/packages/ui/src/styles/components/selector.css index c9bf3e82..37e81276 100644 --- a/packages/ui/src/styles/components/selector.css +++ b/packages/ui/src/styles/components/selector.css @@ -224,12 +224,15 @@ } .selector-button-secondary { - background-color: var(--surface-secondary); + background-color: var(--surface-base); color: var(--text-primary); + border-color: var(--border-base); } .selector-button-secondary:hover:not(:disabled) { - background-color: var(--surface-hover); + background-color: var(--surface-secondary); + border-color: var(--border-base); + color: var(--text-primary); } .selector-button-secondary:disabled { diff --git a/packages/ui/src/styles/controls.css b/packages/ui/src/styles/controls.css index d70d648d..750f8b1d 100644 --- a/packages/ui/src/styles/controls.css +++ b/packages/ui/src/styles/controls.css @@ -4,3 +4,4 @@ @import "./components/dropdown.css"; @import "./components/selector.css"; @import "./components/env-vars.css"; +@import "./components/directory-browser.css";