Bug 1: Subagent events from gateway broadcasts were processed as parent events because the sessionKey filter was accidentally removed during the subagent decoupling refactor. Re-add the filter in wireChildProcess. Bug 2: Creating workspaces at custom paths failed because: - mkdir API rejected absolute paths outside workspace root - Directory picker started at workspace root, not home - Error responses from mkdir were silently swallowed Add absolute path support to mkdir, handle errors in picker UI, start picker at home dir, and normalize init route paths.
135 lines
3.6 KiB
TypeScript
135 lines
3.6 KiB
TypeScript
import { readdirSync, statSync, type Dirent } from "node:fs";
|
|
import { join, dirname, resolve } from "node:path";
|
|
import { homedir } from "node:os";
|
|
import { resolveWorkspaceRoot } from "@/lib/workspace";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
export const runtime = "nodejs";
|
|
|
|
type BrowseNode = {
|
|
name: string;
|
|
path: string; // absolute path
|
|
type: "folder" | "file" | "document" | "database";
|
|
children?: BrowseNode[];
|
|
symlink?: boolean;
|
|
};
|
|
|
|
/** Directories to skip when browsing the filesystem. */
|
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]);
|
|
|
|
/** Resolve a dirent's effective type, following symlinks to their target. */
|
|
function resolveEntryType(entry: Dirent, absPath: string): "directory" | "file" | null {
|
|
if (entry.isDirectory()) {return "directory";}
|
|
if (entry.isFile()) {return "file";}
|
|
if (entry.isSymbolicLink()) {
|
|
try {
|
|
const st = statSync(absPath);
|
|
if (st.isDirectory()) {return "directory";}
|
|
if (st.isFile()) {return "file";}
|
|
} catch {
|
|
// Broken symlink
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Build a depth-limited tree from an absolute directory. */
|
|
function buildBrowseTree(
|
|
absDir: string,
|
|
maxDepth: number,
|
|
currentDepth = 0,
|
|
showHidden = false,
|
|
): BrowseNode[] {
|
|
if (currentDepth >= maxDepth) {return [];}
|
|
|
|
let entries: Dirent[];
|
|
try {
|
|
entries = readdirSync(absDir, { withFileTypes: true });
|
|
} catch {
|
|
return [];
|
|
}
|
|
|
|
const filtered = entries
|
|
.filter((e) => showHidden || !e.name.startsWith("."))
|
|
.filter((e) => {
|
|
const absPath = join(absDir, e.name);
|
|
const t = resolveEntryType(e, absPath);
|
|
return !(t === "directory" && SKIP_DIRS.has(e.name));
|
|
});
|
|
|
|
const sorted = filtered.toSorted((a, b) => {
|
|
const absA = join(absDir, a.name);
|
|
const absB = join(absDir, b.name);
|
|
const typeA = resolveEntryType(a, absA);
|
|
const typeB = resolveEntryType(b, absB);
|
|
const dirA = typeA === "directory";
|
|
const dirB = typeB === "directory";
|
|
if (dirA && !dirB) {return -1;}
|
|
if (!dirA && dirB) {return 1;}
|
|
return a.name.localeCompare(b.name);
|
|
});
|
|
|
|
const nodes: BrowseNode[] = [];
|
|
|
|
for (const entry of sorted) {
|
|
const absPath = join(absDir, entry.name);
|
|
const isSymlink = entry.isSymbolicLink();
|
|
const effectiveType = resolveEntryType(entry, absPath);
|
|
|
|
if (effectiveType === "directory") {
|
|
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden);
|
|
nodes.push({
|
|
name: entry.name,
|
|
path: absPath,
|
|
type: "folder",
|
|
children: children.length > 0 ? children : undefined,
|
|
...(isSymlink && { symlink: true }),
|
|
});
|
|
} else if (effectiveType === "file") {
|
|
const ext = entry.name.split(".").pop()?.toLowerCase();
|
|
const isDocument = ext === "md" || ext === "mdx";
|
|
const isDatabase = ext === "duckdb" || ext === "sqlite" || ext === "sqlite3" || ext === "db";
|
|
|
|
nodes.push({
|
|
name: entry.name,
|
|
path: absPath,
|
|
type: isDatabase ? "database" : isDocument ? "document" : "file",
|
|
...(isSymlink && { symlink: true }),
|
|
});
|
|
}
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
|
|
export async function GET(req: Request) {
|
|
const url = new URL(req.url);
|
|
let dir = url.searchParams.get("dir");
|
|
const showHidden = url.searchParams.get("showHidden") === "1";
|
|
|
|
if (!dir) {
|
|
dir = resolveWorkspaceRoot();
|
|
}
|
|
|
|
if (!dir) {
|
|
return Response.json(
|
|
{ entries: [], currentDir: "/", parentDir: null },
|
|
);
|
|
}
|
|
|
|
if (dir.startsWith("~")) {
|
|
dir = join(homedir(), dir.slice(1));
|
|
}
|
|
|
|
const resolved = resolve(dir);
|
|
|
|
const entries = buildBrowseTree(resolved, 3, 0, showHidden);
|
|
const parentDir = resolved === "/" ? null : dirname(resolved);
|
|
|
|
return Response.json({
|
|
entries,
|
|
currentDir: resolved,
|
|
parentDir,
|
|
});
|
|
}
|