add unrestricted filesystem browsing mode
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user