web: add symlink support and hidden-files toggle across workspace
This commit is contained in:
parent
d86c4fb5b5
commit
8a0520d7bd
@ -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({
|
||||
|
||||
@ -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<string, DbObject> {
|
||||
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<string, DbObject>,
|
||||
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);}
|
||||
|
||||
|
||||
@ -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 (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.55 }}>
|
||||
<path d="m9 18 6-6-6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
|
||||
@ -566,6 +576,13 @@ function DraggableNode({
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Symlink indicator */}
|
||||
{node.symlink && !compact && (
|
||||
<span className="flex-shrink-0 ml-0.5" title="Symbolic link" style={{ color: "var(--color-text-muted)" }}>
|
||||
<SymlinkBadge />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Type badge for objects */}
|
||||
{node.type === "object" && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
|
||||
|
||||
@ -43,6 +43,10 @@ type WorkspaceSidebarProps = {
|
||||
activeProfile?: string | null;
|
||||
/** Called after the user switches to a different profile. */
|
||||
onProfileSwitch?: () => 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
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
<div className="flex items-center gap-0.5">
|
||||
{onToggleHidden && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleHidden}
|
||||
className="p-1.5 rounded-lg transition-colors"
|
||||
style={{ color: showHidden ? "var(--color-accent)" : "var(--color-text-muted)" }}
|
||||
title={showHidden ? "Hide dotfiles" : "Show dotfiles"}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{showHidden ? (
|
||||
<>
|
||||
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<path d="M10.733 5.076a10.744 10.744 0 0 1 11.205 6.575 1 1 0 0 1 0 .696 10.747 10.747 0 0 1-1.444 2.49" />
|
||||
<path d="M14.084 14.158a3 3 0 0 1-4.242-4.242" />
|
||||
<path d="M17.479 17.499a10.75 10.75 0 0 1-15.417-5.151 1 1 0 0 1 0-.696 10.75 10.75 0 0 1 4.446-5.143" />
|
||||
<path d="m2 2 20 20" />
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
|
||||
@ -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<string | null>(null);
|
||||
const [activeProfile, setActiveProfile] = useState<string | null>(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 };
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user