Add virtual folder system that surfaces Skills, Memories, and Chat sessions in the workspace sidebar alongside real dench files. Rearchitect the home page into a landing hub and move the ChatPanel into the workspace as the default view. New API route — virtual-file: - apps/web/app/api/workspace/virtual-file/route.ts: new GET/POST API that resolves virtual paths (~skills/*, ~memories/*) to absolute filesystem paths in ~/.openclaw/skills/ d ~/.openclaw/workspace/. Includes path traversal protection and directory allowlisting. Tree API — virtual folder builders: - apps/web/app/api/workspace/tree/route.ts: add `virtual` field to TreeNode type. Add ildSkillsVirtualFolder() scanning ~/.openclaw/skills/ and ~/.openclaw/workspace/skills/ with SKILL.md frontmatter parsing (name + emoji). Add buildMemoriesVirtualFolder() scanning MEMORY.md and daily logs from ~/.openclaw/workspace/memory/. Virtual folders are appended after real workspace entries and are also returned when no dench root exists. File manager tree — virtual node awareness: - apps/web/app/components/workspace/file-manager-tree.tsx: add isVirtualNode() helper and RESERVED_FOLDER_NAMES set (Chats, Skills, Memories). Virtual nodes show a lock badge, disable drag-and-drop, block rename/delete, and reject reserved names during create/rename. Add ChatBubbleIcon for ~chats/ paths. Markdown editor — virtual path routing: - apps/web/app/components/workspace/markdown-editor.tsx: save to /api/workspace/virtual-file for paths starting with ~ instead of the regular /api/workspace/file endpoint. Home page redesign: - apps/web/app/page.tsx: replace the chat-first layout (SidebarhatPanel) with a branded landing page showing the OpenClaw Dench heading, tagline, and a single "Open Workspace" CTA linking to /workspace. Workspace page — unified layout with integrated chat: - apps/web/app/workspace/page.tsx: ChatPanel is now the default main view when no file is selected. Add session fetching from /api/web-sessions and build an enhanced tree with a virtual "Chats" folder listing all sessions. Clicking ~chats/<id> loads the session; clicking ~chats starts a new one. Add isVirtualPath()/fileApiUrl() helpers for virtual file reads. Add a "Back to chat" button in the top bar alongside the chat sidebar toggle. Sidebar + empty-state cosmetic updates: - apps/web/app/components/workspace/workspace-sidebar.tsx: rename BackIcon to HomeIcon (house SVG), change label from "Back to Chat" to "Home". - apps/web/app/components/workspace/empty-state.tsx: update link text from "Back to Chat" to "Back to Home".
1061 lines
33 KiB
TypeScript
1061 lines
33 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useRef, useEffect } from "react";
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
useDraggable,
|
|
useDroppable,
|
|
closestCenter,
|
|
type DragStartEvent,
|
|
type DragEndEvent,
|
|
type DragOverEvent,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
} from "@dnd-kit/core";
|
|
import { ContextMenu, type ContextMenuAction, type ContextMenuTarget } from "./context-menu";
|
|
import { InlineRename, RENAME_SHAKE_STYLE } from "./inline-rename";
|
|
|
|
// --- Types ---
|
|
|
|
export type TreeNode = {
|
|
name: string;
|
|
path: string;
|
|
type: "object" | "document" | "folder" | "file" | "database" | "report";
|
|
icon?: string;
|
|
defaultView?: "table" | "kanban";
|
|
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;
|
|
};
|
|
|
|
/** Folder names reserved for virtual sections -- cannot be created/renamed to. */
|
|
const RESERVED_FOLDER_NAMES = new Set(["Chats", "Skills", "Memories"]);
|
|
|
|
/** Check if a node (or any of its ancestors) is virtual. Paths starting with ~ are always virtual. */
|
|
function isVirtualNode(node: TreeNode): boolean {
|
|
return !!node.virtual || node.path.startsWith("~");
|
|
}
|
|
|
|
type FileManagerTreeProps = {
|
|
tree: TreeNode[];
|
|
activePath: string | null;
|
|
onSelect: (node: TreeNode) => void;
|
|
onRefresh: () => void;
|
|
compact?: boolean;
|
|
};
|
|
|
|
// --- System file detection (client-side mirror) ---
|
|
|
|
const SYSTEM_PATTERNS = [
|
|
/^\.object\.yaml$/,
|
|
/^workspace\.duckdb/,
|
|
/^workspace_context\.yaml$/,
|
|
/\.wal$/,
|
|
/\.tmp$/,
|
|
];
|
|
|
|
function isSystemFile(path: string): boolean {
|
|
const base = path.split("/").pop() ?? "";
|
|
return SYSTEM_PATTERNS.some((p) => p.test(base));
|
|
}
|
|
|
|
// --- Icons (inline SVG, zero-dep) ---
|
|
|
|
function FolderIcon({ open }: { open?: boolean }) {
|
|
return open ? (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" />
|
|
</svg>
|
|
) : (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function TableIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M12 3v18" /><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M3 9h18" /><path d="M3 15h18" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function KanbanIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<rect width="6" height="14" x="2" y="5" rx="1" /><rect width="6" height="10" x="9" y="5" rx="1" /><rect width="6" height="16" x="16" y="3" rx="1" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function DocumentIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" /><path d="M10 9H8" /><path d="M16 13H8" /><path d="M16 17H8" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function FileIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /><path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function DatabaseIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<ellipse cx="12" cy="5" rx="9" ry="3" /><path d="M3 5V19A9 3 0 0 0 21 19V5" /><path d="M3 12A9 3 0 0 0 21 12" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ReportIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" x2="12" y1="20" y2="10" /><line x1="18" x2="18" y1="20" y2="4" /><line x1="6" x2="6" y1="20" y2="14" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function ChatBubbleIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LockBadge() {
|
|
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.4 }}>
|
|
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</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"
|
|
style={{ transform: open ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 150ms ease" }}>
|
|
<path d="m9 18 6-6-6-6" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function NodeIcon({ node, open }: { node: TreeNode; open?: boolean }) {
|
|
// Chat items use the chat bubble icon
|
|
if (node.path.startsWith("~chats/") || node.path === "~chats") {
|
|
return <ChatBubbleIcon />;
|
|
}
|
|
switch (node.type) {
|
|
case "object":
|
|
return node.defaultView === "kanban" ? <KanbanIcon /> : <TableIcon />;
|
|
case "document":
|
|
return <DocumentIcon />;
|
|
case "folder":
|
|
return <FolderIcon open={open} />;
|
|
case "database":
|
|
return <DatabaseIcon />;
|
|
case "report":
|
|
return <ReportIcon />;
|
|
default:
|
|
return <FileIcon />;
|
|
}
|
|
}
|
|
|
|
function typeColor(node: TreeNode): string {
|
|
switch (node.type) {
|
|
case "object": return "var(--color-accent)";
|
|
case "document": return "#60a5fa";
|
|
case "database": return "#c084fc";
|
|
case "report": return "#22c55e";
|
|
default: return "var(--color-text-muted)";
|
|
}
|
|
}
|
|
|
|
// --- API helpers ---
|
|
|
|
async function apiRename(path: string, newName: string) {
|
|
const res = await fetch("/api/workspace/rename", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path, newName }),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function apiDelete(path: string) {
|
|
const res = await fetch("/api/workspace/file", {
|
|
method: "DELETE",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path }),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function apiMove(sourcePath: string, destinationDir: string) {
|
|
const res = await fetch("/api/workspace/move", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ sourcePath, destinationDir }),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function apiDuplicate(path: string) {
|
|
const res = await fetch("/api/workspace/copy", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path }),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function apiMkdir(path: string) {
|
|
const res = await fetch("/api/workspace/mkdir", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path }),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
async function apiCreateFile(path: string, content: string = "") {
|
|
const res = await fetch("/api/workspace/file", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ path, content }),
|
|
});
|
|
return res.json();
|
|
}
|
|
|
|
// --- Confirm dialog ---
|
|
|
|
function ConfirmDialog({
|
|
message,
|
|
onConfirm,
|
|
onCancel,
|
|
}: {
|
|
message: string;
|
|
onConfirm: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
useEffect(() => {
|
|
function handleKey(e: KeyboardEvent) {
|
|
if (e.key === "Escape") {onCancel();}
|
|
if (e.key === "Enter") {onConfirm();}
|
|
}
|
|
document.addEventListener("keydown", handleKey);
|
|
return () => document.removeEventListener("keydown", handleKey);
|
|
}, [onConfirm, onCancel]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
|
|
<div className="rounded-xl p-5 max-w-sm w-full shadow-2xl border" style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}>
|
|
<p className="text-sm mb-4" style={{ color: "var(--color-text)" }}>{message}</p>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-3 py-1.5 rounded-md text-sm"
|
|
style={{ color: "var(--color-text-muted)", background: "var(--color-surface-hover)" }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onConfirm}
|
|
className="px-3 py-1.5 rounded-md text-sm text-white"
|
|
style={{ background: "#ef4444" }}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- New item prompt ---
|
|
|
|
function NewItemPrompt({
|
|
kind,
|
|
parentPath,
|
|
onSubmit,
|
|
onCancel,
|
|
}: {
|
|
kind: "file" | "folder";
|
|
parentPath: string;
|
|
onSubmit: (name: string) => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const [value, setValue] = useState(kind === "file" ? "untitled.md" : "new-folder");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
useEffect(() => {
|
|
const el = inputRef.current;
|
|
if (!el) {return;}
|
|
el.focus();
|
|
if (kind === "file") {
|
|
const dot = value.lastIndexOf(".");
|
|
el.setSelectionRange(0, dot > 0 ? dot : value.length);
|
|
} else {
|
|
el.select();
|
|
}
|
|
}, []);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center" style={{ background: "rgba(0,0,0,0.5)" }}>
|
|
<div className="rounded-xl p-5 max-w-sm w-full shadow-2xl border" style={{ background: "var(--color-surface)", borderColor: "var(--color-border)" }}>
|
|
<p className="text-sm mb-3 font-medium" style={{ color: "var(--color-text)" }}>
|
|
New {kind} in <span style={{ color: "var(--color-accent)" }}>{parentPath || "/"}</span>
|
|
</p>
|
|
<input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={value}
|
|
onChange={(e) => setValue(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {onSubmit(value.trim());}
|
|
if (e.key === "Escape") {onCancel();}
|
|
}}
|
|
className="w-full px-3 py-2 rounded-md text-sm outline-none border"
|
|
style={{ background: "var(--color-bg)", color: "var(--color-text)", borderColor: "var(--color-border)" }}
|
|
/>
|
|
<div className="flex justify-end gap-2 mt-3">
|
|
<button type="button" onClick={onCancel} className="px-3 py-1.5 rounded-md text-sm" style={{ color: "var(--color-text-muted)", background: "var(--color-surface-hover)" }}>
|
|
Cancel
|
|
</button>
|
|
<button type="button" onClick={() => onSubmit(value.trim())} className="px-3 py-1.5 rounded-md text-sm text-white" style={{ background: "var(--color-accent)" }}>
|
|
Create
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Draggable + Droppable Node ---
|
|
|
|
function DraggableNode({
|
|
node,
|
|
depth,
|
|
activePath,
|
|
selectedPath,
|
|
onSelect,
|
|
onNodeSelect,
|
|
expandedPaths,
|
|
onToggleExpand,
|
|
renamingPath,
|
|
onStartRename,
|
|
onCommitRename,
|
|
onCancelRename,
|
|
onContextMenu,
|
|
compact,
|
|
dragOverPath,
|
|
}: {
|
|
node: TreeNode;
|
|
depth: number;
|
|
activePath: string | null;
|
|
selectedPath: string | null;
|
|
onSelect: (node: TreeNode) => void;
|
|
onNodeSelect: (path: string) => void;
|
|
expandedPaths: Set<string>;
|
|
onToggleExpand: (path: string) => void;
|
|
renamingPath: string | null;
|
|
onStartRename: (path: string) => void;
|
|
onCommitRename: (newName: string) => void;
|
|
onCancelRename: () => void;
|
|
onContextMenu: (e: React.MouseEvent, node: TreeNode) => void;
|
|
compact?: boolean;
|
|
dragOverPath: string | null;
|
|
}) {
|
|
const hasChildren = node.children && node.children.length > 0;
|
|
const isExpandable = hasChildren || node.type === "folder" || node.type === "object";
|
|
const isExpanded = expandedPaths.has(node.path);
|
|
const isActive = activePath === node.path;
|
|
const isSelected = selectedPath === node.path;
|
|
const isRenaming = renamingPath === node.path;
|
|
const isSysFile = isSystemFile(node.path);
|
|
const isVirtual = isVirtualNode(node);
|
|
const isProtected = isSysFile || isVirtual;
|
|
const isDragOver = dragOverPath === node.path && isExpandable;
|
|
|
|
const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({
|
|
id: `drag-${node.path}`,
|
|
data: { node },
|
|
disabled: isProtected,
|
|
});
|
|
|
|
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
|
id: `drop-${node.path}`,
|
|
data: { node },
|
|
disabled: !isExpandable || isVirtual,
|
|
});
|
|
|
|
const handleClick = useCallback(() => {
|
|
onNodeSelect(node.path);
|
|
onSelect(node);
|
|
if (isExpandable) {
|
|
onToggleExpand(node.path);
|
|
}
|
|
}, [node, isExpandable, onSelect, onNodeSelect, onToggleExpand]);
|
|
|
|
const handleDoubleClick = useCallback(() => {
|
|
if (!isProtected) {
|
|
onStartRename(node.path);
|
|
}
|
|
}, [node.path, isProtected, onStartRename]);
|
|
|
|
const handleContextMenu = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onNodeSelect(node.path);
|
|
onContextMenu(e, node);
|
|
},
|
|
[node, onNodeSelect, onContextMenu],
|
|
);
|
|
|
|
// Merge drag + drop refs
|
|
const mergedRef = useCallback(
|
|
(el: HTMLElement | null) => {
|
|
setDragRef(el);
|
|
setDropRef(el);
|
|
},
|
|
[setDragRef, setDropRef],
|
|
);
|
|
|
|
const showDropHighlight = (isOver || isDragOver) && isExpandable;
|
|
|
|
return (
|
|
<div style={{ opacity: isDragging ? 0.4 : 1 }}>
|
|
<div
|
|
ref={mergedRef}
|
|
{...attributes}
|
|
{...listeners}
|
|
role="treeitem"
|
|
tabIndex={-1}
|
|
onClick={handleClick}
|
|
onDoubleClick={handleDoubleClick}
|
|
onContextMenu={handleContextMenu}
|
|
className="w-full flex items-center gap-1.5 py-1 px-2 rounded-md text-left text-sm transition-colors duration-100 cursor-pointer select-none"
|
|
style={{
|
|
paddingLeft: `${depth * 16 + 8}px`,
|
|
background: showDropHighlight
|
|
? "rgba(232, 93, 58, 0.12)"
|
|
: isSelected
|
|
? "var(--color-surface-hover)"
|
|
: isActive
|
|
? "var(--color-surface-hover)"
|
|
: "transparent",
|
|
color: isActive || isSelected ? "var(--color-text)" : "var(--color-text-muted)",
|
|
outline: showDropHighlight ? "1px dashed var(--color-accent)" : "none",
|
|
outlineOffset: "-1px",
|
|
borderRadius: "6px",
|
|
}}
|
|
onMouseEnter={(e) => {
|
|
if (!isActive && !isSelected && !showDropHighlight) {
|
|
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
|
|
}
|
|
}}
|
|
onMouseLeave={(e) => {
|
|
if (!isActive && !isSelected && !showDropHighlight) {
|
|
(e.currentTarget as HTMLElement).style.background = "transparent";
|
|
}
|
|
}}
|
|
>
|
|
{/* Expand/collapse chevron */}
|
|
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center" style={{ opacity: isExpandable ? 1 : 0 }}>
|
|
{isExpandable && <ChevronIcon open={isExpanded} />}
|
|
</span>
|
|
|
|
{/* Icon */}
|
|
<span className="flex-shrink-0 flex items-center" style={{ color: typeColor(node) }}>
|
|
<NodeIcon node={node} open={isExpanded} />
|
|
</span>
|
|
|
|
{/* Label or rename input */}
|
|
{isRenaming ? (
|
|
<InlineRename
|
|
currentName={node.name}
|
|
onCommit={onCommitRename}
|
|
onCancel={onCancelRename}
|
|
/>
|
|
) : (
|
|
<span className="truncate flex-1">{node.name.replace(/\.md$/, "")}</span>
|
|
)}
|
|
|
|
{/* Lock badge for system/virtual files */}
|
|
{isProtected && !compact && (
|
|
<span className="flex-shrink-0 ml-1">
|
|
<LockBadge />
|
|
</span>
|
|
)}
|
|
|
|
{/* Type badge for objects */}
|
|
{node.type === "object" && (
|
|
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
|
|
style={{ background: "rgba(232, 93, 58, 0.15)", color: "var(--color-accent)" }}>
|
|
{node.defaultView === "kanban" ? "board" : "table"}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Children */}
|
|
{isExpanded && hasChildren && (
|
|
<div className="relative" style={{
|
|
borderLeft: depth > 0 ? "1px solid var(--color-border)" : "none",
|
|
marginLeft: `${depth * 16 + 16}px`,
|
|
}}>
|
|
{node.children!.map((child) => (
|
|
<DraggableNode
|
|
key={child.path}
|
|
node={child}
|
|
depth={depth + 1}
|
|
activePath={activePath}
|
|
selectedPath={selectedPath}
|
|
onSelect={onSelect}
|
|
onNodeSelect={onNodeSelect}
|
|
expandedPaths={expandedPaths}
|
|
onToggleExpand={onToggleExpand}
|
|
renamingPath={renamingPath}
|
|
onStartRename={onStartRename}
|
|
onCommitRename={onCommitRename}
|
|
onCancelRename={onCancelRename}
|
|
onContextMenu={onContextMenu}
|
|
compact={compact}
|
|
dragOverPath={dragOverPath}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Drag Overlay ---
|
|
|
|
function DragOverlayContent({ node }: { node: TreeNode }) {
|
|
return (
|
|
<div
|
|
className="flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm shadow-lg border"
|
|
style={{
|
|
background: "var(--color-surface)",
|
|
borderColor: "var(--color-border)",
|
|
color: "var(--color-text)",
|
|
pointerEvents: "none",
|
|
}}
|
|
>
|
|
<span style={{ color: typeColor(node) }}>
|
|
<NodeIcon node={node} />
|
|
</span>
|
|
<span>{node.name}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// --- Helper: find node by path ---
|
|
|
|
function findNode(tree: TreeNode[], path: string): TreeNode | null {
|
|
for (const n of tree) {
|
|
if (n.path === path) {return n;}
|
|
if (n.children) {
|
|
const found = findNode(n.children, path);
|
|
if (found) {return found;}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- Helper: get parent path ---
|
|
|
|
function parentPath(path: string): string {
|
|
const parts = path.split("/");
|
|
parts.pop();
|
|
return parts.join("/") || ".";
|
|
}
|
|
|
|
// --- Flatten tree for keyboard navigation ---
|
|
|
|
function flattenVisible(tree: TreeNode[], expanded: Set<string>): TreeNode[] {
|
|
const result: TreeNode[] = [];
|
|
function walk(nodes: TreeNode[]) {
|
|
for (const n of nodes) {
|
|
result.push(n);
|
|
if (n.children && expanded.has(n.path)) {
|
|
walk(n.children);
|
|
}
|
|
}
|
|
}
|
|
walk(tree);
|
|
return result;
|
|
}
|
|
|
|
// --- Main Exported Component ---
|
|
|
|
export function FileManagerTree({ tree, activePath, onSelect, onRefresh, compact }: FileManagerTreeProps) {
|
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(() => new Set());
|
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
|
const [renamingPath, setRenamingPath] = useState<string | null>(null);
|
|
const [dragOverPath, setDragOverPath] = useState<string | null>(null);
|
|
const [activeNode, setActiveNode] = useState<TreeNode | null>(null);
|
|
|
|
// Context menu state
|
|
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; target: ContextMenuTarget } | null>(null);
|
|
|
|
// Confirm dialog
|
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
|
|
|
|
// New item prompt
|
|
const [newItemPrompt, setNewItemPrompt] = useState<{ kind: "file" | "folder"; parentPath: string } | null>(null);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Auto-expand first level on mount
|
|
useEffect(() => {
|
|
if (tree.length > 0 && expandedPaths.size === 0) {
|
|
const initial = new Set<string>();
|
|
for (const node of tree) {
|
|
if (node.children && node.children.length > 0) {
|
|
initial.add(node.path);
|
|
}
|
|
}
|
|
setExpandedPaths(initial);
|
|
}
|
|
}, [tree]);
|
|
|
|
const handleToggleExpand = useCallback((path: string) => {
|
|
setExpandedPaths((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(path)) {next.delete(path);}
|
|
else {next.add(path);}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// DnD sensors -- require 8px movement before dragging starts (prevents accidental drags on click)
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
|
);
|
|
|
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
const data = event.active.data.current as { node: TreeNode } | undefined;
|
|
if (data?.node) {setActiveNode(data.node);}
|
|
}, []);
|
|
|
|
const handleDragOver = useCallback((event: DragOverEvent) => {
|
|
const overData = event.over?.data.current as { node: TreeNode } | undefined;
|
|
if (overData?.node) {
|
|
setDragOverPath(overData.node.path);
|
|
// Auto-expand folders on drag hover (300ms delay)
|
|
const path = overData.node.path;
|
|
if (overData.node.type === "folder" || overData.node.type === "object") {
|
|
setTimeout(() => {
|
|
setExpandedPaths((prev) => {
|
|
if (prev.has(path)) {return prev;}
|
|
const next = new Set(prev);
|
|
next.add(path);
|
|
return next;
|
|
});
|
|
}, 300);
|
|
}
|
|
} else {
|
|
setDragOverPath(null);
|
|
}
|
|
}, []);
|
|
|
|
const handleDragEnd = useCallback(
|
|
async (event: DragEndEvent) => {
|
|
setActiveNode(null);
|
|
setDragOverPath(null);
|
|
|
|
const activeData = event.active.data.current as { node: TreeNode } | undefined;
|
|
const overData = event.over?.data.current as { node: TreeNode } | undefined;
|
|
|
|
if (!activeData?.node || !overData?.node) {return;}
|
|
|
|
const source = activeData.node;
|
|
const target = overData.node;
|
|
|
|
// Only drop onto expandable targets (folders/objects)
|
|
if (target.type !== "folder" && target.type !== "object") {return;}
|
|
|
|
// Prevent dropping into self or children
|
|
if (target.path === source.path || target.path.startsWith(source.path + "/")) {return;}
|
|
|
|
// Prevent no-op moves (already in same parent)
|
|
if (parentPath(source.path) === target.path) {return;}
|
|
|
|
const result = await apiMove(source.path, target.path);
|
|
if (result.ok) {
|
|
onRefresh();
|
|
}
|
|
},
|
|
[onRefresh],
|
|
);
|
|
|
|
const handleDragCancel = useCallback(() => {
|
|
setActiveNode(null);
|
|
setDragOverPath(null);
|
|
}, []);
|
|
|
|
// Context menu handlers
|
|
const handleContextMenu = useCallback((e: React.MouseEvent, node: TreeNode) => {
|
|
const isSys = isSystemFile(node.path) || isVirtualNode(node);
|
|
const isFolder = node.type === "folder" || node.type === "object";
|
|
setCtxMenu({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
target: {
|
|
kind: isFolder ? "folder" : "file",
|
|
path: node.path,
|
|
name: node.name,
|
|
isSystem: isSys,
|
|
},
|
|
});
|
|
}, []);
|
|
|
|
const handleEmptyContextMenu = useCallback((e: React.MouseEvent) => {
|
|
e.preventDefault();
|
|
setCtxMenu({
|
|
x: e.clientX,
|
|
y: e.clientY,
|
|
target: { kind: "empty" },
|
|
});
|
|
}, []);
|
|
|
|
const handleContextMenuAction = useCallback(
|
|
async (action: ContextMenuAction) => {
|
|
const target = ctxMenu?.target;
|
|
if (!target) {return;}
|
|
|
|
switch (action) {
|
|
case "open": {
|
|
if (target.kind !== "empty") {
|
|
const node = findNode(tree, target.path);
|
|
if (node) {onSelect(node);}
|
|
}
|
|
break;
|
|
}
|
|
case "rename": {
|
|
if (target.kind !== "empty") {
|
|
setRenamingPath(target.path);
|
|
}
|
|
break;
|
|
}
|
|
case "duplicate": {
|
|
if (target.kind !== "empty") {
|
|
await apiDuplicate(target.path);
|
|
onRefresh();
|
|
}
|
|
break;
|
|
}
|
|
case "copy": {
|
|
if (target.kind !== "empty") {
|
|
await navigator.clipboard.writeText(target.path);
|
|
}
|
|
break;
|
|
}
|
|
case "delete": {
|
|
if (target.kind !== "empty") {
|
|
setConfirmDelete(target.path);
|
|
}
|
|
break;
|
|
}
|
|
case "newFile": {
|
|
const parent = target.kind === "folder" ? target.path : target.kind === "file" ? parentPath(target.path) : "knowledge";
|
|
setNewItemPrompt({ kind: "file", parentPath: parent });
|
|
break;
|
|
}
|
|
case "newFolder": {
|
|
const parent = target.kind === "folder" ? target.path : target.kind === "file" ? parentPath(target.path) : "knowledge";
|
|
setNewItemPrompt({ kind: "folder", parentPath: parent });
|
|
break;
|
|
}
|
|
case "getInfo": {
|
|
// Future: show info panel. For now, copy path.
|
|
if (target.kind !== "empty") {
|
|
await navigator.clipboard.writeText(target.path);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
[ctxMenu, tree, onSelect, onRefresh],
|
|
);
|
|
|
|
// Rename handlers
|
|
const handleCommitRename = useCallback(
|
|
async (newName: string) => {
|
|
if (!renamingPath) {return;}
|
|
// Block reserved folder names
|
|
if (RESERVED_FOLDER_NAMES.has(newName)) {
|
|
alert(`"${newName}" is a reserved name and cannot be used.`);
|
|
setRenamingPath(null);
|
|
return;
|
|
}
|
|
const result = await apiRename(renamingPath, newName);
|
|
setRenamingPath(null);
|
|
if (result.ok) {onRefresh();}
|
|
},
|
|
[renamingPath, onRefresh],
|
|
);
|
|
|
|
const handleCancelRename = useCallback(() => {
|
|
setRenamingPath(null);
|
|
}, []);
|
|
|
|
// Delete confirm
|
|
const handleConfirmDelete = useCallback(async () => {
|
|
if (!confirmDelete) {return;}
|
|
const result = await apiDelete(confirmDelete);
|
|
setConfirmDelete(null);
|
|
if (result.ok) {onRefresh();}
|
|
}, [confirmDelete, onRefresh]);
|
|
|
|
// New item submit
|
|
const handleNewItemSubmit = useCallback(
|
|
async (name: string) => {
|
|
if (!newItemPrompt || !name) {return;}
|
|
|
|
// Block reserved folder names
|
|
if (RESERVED_FOLDER_NAMES.has(name)) {
|
|
alert(`"${name}" is a reserved name and cannot be used.`);
|
|
return;
|
|
}
|
|
|
|
const fullPath = newItemPrompt.parentPath ? `${newItemPrompt.parentPath}/${name}` : name;
|
|
|
|
if (newItemPrompt.kind === "folder") {
|
|
await apiMkdir(fullPath);
|
|
} else {
|
|
await apiCreateFile(fullPath, "");
|
|
}
|
|
|
|
setNewItemPrompt(null);
|
|
onRefresh();
|
|
|
|
// Auto-expand parent
|
|
setExpandedPaths((prev) => {
|
|
const next = new Set(prev);
|
|
next.add(newItemPrompt.parentPath);
|
|
return next;
|
|
});
|
|
},
|
|
[newItemPrompt, onRefresh],
|
|
);
|
|
|
|
// Keyboard shortcuts
|
|
const handleKeyDown = useCallback(
|
|
(e: React.KeyboardEvent) => {
|
|
// Don't capture keyboard events when renaming
|
|
if (renamingPath) {return;}
|
|
|
|
const flat = flattenVisible(tree, expandedPaths);
|
|
const curIdx = flat.findIndex((n) => n.path === selectedPath);
|
|
const curNode = curIdx >= 0 ? flat[curIdx] : null;
|
|
|
|
switch (e.key) {
|
|
case "ArrowDown": {
|
|
e.preventDefault();
|
|
const next = curIdx < flat.length - 1 ? flat[curIdx + 1] : flat[0];
|
|
if (next) {setSelectedPath(next.path);}
|
|
break;
|
|
}
|
|
case "ArrowUp": {
|
|
e.preventDefault();
|
|
const prev = curIdx > 0 ? flat[curIdx - 1] : flat[flat.length - 1];
|
|
if (prev) {setSelectedPath(prev.path);}
|
|
break;
|
|
}
|
|
case "ArrowRight": {
|
|
e.preventDefault();
|
|
if (curNode && (curNode.type === "folder" || curNode.type === "object")) {
|
|
setExpandedPaths((p) => new Set([...p, curNode.path]));
|
|
}
|
|
break;
|
|
}
|
|
case "ArrowLeft": {
|
|
e.preventDefault();
|
|
if (curNode && expandedPaths.has(curNode.path)) {
|
|
setExpandedPaths((p) => {
|
|
const n = new Set(p);
|
|
n.delete(curNode.path);
|
|
return n;
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
case "Enter": {
|
|
e.preventDefault();
|
|
if (curNode) {
|
|
const curProtected = isSystemFile(curNode.path) || isVirtualNode(curNode);
|
|
if (e.shiftKey || curProtected) {
|
|
onSelect(curNode);
|
|
} else {
|
|
setRenamingPath(curNode.path);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
case "F2": {
|
|
e.preventDefault();
|
|
if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
|
|
setRenamingPath(curNode.path);
|
|
}
|
|
break;
|
|
}
|
|
case "Backspace":
|
|
case "Delete": {
|
|
if (curNode && !isSystemFile(curNode.path) && !isVirtualNode(curNode)) {
|
|
e.preventDefault();
|
|
setConfirmDelete(curNode.path);
|
|
}
|
|
break;
|
|
}
|
|
default: {
|
|
// Cmd+key shortcuts
|
|
if (e.metaKey || e.ctrlKey) {
|
|
if (e.key === "c" && curNode) {
|
|
e.preventDefault();
|
|
navigator.clipboard.writeText(curNode.path);
|
|
} else if (e.key === "d" && curNode && !isSystemFile(curNode.path)) {
|
|
e.preventDefault();
|
|
apiDuplicate(curNode.path).then(() => onRefresh());
|
|
} else if (e.key === "n") {
|
|
e.preventDefault();
|
|
const parent = curNode
|
|
? curNode.type === "folder" || curNode.type === "object"
|
|
? curNode.path
|
|
: parentPath(curNode.path)
|
|
: "knowledge";
|
|
if (e.shiftKey) {
|
|
setNewItemPrompt({ kind: "folder", parentPath: parent });
|
|
} else {
|
|
setNewItemPrompt({ kind: "file", parentPath: parent });
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
},
|
|
[tree, expandedPaths, selectedPath, renamingPath, onSelect, onRefresh],
|
|
);
|
|
|
|
if (tree.length === 0) {
|
|
return (
|
|
<div
|
|
className="px-4 py-6 text-center text-sm"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
onContextMenu={handleEmptyContextMenu}
|
|
>
|
|
No files in workspace
|
|
{ctxMenu && (
|
|
<ContextMenu
|
|
x={ctxMenu.x}
|
|
y={ctxMenu.y}
|
|
target={ctxMenu.target}
|
|
onAction={handleContextMenuAction}
|
|
onClose={() => setCtxMenu(null)}
|
|
/>
|
|
)}
|
|
{newItemPrompt && (
|
|
<NewItemPrompt
|
|
kind={newItemPrompt.kind}
|
|
parentPath={newItemPrompt.parentPath}
|
|
onSubmit={handleNewItemSubmit}
|
|
onCancel={() => setNewItemPrompt(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={handleDragCancel}
|
|
>
|
|
<div
|
|
ref={containerRef}
|
|
className="py-1 outline-none"
|
|
tabIndex={0}
|
|
role="tree"
|
|
onKeyDown={handleKeyDown}
|
|
onContextMenu={handleEmptyContextMenu}
|
|
>
|
|
{tree.map((node) => (
|
|
<DraggableNode
|
|
key={node.path}
|
|
node={node}
|
|
depth={0}
|
|
activePath={activePath}
|
|
selectedPath={selectedPath}
|
|
onSelect={onSelect}
|
|
onNodeSelect={setSelectedPath}
|
|
expandedPaths={expandedPaths}
|
|
onToggleExpand={handleToggleExpand}
|
|
renamingPath={renamingPath}
|
|
onStartRename={setRenamingPath}
|
|
onCommitRename={handleCommitRename}
|
|
onCancelRename={handleCancelRename}
|
|
onContextMenu={handleContextMenu}
|
|
compact={compact}
|
|
dragOverPath={dragOverPath}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
{/* Drag overlay (ghost) */}
|
|
<DragOverlay dropAnimation={null}>
|
|
{activeNode ? <DragOverlayContent node={activeNode} /> : null}
|
|
</DragOverlay>
|
|
|
|
{/* Context menu */}
|
|
{ctxMenu && (
|
|
<ContextMenu
|
|
x={ctxMenu.x}
|
|
y={ctxMenu.y}
|
|
target={ctxMenu.target}
|
|
onAction={handleContextMenuAction}
|
|
onClose={() => setCtxMenu(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Delete confirmation dialog */}
|
|
{confirmDelete && (
|
|
<ConfirmDialog
|
|
message={`Are you sure you want to delete "${confirmDelete.split("/").pop()}"? This action cannot be undone.`}
|
|
onConfirm={handleConfirmDelete}
|
|
onCancel={() => setConfirmDelete(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* New file/folder prompt */}
|
|
{newItemPrompt && (
|
|
<NewItemPrompt
|
|
kind={newItemPrompt.kind}
|
|
parentPath={newItemPrompt.parentPath}
|
|
onSubmit={handleNewItemSubmit}
|
|
onCancel={() => setNewItemPrompt(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Inject animation styles */}
|
|
<style>{RENAME_SHAKE_STYLE}</style>
|
|
</DndContext>
|
|
);
|
|
}
|