add unrestricted filesystem browsing mode

This commit is contained in:
Shantur Rathore
2025-11-17 23:40:02 +00:00
parent a3f02befa7
commit a43a004e23
8 changed files with 480 additions and 164 deletions

View File

@@ -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. 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. 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 <path>` (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.

View File

@@ -60,13 +60,39 @@ export interface FileSystemEntry {
name: string name: string
/** Path relative to the CLI server root ("." represents the root itself). */ /** Path relative to the CLI server root ("." represents the root itself). */
path: string path: string
/** Absolute path when available (unrestricted listings). */
absolutePath?: string
type: "file" | "directory" type: "file" | "directory"
size?: number size?: number
/** ISO timestamp of last modification when available. */ /** ISO timestamp of last modification when available. */
modifiedAt?: string 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 { export interface WorkspaceFileResponse {
workspaceId: string workspaceId: string

View File

@@ -1,58 +1,198 @@
import fs from "fs" import fs from "fs"
import os from "os"
import path from "path" import path from "path"
import { FileSystemEntry } from "../api-types" import {
FileSystemEntry,
FileSystemListResponse,
FileSystemListingMetadata,
WINDOWS_DRIVES_ROOT,
} from "../api-types"
interface FileSystemBrowserOptions { interface FileSystemBrowserOptions {
rootDir: string 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 { export class FileSystemBrowser {
private readonly root: string private readonly root: string
private readonly unrestricted: boolean
private readonly homeDir: string
private readonly isWindows: boolean
constructor(options: FileSystemBrowserOptions) { constructor(options: FileSystemBrowserOptions) {
this.root = path.resolve(options.rootDir) 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[] { list(relativePath = ".", options: { includeFiles?: boolean } = {}): FileSystemEntry[] {
const depth = options.depth ?? 2 if (this.unrestricted) {
throw new Error("Relative listing is unavailable when running with unrestricted root")
}
const includeFiles = options.includeFiles ?? true const includeFiles = options.includeFiles ?? true
if (depth < 1) {
throw new Error("Depth must be at least 1")
}
const normalizedPath = this.normalizeRelativePath(relativePath) const normalizedPath = this.normalizeRelativePath(relativePath)
return this.walk(normalizedPath, depth, includeFiles) const absolutePath = this.toRestrictedAbsolute(normalizedPath)
} return this.readDirectoryEntries(absolutePath, {
includeFiles,
private walk(relativePath: string, remainingDepth: number, includeFiles: boolean): FileSystemEntry[] { formatPath: (entryName) => this.buildRelativePath(normalizedPath, entryName),
const resolved = this.toAbsolute(relativePath) formatAbsolutePath: (entryName) => this.resolveRestrictedAbsoluteChild(normalizedPath, entryName),
const entries = fs.readdirSync(resolved, { withFileTypes: true })
return entries.flatMap<FileSystemEntry>((entry) => {
const entryPath = path.join(relativePath, entry.name)
const absolutePath = this.toAbsolute(entryPath)
const stats = fs.statSync(absolutePath)
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]
}) })
} }
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)
}
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")
}
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) { private normalizeRelativePath(input: string | undefined) {
if (!input || input === "." || input === "./" || input === "/") { if (!input || input === "." || input === "./" || input === "/") {
return "." return "."
@@ -67,16 +207,89 @@ export class FileSystemBrowser {
return normalized === "" ? "." : normalized return normalized === "" ? "." : normalized
} }
readFile(relativePath: string): string { private buildRelativePath(parent: string, child: string) {
const resolved = this.toAbsolute(relativePath) if (!parent || parent === ".") {
return fs.readFileSync(resolved, "utf-8") return this.normalizeRelativePath(child)
}
return this.normalizeRelativePath(`${parent}/${child}`)
} }
private toAbsolute(relativePath: string) { private resolveRestrictedAbsolute(relativePath: string) {
const target = path.resolve(this.root, relativePath) return this.toRestrictedAbsolute(relativePath)
if (!target.startsWith(this.root)) { }
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") throw new Error("Access outside of root is not allowed")
} }
return target 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)
}
} }

View File

@@ -19,6 +19,7 @@ interface CliOptions {
host: string host: string
rootDir: string rootDir: string
configPath: string configPath: string
unrestrictedRoot: boolean
logLevel?: string logLevel?: string
logDestination?: string logDestination?: string
} }
@@ -34,7 +35,11 @@ function parseCliOptions(argv: string[]): CliOptions {
.version(packageJson.version, "-v, --version", "Show the CLI version") .version(packageJson.version, "-v, --version", "Show the CLI version")
.addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST)) .addOption(new Option("--host <host>", "Host interface to bind").env("CLI_HOST").default(DEFAULT_HOST))
.addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort)) .addOption(new Option("--port <number>", "Port for the HTTP server").env("CLI_PORT").default(DEFAULT_PORT).argParser(parsePort))
.addOption(new Option("--root <path>", "Workspace root directory").default(process.cwd())) .addOption(
new Option("--workspace-root <path>", "Workspace root directory").env("CLI_WORKSPACE_ROOT").default(process.cwd()),
)
.addOption(new Option("--root <path>").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>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH)) .addOption(new Option("--config <path>", "Path to the config file").env("CLI_CONFIG").default(DEFAULT_CONFIG_PATH))
.addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL")) .addOption(new Option("--log-level <level>", "Log level (trace|debug|info|warn|error)").env("CLI_LOG_LEVEL"))
.addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION")) .addOption(new Option("--log-destination <path>", "Log destination file (defaults to stdout)").env("CLI_LOG_DESTINATION"))
@@ -43,17 +48,22 @@ function parseCliOptions(argv: string[]): CliOptions {
const parsed = program.opts<{ const parsed = program.opts<{
host: string host: string
port: number port: number
root: string workspaceRoot?: string
root?: string
unrestrictedRoot?: boolean
config: string config: string
logLevel?: string logLevel?: string
logDestination?: string logDestination?: string
}>() }>()
const resolvedRoot = parsed.workspaceRoot ?? parsed.root ?? process.cwd()
return { return {
port: parsed.port, port: parsed.port,
host: parsed.host, host: parsed.host,
rootDir: parsed.root, rootDir: resolvedRoot,
configPath: parsed.config, configPath: parsed.config,
unrestrictedRoot: Boolean(parsed.unrestrictedRoot),
logLevel: parsed.logLevel, logLevel: parsed.logLevel,
logDestination: parsed.logDestination, logDestination: parsed.logDestination,
} }
@@ -86,7 +96,7 @@ async function main() {
eventBus, eventBus,
logger: workspaceLogger, logger: workspaceLogger,
}) })
const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir }) const fileSystemBrowser = new FileSystemBrowser({ rootDir: options.rootDir, unrestricted: options.unrestrictedRoot })
const instanceStore = new InstanceStore() const instanceStore = new InstanceStore()
const serverMeta: ServerMeta = { const serverMeta: ServerMeta = {

View File

@@ -8,18 +8,15 @@ 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), includeFiles: z.coerce.boolean().optional(),
includeFiles: z.coerce.boolean().default(true),
}) })
export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) { export function registerFilesystemRoutes(app: FastifyInstance, deps: RouteDeps) {
app.get("/api/filesystem", async (request, reply) => { app.get("/api/filesystem", async (request, reply) => {
const query = FilesystemQuerySchema.parse(request.query ?? {}) const query = FilesystemQuerySchema.parse(request.query ?? {})
const targetPath = query.path ?? "."
try { try {
return deps.fileSystemBrowser.list(targetPath, { return deps.fileSystemBrowser.browse(query.path, {
depth: query.depth,
includeFiles: query.includeFiles, includeFiles: query.includeFiles,
}) })
} catch (error) { } catch (error) {

View File

@@ -1,12 +1,36 @@
import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js" import { Component, Show, For, createSignal, createMemo, createEffect, onCleanup } from "solid-js"
import { ArrowUpLeft, Folder as FolderIcon, Loader2, X } from "lucide-solid" 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 { cliApi } from "../lib/api-client"
import { getServerMeta } from "../lib/server-meta"
const ROOT_KEY = "." function normalizePathKey(input?: string | null) {
const ROOT_REQUEST_PATH = "/" if (!input || input === "." || input === "./") {
const DEFAULT_DEPTH = 2 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 { interface DirectoryBrowserDialogProps {
open: boolean open: boolean
@@ -16,37 +40,16 @@ interface DirectoryBrowserDialogProps {
onClose: () => 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) { function resolveAbsolutePath(root: string, relativePath: string) {
if (!root) { if (!root) {
return relativePath return relativePath
} }
if (!relativePath || relativePath === "." || relativePath === "./" || relativePath === "/") { if (!relativePath || relativePath === "." || relativePath === "./") {
return root return root
} }
if (isAbsolutePathLike(relativePath)) {
return relativePath
}
const separator = root.includes("\\") ? "\\" : "/" const separator = root.includes("\\") ? "\\" : "/"
const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}` const trimmedRoot = root.endsWith(separator) ? root : `${root}${separator}`
const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "") const normalized = relativePath.replace(/[\\/]+/g, separator).replace(/^[\\/]+/, "")
@@ -63,14 +66,19 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
const [error, setError] = createSignal<string | null>(null) const [error, setError] = createSignal<string | null>(null)
const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map()) const [directoryChildren, setDirectoryChildren] = createSignal<Map<string, FileSystemEntry[]>>(new Map())
const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set()) const [loadingPaths, setLoadingPaths] = createSignal<Set<string>>(new Set())
const [loadedPaths, setLoadedPaths] = createSignal<Set<string>>(new Set()) const [currentPathKey, setCurrentPathKey] = createSignal<string | null>(null)
const [currentPath, setCurrentPath] = createSignal(ROOT_KEY) const [currentMetadata, setCurrentMetadata] = createSignal<FileSystemListingMetadata | null>(null)
const metadataCache = new Map<string, FileSystemListingMetadata>()
const inFlightRequests = new Map<string, Promise<FileSystemListingMetadata>>()
function resetState() { function resetState() {
setDirectoryChildren(new Map<string, FileSystemEntry[]>()) setDirectoryChildren(new Map<string, FileSystemEntry[]>())
setLoadingPaths(new Set<string>()) setLoadingPaths(new Set<string>())
setLoadedPaths(new Set<string>()) setCurrentPathKey(null)
setCurrentPath(ROOT_KEY) setCurrentMetadata(null)
metadataCache.clear()
inFlightRequests.clear()
setError(null) setError(null)
} }
@@ -97,9 +105,8 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
async function initialize() { async function initialize() {
setLoading(true) setLoading(true)
try { try {
const meta = await getServerMeta() const metadata = await loadDirectory()
setRootPath(meta.workspaceRoot) applyMetadata(metadata)
await ensureDirectoryLoaded(ROOT_KEY)
} 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)
@@ -108,86 +115,105 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
} }
} }
async function ensureDirectoryLoaded(path: string) { function applyMetadata(metadata: FileSystemListingMetadata) {
const normalized = normalizeRelativePath(path) const key = normalizePathKey(metadata.currentPath)
if (loadedPaths().has(normalized)) { setCurrentPathKey(key)
return setCurrentMetadata(metadata)
} setRootPath(metadata.rootPath)
await loadDirectory(normalized)
} }
async function loadDirectory(path: string) { async function loadDirectory(targetPath?: string): Promise<FileSystemListingMetadata> {
const normalized = normalizeRelativePath(path) const key = targetPath ? normalizePathKey(targetPath) : undefined
if (loadingPaths().has(normalized)) { if (key) {
return const cached = metadataCache.get(key)
if (cached) {
return cached
}
const pending = inFlightRequests.get(key)
if (pending) {
return pending
}
} }
const request = (async () => {
if (key) {
setLoadingPaths((prev) => { setLoadingPaths((prev) => {
const next = new Set(prev) const next = new Set(prev)
next.add(normalized) next.add(key)
return next 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 response = await cliApi.listFileSystem(targetPath, { includeFiles: false })
const grouped = new Map<string, FileSystemEntry[]>([[basePath, []]]) const canonicalKey = normalizePathKey(response.metadata.currentPath)
for (const entry of entries) { const directories = response.entries
if (entry.type !== "directory") { .filter((entry) => entry.type === "directory")
continue .sort((a, b) => a.name.localeCompare(b.name))
}
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) => { setDirectoryChildren((prev) => {
const next = new Map(prev) const next = new Map(prev)
for (const [parent, children] of grouped.entries()) { next.set(canonicalKey, directories)
const sorted = children.slice().sort((a, b) => a.name.localeCompare(b.name)) return next
next.set(parent, sorted) })
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 return next
}) })
} }
throw err
})
.finally(() => {
if (key) {
inFlightRequests.delete(key)
}
})
function handleEntrySelect(relativePath: string) { if (key) {
const absolute = resolveAbsolutePath(rootPath(), relativePath) inFlightRequests.set(key, request)
props.onSelect(absolute)
} }
function isPathLoading(path: string) { return request
return loadingPaths().has(normalizeRelativePath(path)) }
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)
}
} }
const folderRows = createMemo<FolderRow[]>(() => { const folderRows = createMemo<FolderRow[]>(() => {
const rows: FolderRow[] = [] const rows: FolderRow[] = []
if (currentPath() !== ROOT_KEY) { const metadata = currentMetadata()
rows.push({ type: "up", path: getParentPath(currentPath()) }) 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) { for (const entry of children) {
rows.push({ type: "folder", entry }) rows.push({ type: "folder", entry })
} }
@@ -195,16 +221,44 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
}) })
function handleNavigateTo(path: string) { function handleNavigateTo(path: string) {
const normalized = normalizeRelativePath(path) void navigateTo(path)
setCurrentPath(normalized)
void ensureDirectoryLoaded(normalized)
} }
function handleNavigateUp() { 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) { function handleOverlayClick(event: MouseEvent) {
if (event.target === event.currentTarget) { if (event.target === event.currentTarget) {
@@ -239,7 +293,13 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
<button <button
type="button" type="button"
class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select" class="selector-button selector-button-secondary directory-browser-select directory-browser-current-select"
onClick={() => handleEntrySelect(currentPath())} disabled={!canSelectCurrent()}
onClick={() => {
const absolute = currentAbsolutePath()
if (absolute) {
props.onSelect(absolute)
}
}}
> >
Select Current Select Current
</button> </button>
@@ -290,7 +350,7 @@ const DirectoryBrowserDialog: Component<DirectoryBrowserDialogProps> = (props) =
class="selector-button selector-button-secondary directory-browser-select" class="selector-button selector-button-secondary directory-browser-select"
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
handleEntrySelect(item.entry.path) handleEntrySelect(item.entry)
}} }}
> >
Select Select

View File

@@ -5,7 +5,6 @@ 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
type CacheListener = (entries: FileSystemEntry[]) => void type CacheListener = (entries: FileSystemEntry[]) => void
@@ -124,8 +123,8 @@ async function loadDirectory(path: string): Promise<void> {
} }
const promise = cliApi const promise = cliApi
.listFileSystem(normalized === "." ? "." : normalized, { depth: DEFAULT_DEPTH }) .listFileSystem(normalized === "." ? "." : normalized)
.then((entries) => { .then(({ entries }) => {
const changed = updateCache(entries) const changed = updateCache(entries)
fileSystemCache.loadedDirectories.add(normalized) fileSystemCache.loadedDirectories.add(normalized)
for (const entry of entries) { for (const entry of entries) {

View File

@@ -6,6 +6,7 @@ import type {
BinaryUpdateRequest, BinaryUpdateRequest,
BinaryValidationResult, BinaryValidationResult,
FileSystemEntry, FileSystemEntry,
FileSystemListResponse,
InstanceData, InstanceData,
ServerMeta, ServerMeta,
@@ -130,15 +131,16 @@ export const cliApi = {
body: JSON.stringify({ path }), body: JSON.stringify({ path }),
}) })
}, },
listFileSystem(relativePath = ".", options?: { depth?: number; includeFiles?: boolean }): Promise<FileSystemEntry[]> { listFileSystem(path?: string, options?: { includeFiles?: boolean }): Promise<FileSystemListResponse> {
const params = new URLSearchParams({ path: relativePath }) const params = new URLSearchParams()
if (options?.depth) { if (path && path !== ".") {
params.set("depth", String(options.depth)) params.set("path", path)
} }
if (options?.includeFiles !== undefined) { if (options?.includeFiles !== undefined) {
params.set("includeFiles", String(options.includeFiles)) params.set("includeFiles", String(options.includeFiles))
} }
return request<FileSystemEntry[]>(`/api/filesystem?${params.toString()}`) const query = params.toString()
return request<FileSystemListResponse>(query ? `/api/filesystem?${query}` : "/api/filesystem")
}, },
readInstanceData(id: string): Promise<InstanceData> { readInstanceData(id: string): Promise<InstanceData> {
return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`) return request<InstanceData>(`/api/storage/instances/${encodeURIComponent(id)}`)