2026-02-21 13:10:32 -08:00
|
|
|
import { readdirSync, statSync, type Dirent } from "node:fs";
|
2026-02-13 15:16:36 -08:00
|
|
|
import { join, dirname, resolve } from "node:path";
|
2026-02-21 13:45:11 -08:00
|
|
|
import { homedir } from "node:os";
|
2026-02-15 18:18:15 -08:00
|
|
|
import { resolveWorkspaceRoot } from "@/lib/workspace";
|
2026-02-13 15:16:36 -08:00
|
|
|
|
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
|
|
|
|
|
|
type BrowseNode = {
|
|
|
|
|
name: string;
|
|
|
|
|
path: string; // absolute path
|
|
|
|
|
type: "folder" | "file" | "document" | "database";
|
|
|
|
|
children?: BrowseNode[];
|
2026-02-21 13:10:32 -08:00
|
|
|
symlink?: boolean;
|
2026-02-13 15:16:36 -08:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** Directories to skip when browsing the filesystem. */
|
|
|
|
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".Trash", "__pycache__", ".cache"]);
|
|
|
|
|
|
2026-02-21 13:10:32 -08:00
|
|
|
/** 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:16:36 -08:00
|
|
|
/** Build a depth-limited tree from an absolute directory. */
|
|
|
|
|
function buildBrowseTree(
|
|
|
|
|
absDir: string,
|
|
|
|
|
maxDepth: number,
|
|
|
|
|
currentDepth = 0,
|
2026-02-21 13:10:32 -08:00
|
|
|
showHidden = false,
|
2026-02-13 15:16:36 -08:00
|
|
|
): BrowseNode[] {
|
|
|
|
|
if (currentDepth >= maxDepth) {return [];}
|
|
|
|
|
|
|
|
|
|
let entries: Dirent[];
|
|
|
|
|
try {
|
|
|
|
|
entries = readdirSync(absDir, { withFileTypes: true });
|
|
|
|
|
} catch {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 13:10:32 -08:00
|
|
|
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));
|
2026-02-13 15:16:36 -08:00
|
|
|
});
|
|
|
|
|
|
2026-02-21 13:10:32 -08:00
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-13 15:16:36 -08:00
|
|
|
const nodes: BrowseNode[] = [];
|
|
|
|
|
|
|
|
|
|
for (const entry of sorted) {
|
|
|
|
|
const absPath = join(absDir, entry.name);
|
2026-02-21 13:10:32 -08:00
|
|
|
const isSymlink = entry.isSymbolicLink();
|
|
|
|
|
const effectiveType = resolveEntryType(entry, absPath);
|
2026-02-13 15:16:36 -08:00
|
|
|
|
2026-02-21 13:10:32 -08:00
|
|
|
if (effectiveType === "directory") {
|
|
|
|
|
const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1, showHidden);
|
2026-02-13 15:16:36 -08:00
|
|
|
nodes.push({
|
|
|
|
|
name: entry.name,
|
|
|
|
|
path: absPath,
|
|
|
|
|
type: "folder",
|
|
|
|
|
children: children.length > 0 ? children : undefined,
|
2026-02-21 13:10:32 -08:00
|
|
|
...(isSymlink && { symlink: true }),
|
2026-02-13 15:16:36 -08:00
|
|
|
});
|
2026-02-21 13:10:32 -08:00
|
|
|
} else if (effectiveType === "file") {
|
2026-02-13 15:16:36 -08:00
|
|
|
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",
|
2026-02-21 13:10:32 -08:00
|
|
|
...(isSymlink && { symlink: true }),
|
2026-02-13 15:16:36 -08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nodes;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function GET(req: Request) {
|
|
|
|
|
const url = new URL(req.url);
|
|
|
|
|
let dir = url.searchParams.get("dir");
|
2026-02-21 13:10:32 -08:00
|
|
|
const showHidden = url.searchParams.get("showHidden") === "1";
|
2026-02-13 15:16:36 -08:00
|
|
|
|
|
|
|
|
if (!dir) {
|
2026-02-15 18:18:15 -08:00
|
|
|
dir = resolveWorkspaceRoot();
|
2026-02-13 15:16:36 -08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!dir) {
|
|
|
|
|
return Response.json(
|
|
|
|
|
{ entries: [], currentDir: "/", parentDir: null },
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 13:45:11 -08:00
|
|
|
if (dir.startsWith("~")) {
|
|
|
|
|
dir = join(homedir(), dir.slice(1));
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-13 15:16:36 -08:00
|
|
|
const resolved = resolve(dir);
|
|
|
|
|
|
2026-02-21 13:10:32 -08:00
|
|
|
const entries = buildBrowseTree(resolved, 3, 0, showHidden);
|
2026-02-13 15:16:36 -08:00
|
|
|
const parentDir = resolved === "/" ? null : dirname(resolved);
|
|
|
|
|
|
|
|
|
|
return Response.json({
|
|
|
|
|
entries,
|
|
|
|
|
currentDir: resolved,
|
|
|
|
|
parentDir,
|
|
|
|
|
});
|
|
|
|
|
}
|