diff --git a/README.md b/README.md index f19e3dca..868c3470 100644 --- a/README.md +++ b/README.md @@ -42,3 +42,12 @@ Grab the latest build for macOS, Windows, and Linux from the [GitHub Releases pa 3. Connect to one or more OpenCode instances, set keyboard shortcuts in preferences, and start a session. 4. Use tabs to swap between instances, the task sidebar to dive into child sessions, and the prompt input to keep shipping. +## CLI Server Flags + +The bundled CLI server (`@codenomad/cli`) controls which folders the UI can browse when you pick a workspace: + +- `--workspace-root ` (default: current working directory) scopes browsing to a safe subtree. The UI can only see folders beneath this root. +- `--unrestricted-root` explicitly allows full-machine browsing for the current process. In this mode the UI starts from the host home directory, adds a "parent" option so you can reach `/` on macOS/Linux, and lists drives/UNC paths on Windows. The flag is runtime-only—restart the CLI without it to go back to restricted mode. + +Use unrestricted mode only when you trust the host; the CLI will skip directories it cannot read and never persists the opt-in. + diff --git a/packages/cli/src/api-types.ts b/packages/cli/src/api-types.ts index 936e3786..67912fac 100644 --- a/packages/cli/src/api-types.ts +++ b/packages/cli/src/api-types.ts @@ -60,13 +60,39 @@ export interface FileSystemEntry { name: string /** Path relative to the CLI server root ("." represents the root itself). */ path: string + /** Absolute path when available (unrestricted listings). */ + absolutePath?: string type: "file" | "directory" size?: number /** ISO timestamp of last modification when available. */ modifiedAt?: string } -export type FileSystemListResponse = FileSystemEntry[] +export type FileSystemScope = "restricted" | "unrestricted" +export type FileSystemPathKind = "relative" | "absolute" | "drives" + +export interface FileSystemListingMetadata { + scope: FileSystemScope + /** Canonical identifier of the current view ("." for restricted roots, absolute paths otherwise). */ + currentPath: string + /** Optional parent path if navigation upward is allowed. */ + parentPath?: string + /** Absolute path representing the root or origin point for this listing. */ + rootPath: string + /** Absolute home directory of the CLI host (useful defaults for unrestricted mode). */ + homePath: string + /** Human-friendly label for the current path. */ + displayPath: string + /** Indicates whether entry paths are relative, absolute, or represent drive roots. */ + pathKind: FileSystemPathKind +} + +export interface FileSystemListResponse { + entries: FileSystemEntry[] + metadata: FileSystemListingMetadata +} + +export const WINDOWS_DRIVES_ROOT = "__drives__" export interface WorkspaceFileResponse { workspaceId: string diff --git a/packages/cli/src/filesystem/browser.ts b/packages/cli/src/filesystem/browser.ts index 67fbe3f6..29ddb1c1 100644 --- a/packages/cli/src/filesystem/browser.ts +++ b/packages/cli/src/filesystem/browser.ts @@ -1,56 +1,196 @@ import fs from "fs" +import os from "os" import path from "path" -import { FileSystemEntry } from "../api-types" +import { + FileSystemEntry, + FileSystemListResponse, + FileSystemListingMetadata, + WINDOWS_DRIVES_ROOT, +} from "../api-types" interface FileSystemBrowserOptions { rootDir: string + unrestricted?: boolean } +interface DirectoryReadOptions { + includeFiles: boolean + formatPath: (entryName: string) => string + formatAbsolutePath: (entryName: string) => string +} + +const WINDOWS_DRIVE_LETTERS = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i)) + export class FileSystemBrowser { private readonly root: string + private readonly unrestricted: boolean + private readonly homeDir: string + private readonly isWindows: boolean constructor(options: FileSystemBrowserOptions) { this.root = path.resolve(options.rootDir) + this.unrestricted = Boolean(options.unrestricted) + this.homeDir = os.homedir() + this.isWindows = process.platform === "win32" } - 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") + list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] { + if (this.unrestricted) { + throw new Error("Relative listing is unavailable when running with unrestricted root") } + const includeFiles = options.includeFiles ?? true const normalizedPath = this.normalizeRelativePath(relativePath) - return this.walk(normalizedPath, depth, includeFiles) + const absolutePath = this.toRestrictedAbsolute(normalizedPath) + return this.readDirectoryEntries(absolutePath, { + includeFiles, + formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName), + formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName), + }) } - private walk(relativePath: string, remainingDepth: number, includeFiles: boolean): FileSystemEntry[] { - const resolved = this.toAbsolute(relativePath) - const entries = fs.readdirSync(resolved, { withFileTypes: true }) + browse(targetPath?: string, options: { includeFiles?: boolean } = {}): FileSystemListResponse { + const includeFiles = options.includeFiles ?? true + if (this.unrestricted) { + return this.listUnrestricted(targetPath, includeFiles) + } + return this.listRestrictedWithMetadata(targetPath, includeFiles) + } - return entries.flatMap((entry) => { - const entryPath = path.join(relativePath, entry.name) - const absolutePath = this.toAbsolute(entryPath) - const stats = fs.statSync(absolutePath) + readFile(relativePath: string): string { + if (this.unrestricted) { + throw new Error("readFile is not available in unrestricted mode") + } + const resolved = this.toRestrictedAbsolute(relativePath) + return fs.readFileSync(resolved, "utf-8") + } - const current: FileSystemEntry = { - name: entry.name, - 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, includeFiles) - return [current, ...nested] - } - - if (!entry.isDirectory() && !includeFiles) { - return [] - } - - return [current] + private listRestrictedWithMetadata(relativePath: string | undefined, includeFiles: boolean): FileSystemListResponse { + const normalizedPath = this.normalizeRelativePath(relativePath) + const absolutePath = this.toRestrictedAbsolute(normalizedPath) + const entries = this.readDirectoryEntries(absolutePath, { + includeFiles, + formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName), + formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName), }) + + const metadata: FileSystemListingMetadata = { + scope: "restricted", + currentPath: normalizedPath, + parentPath: normalizedPath === "." ? undefined : this.getRestrictedParent(normalizedPath), + rootPath: this.root, + homePath: this.homeDir, + displayPath: this.resolveRestrictedAbsolute(normalizedPath), + pathKind: "relative", + } + + return { entries, metadata } + } + + private listUnrestricted(targetPath: string | undefined, includeFiles: boolean): FileSystemListResponse { + const resolvedPath = this.resolveUnrestrictedPath(targetPath) + + if (this.isWindows && resolvedPath === WINDOWS_DRIVES_ROOT) { + return this.listWindowsDrives() + } + + const entries = this.readDirectoryEntries(resolvedPath, { + includeFiles, + formatPath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName), + formatAbsolutePath: (entryName) => this.resolveAbsoluteChild(resolvedPath, entryName), + }) + + const parentPath = this.getUnrestrictedParent(resolvedPath) + + const metadata: FileSystemListingMetadata = { + scope: "unrestricted", + currentPath: resolvedPath, + parentPath, + rootPath: this.homeDir, + homePath: this.homeDir, + displayPath: resolvedPath, + pathKind: "absolute", + } + + return { entries, metadata } + } + + private listWindowsDrives(): FileSystemListResponse { + if (!this.isWindows) { + throw new Error("Drive listing is only supported on Windows hosts") + } + + const entries: FileSystemEntry[] = [] + for (const letter of WINDOWS_DRIVE_LETTERS) { + const drivePath = `${letter}:\\` + try { + if (fs.existsSync(drivePath)) { + entries.push({ + name: `${letter}:`, + path: drivePath, + absolutePath: drivePath, + type: "directory", + }) + } + } catch { + // Ignore inaccessible drives + } + } + + // Provide a generic UNC root entry so users can navigate to network shares manually. + entries.push({ + name: "UNC Network", + path: "\\\\", + absolutePath: "\\\\", + type: "directory", + }) + + const metadata: FileSystemListingMetadata = { + scope: "unrestricted", + currentPath: WINDOWS_DRIVES_ROOT, + parentPath: undefined, + rootPath: this.homeDir, + homePath: this.homeDir, + displayPath: "Drives", + pathKind: "drives", + } + + return { entries, metadata } + } + + private readDirectoryEntries(directory: string, options: DirectoryReadOptions): FileSystemEntry[] { + const dirents = fs.readdirSync(directory, { withFileTypes: true }) + const results: FileSystemEntry[] = [] + + for (const entry of dirents) { + if (!options.includeFiles && !entry.isDirectory()) { + continue + } + + const absoluteEntryPath = path.join(directory, entry.name) + let stats: fs.Stats + try { + stats = fs.statSync(absoluteEntryPath) + } catch { + // Skip entries we cannot stat (insufficient permissions, etc.) + continue + } + + const isDirectory = entry.isDirectory() + if (!options.includeFiles && !isDirectory) { + continue + } + + results.push({ + name: entry.name, + path: options.formatPath(entry.name), + absolutePath: options.formatAbsolutePath(entry.name), + type: isDirectory ? "directory" : "file", + size: isDirectory ? undefined : stats.size, + modifiedAt: stats.mtime.toISOString(), + }) + } + + return results.sort((a, b) => a.name.localeCompare(b.name)) } private normalizeRelativePath(input: string | undefined) { @@ -67,16 +207,89 @@ export class FileSystemBrowser { return normalized === "" ? "." : normalized } - readFile(relativePath: string): string { - const resolved = this.toAbsolute(relativePath) - return fs.readFileSync(resolved, "utf-8") + private buildRelativePath(parent: string, child: string) { + if (!parent || parent === ".") { + return this.normalizeRelativePath(child) + } + return this.normalizeRelativePath(`${parent}/${child}`) } - private toAbsolute(relativePath: string) { - const target = path.resolve(this.root, relativePath) - if (!target.startsWith(this.root)) { + private resolveRestrictedAbsolute(relativePath: string) { + return this.toRestrictedAbsolute(relativePath) + } + + private resolveRestrictedAbsoluteChild(parent: string, child: string) { + const normalized = this.buildRelativePath(parent, child) + return this.toRestrictedAbsolute(normalized) + } + + private toRestrictedAbsolute(relativePath: string) { + const normalized = this.normalizeRelativePath(relativePath) + const target = path.resolve(this.root, normalized) + const relativeToRoot = path.relative(this.root, target) + if (relativeToRoot.startsWith("..") || path.isAbsolute(relativeToRoot) && relativeToRoot !== "") { throw new Error("Access outside of root is not allowed") } return target } + + private resolveUnrestrictedPath(input: string | undefined): string { + if (!input || input === "." || input === "./") { + return this.homeDir + } + + if (this.isWindows) { + if (input === WINDOWS_DRIVES_ROOT) { + return WINDOWS_DRIVES_ROOT + } + const normalized = path.win32.normalize(input) + if (/^[a-zA-Z]:/.test(normalized) || normalized.startsWith("\\\\")) { + return normalized + } + return path.win32.resolve(this.homeDir, normalized) + } + + if (input.startsWith("/")) { + return path.posix.normalize(input) + } + + return path.posix.resolve(this.homeDir, input) + } + + private resolveAbsoluteChild(parent: string, child: string) { + if (this.isWindows) { + return path.win32.normalize(path.win32.join(parent, child)) + } + return path.posix.normalize(path.posix.join(parent, child)) + } + + private getRestrictedParent(relativePath: string) { + const normalized = this.normalizeRelativePath(relativePath) + if (normalized === ".") { + return undefined + } + const segments = normalized.split("/") + segments.pop() + return segments.length === 0 ? "." : segments.join("/") + } + + private getUnrestrictedParent(currentPath: string) { + if (this.isWindows) { + const normalized = path.win32.normalize(currentPath) + const parsed = path.win32.parse(normalized) + if (normalized === WINDOWS_DRIVES_ROOT) { + return undefined + } + if (normalized === parsed.root) { + return WINDOWS_DRIVES_ROOT + } + return path.win32.dirname(normalized) + } + + const normalized = path.posix.normalize(currentPath) + if (normalized === "/") { + return undefined + } + return path.posix.dirname(normalized) + } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8da54d4a..d3652f8f 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -19,6 +19,7 @@ interface CliOptions { host: string rootDir: string configPath: string + unrestrictedRoot: boolean logLevel?: string logDestination?: string } @@ -34,7 +35,11 @@ function parseCliOptions(argv: string[]): CliOptions { .version(packageJson.version, "-v, --version", "Show the CLI version") .addOption(new Option("--host ", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST)) .addOption(new Option("--port ", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort)) - .addOption(new Option("--root ", "Workspace root directory").default(process.cwd())) + .addOption( + new Option("--workspace-root ", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()), + ) + .addOption(new Option("--root ").env("CLI_ROOT").hideHelp(true)) + .addOption(new Option("--unrestricted-root", "Allow browsing the full filesystem").env("CLI_UNRESTRICTED_ROOT").default(false)) .addOption(new Option("--config ", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH)) .addOption(new Option("--log-level ", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL")) .addOption(new Option("--log-destination ", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION")) @@ -43,17 +48,22 @@ function parseCliOptions(argv: string[]): CliOptions { const parsed = program.opts<{ host: string port: number - root: string + workspaceRoot?: string + root?: string + unrestrictedRoot?: boolean config: string logLevel?: string logDestination?: string }>() + const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd() + return { port: parsed.port, host: parsed.host, - rootDir: parsed.root, + rootDir: resolvedRoot, configPath: parsed.config, + unrestrictedRoot: Boolean(parsed.unrestrictedRoot), logLevel: parsed.logLevel, logDestination: parsed.logDestination, } @@ -86,7 +96,7 @@ async function main() { eventBus, logger: workspaceLogger, }) - const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir }) + const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot }) const instanceStore = new InstanceStore() const serverMeta: ServerMeta = { diff --git a/packages/cli/src/server/routes/filesystem.ts b/packages/cli/src/server/routes/filesystem.ts index 8f766f31..d919c29e 100644 --- a/packages/cli/src/server/routes/filesystem.ts +++ b/packages/cli/src/server/routes/filesystem.ts @@ -8,18 +8,15 @@ 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), + includeFiles: z.coerce.boolean().optional(), }) export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) { app.get("/api/filesystem", async (request, reply) => { const query = FilesystemQuerySchema.parse(request.query ?? {}) - const targetPath = query.path ?? "." try { - return deps.fileSystemBrowser.list(targetPath, { - depth: query.depth, + return deps.fileSystemBrowser.browse(query.path, { includeFiles: query.includeFiles, }) } catch (error) { diff --git a/packages/ui/src/components/directory-browser-dialog.tsx b/packages/ui/src/components/directory-browser-dialog.tsx index 12287cea..6f357f89 100644 --- a/packages/ui/src/components/directory-browser-dialog.tsx +++ b/packages/ui/src/components/directory-browser-dialog.tsx @@ -1,12 +1,36 @@ 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 type { FileSystemEntry, FileSystemListingMetadata } from "../../../cli/src/api-types" +import { WINDOWS_DRIVES_ROOT } 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 +function normalizePathKey(input?: string | null) { + if (!input || input === "." || input === "./") { + return "." + } + if (input === WINDOWS_DRIVES_ROOT) { + return WINDOWS_DRIVES_ROOT + } + let normalized = input.replace(/\\/g, "/") + if (/^[a-zA-Z]:/.test(normalized)) { + const [drive, rest = ""] = normalized.split(":") + const suffix = rest.startsWith("/") ? rest : rest ? `/${rest}` : "/" + return `${drive.toUpperCase()}:${suffix.replace(/\/+/g, "/")}` + } + if (normalized.startsWith("//")) { + return `//${normalized.slice(2).replace(/\/+/g, "/")}` + } + if (normalized.startsWith("/")) { + return `/${normalized.slice(1).replace(/\/+/g, "/")}` + } + normalized = normalized.replace(/^\.\/+/, "").replace(/\/+/g, "/") + return normalized === "" ? "." : normalized +} + + +function isAbsolutePathLike(input: string) { + return input.startsWith("/") || /^[a-zA-Z]:/.test(input) || input.startsWith("\\\\") +} interface DirectoryBrowserDialogProps { open: boolean @@ -16,37 +40,16 @@ interface DirectoryBrowserDialogProps { 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 === "/") { + if (!relativePath || relativePath === "." || relativePath === "./") { return root } + if (isAbsolutePathLike(relativePath)) { + return relativePath + } const separator = root.includes("\\") ? "\\" : "/" const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}` const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "") @@ -63,14 +66,19 @@ const DirectoryBrowserDialog: Component = (props) = 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) + const [currentPathKey, setCurrentPathKey] = createSignal(null) + const [currentMetadata, setCurrentMetadata] = createSignal(null) + + const metadataCache = new Map() + const inFlightRequests = new Map>() function resetState() { setDirectoryChildren(new Map()) setLoadingPaths(new Set()) - setLoadedPaths(new Set()) - setCurrentPath(ROOT_KEY) + setCurrentPathKey(null) + setCurrentMetadata(null) + metadataCache.clear() + inFlightRequests.clear() setError(null) } @@ -97,9 +105,8 @@ const DirectoryBrowserDialog: Component = (props) = async function initialize() { setLoading(true) try { - const meta = await getServerMeta() - setRootPath(meta.workspaceRoot) - await ensureDirectoryLoaded(ROOT_KEY) + const metadata = await loadDirectory() + applyMetadata(metadata) } catch (err) { const message = err instanceof Error ? err.message : "Unable to load filesystem" setError(message) @@ -108,86 +115,105 @@ const DirectoryBrowserDialog: Component = (props) = } } - async function ensureDirectoryLoaded(path: string) { - const normalized = normalizeRelativePath(path) - if (loadedPaths().has(normalized)) { - return - } - await loadDirectory(normalized) + function applyMetadata(metadata: FileSystemListingMetadata) { + const key = normalizePathKey(metadata.currentPath) + setCurrentPathKey(key) + setCurrentMetadata(metadata) + setRootPath(metadata.rootPath) } - async function loadDirectory(path: string) { - const normalized = normalizeRelativePath(path) - if (loadingPaths().has(normalized)) { - return + async function loadDirectory(targetPath?: string): Promise { + const key = targetPath ? normalizePathKey(targetPath) : undefined + if (key) { + const cached = metadataCache.get(key) + if (cached) { + return cached + } + const pending = inFlightRequests.get(key) + if (pending) { + return pending + } } - setLoadingPaths((prev) => { - const next = new Set(prev) - next.add(normalized) - return next - }) + const request = (async () => { + if (key) { + setLoadingPaths((prev) => { + const next = new Set(prev) + next.add(key) + 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) + const response = await cliApi.listFileSystem(targetPath, { includeFiles: false }) + const canonicalKey = normalizePathKey(response.metadata.currentPath) + const directories = response.entries + .filter((entry) => entry.type === "directory") + .sort((a, b) => a.name.localeCompare(b.name)) + + setDirectoryChildren((prev) => { + const next = new Map(prev) + next.set(canonicalKey, directories) return next }) + + metadataCache.set(canonicalKey, response.metadata) + + setLoadingPaths((prev) => { + const next = new Set(prev) + if (key) { + next.delete(key) + } + next.delete(canonicalKey) + return next + }) + + return response.metadata + })() + .catch((err) => { + if (key) { + setLoadingPaths((prev) => { + const next = new Set(prev) + next.delete(key) + return next + }) + } + throw err + }) + .finally(() => { + if (key) { + inFlightRequests.delete(key) + } + }) + + if (key) { + inFlightRequests.set(key, request) + } + + return request + } + + async function navigateTo(path?: string) { + setError(null) + try { + const metadata = await loadDirectory(path) + applyMetadata(metadata) } 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 metadata = currentMetadata() + if (metadata?.parentPath) { + rows.push({ type: "up", path: metadata.parentPath }) } - const children = directoryChildren().get(currentPath()) ?? [] + const key = currentPathKey() + if (!key) { + return rows + } + const children = directoryChildren().get(key) ?? [] for (const entry of children) { rows.push({ type: "folder", entry }) } @@ -195,16 +221,44 @@ const DirectoryBrowserDialog: Component = (props) = }) function handleNavigateTo(path: string) { - const normalized = normalizeRelativePath(path) - setCurrentPath(normalized) - void ensureDirectoryLoaded(normalized) + void navigateTo(path) } function handleNavigateUp() { - handleNavigateTo(getParentPath(currentPath())) + const parent = currentMetadata()?.parentPath + if (parent) { + void navigateTo(parent) + } } - const currentAbsolutePath = createMemo(() => resolveAbsolutePath(rootPath(), currentPath())) + const currentAbsolutePath = createMemo(() => { + const metadata = currentMetadata() + if (!metadata) { + return "" + } + if (metadata.pathKind === "drives") { + return "" + } + if (metadata.pathKind === "relative") { + return resolveAbsolutePath(metadata.rootPath, metadata.currentPath) + } + return metadata.displayPath + }) + + const canSelectCurrent = createMemo(() => Boolean(currentAbsolutePath())) + + function handleEntrySelect(entry: FileSystemEntry) { + const absolutePath = entry.absolutePath + ? entry.absolutePath + : isAbsolutePathLike(entry.path) + ? entry.path + : resolveAbsolutePath(rootPath(), entry.path) + props.onSelect(absolutePath) + } + + function isPathLoading(path: string) { + return loadingPaths().has(normalizePathKey(path)) + } function handleOverlayClick(event: MouseEvent) { if (event.target === event.currentTarget) { @@ -239,7 +293,13 @@ const DirectoryBrowserDialog: Component = (props) = @@ -290,7 +350,7 @@ const DirectoryBrowserDialog: Component = (props) = class="selector-button selector-button-secondary directory-browser-select" onClick={(event) => { event.stopPropagation() - handleEntrySelect(item.entry.path) + handleEntrySelect(item.entry) }} > Select diff --git a/packages/ui/src/components/filesystem-browser-dialog.tsx b/packages/ui/src/components/filesystem-browser-dialog.tsx index ac1fe08c..3313a842 100644 --- a/packages/ui/src/components/filesystem-browser-dialog.tsx +++ b/packages/ui/src/components/filesystem-browser-dialog.tsx @@ -5,7 +5,6 @@ import { cliApi } from "../lib/api-client" import { getServerMeta } from "../lib/server-meta" const MAX_RESULTS = 200 -const DEFAULT_DEPTH = 2 type CacheListener = (entries: FileSystemEntry[]) => void @@ -124,8 +123,8 @@ async function loadDirectory(path: string): Promise { } const promise = cliApi - .listFileSystem(normalized === "." ? "." : normalized, { depth: DEFAULT_DEPTH }) - .then((entries) => { + .listFileSystem(normalized === "." ? "." : normalized) + .then(({ entries }) => { const changed = updateCache(entries) fileSystemCache.loadedDirectories.add(normalized) for (const entry of entries) { diff --git a/packages/ui/src/lib/api-client.ts b/packages/ui/src/lib/api-client.ts index 59c7989e..c9047013 100644 --- a/packages/ui/src/lib/api-client.ts +++ b/packages/ui/src/lib/api-client.ts @@ -6,6 +6,7 @@ import type { BinaryUpdateRequest, BinaryValidationResult, FileSystemEntry, + FileSystemListResponse, InstanceData, ServerMeta, @@ -130,15 +131,16 @@ export const cliApi = { body: JSON.stringify({ path }), }) }, - listFileSystem(relativePath = ".", options?: { depth?: number; includeFiles?: boolean }): Promise { - const params = new URLSearchParams({ path: relativePath }) - if (options?.depth) { - params.set("depth", String(options.depth)) + listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise { + const params = new URLSearchParams() + if (path && path !== ".") { + params.set("path", path) } if (options?.includeFiles !== undefined) { params.set("includeFiles", String(options.includeFiles)) } - return request(`/api/filesystem?${params.toString()}`) + const query = params.toString() + return request(query ? `/api/filesystem?${query}` : "/api/filesystem") }, readInstanceData(id: string): Promise { return request(`/api/storage/instances/${encodeURIComponent(id)}`)