From 8a0520d7bd9ddffb406e6138d8619dd1d4968d00 Mon Sep 17 00:00:00 2001 From: kumarabhirup Date: Fri, 20 Feb 2026 00:42:38 -0800 Subject: [PATCH] web: add symlink support and hidden-files toggle across workspace --- apps/web/app/api/workspace/browse/route.ts | 60 ++++++++++---- apps/web/app/api/workspace/tree/route.ts | 78 +++++++++++++------ .../workspace/file-manager-tree.tsx | 17 ++++ .../workspace/workspace-sidebar.tsx | 44 ++++++++++- apps/web/app/hooks/use-workspace-watcher.ts | 17 ++-- 5 files changed, 172 insertions(+), 44 deletions(-) diff --git a/apps/web/app/api/workspace/browse/route.ts b/apps/web/app/api/workspace/browse/route.ts index 3304bfbb29e..8fa024977c6 100644 --- a/apps/web/app/api/workspace/browse/route.ts +++ b/apps/web/app/api/workspace/browse/route.ts @@ -1,4 +1,4 @@ -import { readdirSync, type Dirent } from "node:fs"; +import { readdirSync, statSync, type Dirent } from "node:fs"; import { join, dirname, resolve } from "node:path"; import { resolveWorkspaceRoot } from "@/lib/workspace"; @@ -10,16 +10,34 @@ type BrowseNode = { 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 [];} @@ -30,29 +48,43 @@ function buildBrowseTree( return []; } - const sorted = entries - .filter((e) => !e.name.startsWith(".")) - .filter((e) => !(e.isDirectory() && SKIP_DIRS.has(e.name))) - .toSorted((a, b) => { - if (a.isDirectory() && !b.isDirectory()) {return -1;} - if (!a.isDirectory() && b.isDirectory()) {return 1;} - return a.name.localeCompare(b.name); + 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 (entry.isDirectory()) { - const children = buildBrowseTree(absPath, maxDepth, currentDepth + 1); + 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 (entry.isFile()) { + } 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"; @@ -61,6 +93,7 @@ function buildBrowseTree( name: entry.name, path: absPath, type: isDatabase ? "database" : isDocument ? "document" : "file", + ...(isSymlink && { symlink: true }), }); } } @@ -71,8 +104,8 @@ function buildBrowseTree( 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"; - // Default to the workspace root if (!dir) { dir = resolveWorkspaceRoot(); } @@ -83,10 +116,9 @@ export async function GET(req: Request) { ); } - // Resolve and normalize the directory path const resolved = resolve(dir); - const entries = buildBrowseTree(resolved, 3); + const entries = buildBrowseTree(resolved, 3, 0, showHidden); const parentDir = resolved === "/" ? null : dirname(resolved); return Response.json({ diff --git a/apps/web/app/api/workspace/tree/route.ts b/apps/web/app/api/workspace/tree/route.ts index 30e759bb3c4..c603d38b4eb 100644 --- a/apps/web/app/api/workspace/tree/route.ts +++ b/apps/web/app/api/workspace/tree/route.ts @@ -1,4 +1,4 @@ -import { readdirSync, readFileSync, existsSync, type Dirent } from "node:fs"; +import { readdirSync, readFileSync, existsSync, statSync, type Dirent } from "node:fs"; import { join } from "node:path"; import { resolveWorkspaceRoot, resolveOpenClawStateDir, getEffectiveProfile, parseSimpleYaml, duckdbQueryAll, isDatabaseFile } from "@/lib/workspace"; @@ -14,6 +14,8 @@ export type TreeNode = { children?: TreeNode[]; /** Virtual nodes live outside the main workspace (e.g. Skills, Memories). */ virtual?: boolean; + /** True when the entry is a symbolic link. */ + symlink?: boolean; }; type DbObject = { @@ -58,11 +60,28 @@ function loadDbObjects(): Map { return map; } +/** 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 -- skip + } + } + return null; +} + /** Recursively build a tree from a workspace directory. */ function buildTree( absDir: string, relativeBase: string, dbObjects: Map, + showHidden = false, ): TreeNode[] { const nodes: TreeNode[] = []; @@ -73,32 +92,44 @@ function buildTree( return nodes; } + const filtered = entries.filter((e) => { + // .object.yaml is always needed for metadata; also shown as a node when showHidden is on + if (e.name === ".object.yaml") {return true;} + if (e.name.startsWith(".")) {return showHidden;} + return true; + }); + // Sort: directories first, then files, alphabetical within each group - const sorted = entries - .filter((e) => !e.name.startsWith(".") || e.name === ".object.yaml") - .toSorted((a, b) => { - if (a.isDirectory() && !b.isDirectory()) {return -1;} - if (!a.isDirectory() && b.isDirectory()) {return 1;} - return a.name.localeCompare(b.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); + }); for (const entry of sorted) { - // Skip hidden files except .object.yaml (but don't list it as a node) - if (entry.name === ".object.yaml") {continue;} - if (entry.name.startsWith(".")) {continue;} + // .object.yaml is consumed for metadata; only show it as a visible node when revealing hidden files + if (entry.name === ".object.yaml" && !showHidden) {continue;} const absPath = join(absDir, entry.name); const relPath = relativeBase ? `${relativeBase}/${entry.name}` : entry.name; - if (entry.isDirectory()) { + const isSymlink = entry.isSymbolicLink(); + const effectiveType = resolveEntryType(entry, absPath); + + if (effectiveType === "directory") { const objectMeta = readObjectMeta(absPath); const dbObject = dbObjects.get(entry.name); - const children = buildTree(absPath, relPath, dbObjects); + const children = buildTree(absPath, relPath, dbObjects, showHidden); if (objectMeta || dbObject) { - // This directory represents a CRM object (from .object.yaml OR DuckDB) nodes.push({ name: entry.name, path: relPath, @@ -109,17 +140,18 @@ function buildTree( | "table" | "kanban") ?? "table", children: children.length > 0 ? children : undefined, + ...(isSymlink && { symlink: true }), }); } else { - // Regular folder nodes.push({ name: entry.name, path: relPath, type: "folder", children: children.length > 0 ? children : undefined, + ...(isSymlink && { symlink: true }), }); } - } else if (entry.isFile()) { + } else if (effectiveType === "file") { const ext = entry.name.split(".").pop()?.toLowerCase(); const isReport = entry.name.endsWith(".report.json"); const isDocument = ext === "md" || ext === "mdx"; @@ -129,6 +161,7 @@ function buildTree( name: entry.name, path: relPath, type: isReport ? "report" : isDatabase ? "database" : isDocument ? "document" : "file", + ...(isSymlink && { symlink: true }), }); } } @@ -206,27 +239,24 @@ function buildSkillsVirtualFolder(): TreeNode | null { } -export async function GET() { +export async function GET(req: Request) { + const url = new URL(req.url); + const showHidden = url.searchParams.get("showHidden") === "1"; + const openclawDir = resolveOpenClawStateDir(); const profile = getEffectiveProfile(); const root = resolveWorkspaceRoot(); if (!root) { - // Even without a workspace, return virtual folders if they exist const tree: TreeNode[] = []; const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} return Response.json({ tree, exists: false, workspaceRoot: null, openclawDir, profile }); } - // Load objects from DuckDB for smart directory detection const dbObjects = loadDbObjects(); - // Scan the workspace root — it IS the knowledge base. - // All top-level directories, files, objects, and documents are visible - // in the sidebar (USER.md, SOUL.md, memory/, etc. are all part of the tree). - const tree = buildTree(root, "", dbObjects); + const tree = buildTree(root, "", dbObjects, showHidden); - // Virtual folders go after all real files/folders const skillsFolder = buildSkillsVirtualFolder(); if (skillsFolder) {tree.push(skillsFolder);} diff --git a/apps/web/app/components/workspace/file-manager-tree.tsx b/apps/web/app/components/workspace/file-manager-tree.tsx index 94bf8aaec19..1b2a5fe0c1b 100644 --- a/apps/web/app/components/workspace/file-manager-tree.tsx +++ b/apps/web/app/components/workspace/file-manager-tree.tsx @@ -28,6 +28,8 @@ export type TreeNode = { children?: TreeNode[]; /** When true, the node represents a virtual folder/file outside the real workspace (e.g. Skills, Memories). CRUD ops are disabled. */ virtual?: boolean; + /** True when the entry is a symbolic link / shortcut. */ + symlink?: boolean; }; /** Folder names reserved for virtual sections -- cannot be created/renamed to. */ @@ -156,6 +158,14 @@ function LockBadge() { ); } +function SymlinkBadge() { + return ( + + + + ); +} + function ChevronIcon({ open }: { open: boolean }) { return ( )} + {/* Symlink indicator */} + {node.symlink && !compact && ( + + + + )} + {/* Type badge for objects */} {node.type === "object" && ( void; + /** Whether hidden (dot) files/folders are currently shown. */ + showHidden?: boolean; + /** Toggle hidden files visibility. */ + onToggleHidden?: () => void; }; function HomeIcon() { @@ -401,6 +405,8 @@ export function WorkspaceSidebar({ onClose, activeProfile, onProfileSwitch, + showHidden, + onToggleHidden, }: WorkspaceSidebarProps) { const isBrowsing = browseDir != null; const [showCreateWorkspace, setShowCreateWorkspace] = useState(false); @@ -579,7 +585,43 @@ export function WorkspaceSidebar({ > ironclaw.sh - +
+ {onToggleHidden && ( + + )} + +
); diff --git a/apps/web/app/hooks/use-workspace-watcher.ts b/apps/web/app/hooks/use-workspace-watcher.ts index 44daf617d0f..f5c8aa6b13e 100644 --- a/apps/web/app/hooks/use-workspace-watcher.ts +++ b/apps/web/app/hooks/use-workspace-watcher.ts @@ -9,6 +9,8 @@ export type TreeNode = { icon?: string; defaultView?: "table" | "kanban"; children?: TreeNode[]; + /** True when the entry is a symbolic link. */ + symlink?: boolean; }; /** @@ -30,6 +32,9 @@ export function useWorkspaceWatcher() { const [openclawDir, setOpenclawDir] = useState(null); const [activeProfile, setActiveProfile] = useState(null); + // Show hidden (dot) files/folders + const [showHidden, setShowHidden] = useState(false); + const mountedRef = useRef(true); const retryDelayRef = useRef(1000); // Version counter: prevents stale fetch responses from overwriting newer data. @@ -44,7 +49,8 @@ export function useWorkspaceWatcher() { const fetchWorkspaceTree = useCallback(async () => { const version = ++fetchVersionRef.current; try { - const res = await fetch("/api/workspace/tree"); + const qs = showHidden ? "?showHidden=1" : ""; + const res = await fetch(`/api/workspace/tree${qs}`); const data = await res.json(); if (mountedRef.current && fetchVersionRef.current === version) { setTree(data.tree ?? []); @@ -57,14 +63,15 @@ export function useWorkspaceWatcher() { } catch { if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);} } - }, []); + }, [showHidden]); // Fetch a directory listing from the browse API const fetchBrowseTree = useCallback(async (dir: string) => { const version = ++fetchVersionRef.current; try { setLoading(true); - const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}`); + const hiddenQs = showHidden ? "&showHidden=1" : ""; + const res = await fetch(`/api/workspace/browse?dir=${encodeURIComponent(dir)}${hiddenQs}`); const data = await res.json(); if (mountedRef.current && fetchVersionRef.current === version) { setTree(data.entries ?? []); @@ -75,7 +82,7 @@ export function useWorkspaceWatcher() { } catch { if (mountedRef.current && fetchVersionRef.current === version) {setLoading(false);} } - }, []); + }, [showHidden]); // Smart setBrowseDir: auto-return to workspace mode when navigating to the // workspace root, so all virtual folders (Chats, Cron, etc.) and DuckDB @@ -211,5 +218,5 @@ export function useWorkspaceWatcher() { }; }, [browseDirRaw, fetchWorkspaceTree, sseReconnectKey]); - return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile }; + return { tree, loading, exists, refresh, reconnect, browseDir, setBrowseDir, parentDir, workspaceRoot, openclawDir, activeProfile, showHidden, setShowHidden }; }