openclaw/apps/web/app/components/workspace/file-manager-tree.tsx
kumarabhirup fe15ab44dc
Dench workspace: virtual folders (Skills, Memories, Chats), landing page, and unified workspace layout
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".
2026-02-11 22:09:59 -08:00

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>
);
}