openclaw/apps/web/app/components/workspace/file-manager-tree.tsx

1284 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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;
/** True when the entry is a symbolic link / shortcut. */
symlink?: 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;
/** Parent directory path for ".." navigation. Null when at filesystem root or in workspace mode without browsing. */
parentDir?: string | null;
/** Callback when user clicks ".." to navigate up. */
onNavigateUp?: () => void;
/** Current browse directory (absolute path), or null when in workspace mode. */
browseDir?: string | null;
/** Absolute path of the workspace root. Nodes matching this path are rendered as a special non-collapsible workspace entry point. */
workspaceRoot?: string | null;
/** Called when a node is dragged and dropped outside the tree onto an external drop target (e.g. chat input). */
onExternalDrop?: (node: TreeNode) => void;
};
// --- System file detection (client-side mirror) ---
/** Always protected regardless of depth. */
const ALWAYS_SYSTEM_PATTERNS = [
/^\.object\.yaml$/,
/\.wal$/,
/\.tmp$/,
];
/** Only protected at the workspace root (no "/" in the relative path). */
const ROOT_ONLY_SYSTEM_PATTERNS = [
/^workspace\.duckdb/,
/^workspace_context\.yaml$/,
];
function isSystemFile(path: string): boolean {
const base = path.split("/").pop() ?? "";
if (ALWAYS_SYSTEM_PATTERNS.some((p) => p.test(base))) {return true;}
const isRoot = !path.includes("/");
return isRoot && ROOT_ONLY_SYSTEM_PATTERNS.some((p) => p.test(base));
}
// --- Icons (inline SVG, zero-dep) ---
function FolderIcon({ open }: { open?: boolean }) {
return (
<img
src={open ? "/icons/folder-open.png" : "/icons/folder.png"}
alt=""
width={16}
height={16}
draggable={false}
style={{ flexShrink: 0 }}
/>
);
}
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 (
<img src="/icons/document.png" alt="" width={16} height={16} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
);
}
function FileIcon() {
return (
<img src="/icons/document.png" alt="" width={16} height={16} draggable={false} style={{ flexShrink: 0, filter: "drop-shadow(0 0.5px 1.5px rgba(0,0,0,0.2))" }} />
);
}
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 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"
style={{ transform: open ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 150ms ease" }}>
<path d="m9 18 6-6-6-6" />
</svg>
);
}
function WorkspaceGridIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect width="7" height="7" x="3" y="3" rx="1" />
<rect width="7" height="7" x="14" y="3" rx="1" />
<rect width="7" height="7" x="14" y="14" rx="1" />
<rect width="7" height="7" x="3" y="14" rx="1" />
</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,
workspaceRoot,
}: {
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;
workspaceRoot?: string | null;
}) {
// Workspace root in browse mode: non-expandable entry point back to workspace
const isWorkspaceRoot = !!workspaceRoot && node.path === workspaceRoot;
const hasChildren = node.children && node.children.length > 0;
const isExpandable = isWorkspaceRoot ? false : (hasChildren || node.type === "folder" || node.type === "object");
const isExpanded = isWorkspaceRoot ? false : 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 || isWorkspaceRoot;
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}
draggable={!isProtected}
onDragStart={(e) => {
// Native HTML5 drag for cross-component drops (e.g. into chat editor).
// Coexists with @dnd-kit which uses pointer events for intra-tree reordering.
e.dataTransfer.setData(
"application/x-file-mention",
JSON.stringify({ name: node.name, path: node.path }),
);
e.dataTransfer.setData("text/plain", node.path);
e.dataTransfer.effectAllowed = "copy";
}}
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: isWorkspaceRoot
? "var(--color-accent-light)"
: showDropHighlight
? "var(--color-accent-light)"
: isSelected
? "var(--color-surface-hover)"
: isActive
? "var(--color-surface-hover)"
: "transparent",
color: isWorkspaceRoot
? "var(--color-accent)"
: isActive || isSelected ? "var(--color-text)" : "var(--color-text-muted)",
outline: isWorkspaceRoot
? "1.5px solid var(--color-accent)"
: showDropHighlight ? "1px dashed var(--color-accent)" : "none",
outlineOffset: "-1px",
borderRadius: isWorkspaceRoot ? "8px" : "6px",
marginTop: isWorkspaceRoot ? "2px" : undefined,
marginBottom: isWorkspaceRoot ? "2px" : undefined,
}}
onMouseEnter={(e) => {
if (isWorkspaceRoot) {
(e.currentTarget as HTMLElement).style.opacity = "0.8";
} else if (!isActive && !isSelected && !showDropHighlight) {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}
}}
onMouseLeave={(e) => {
if (isWorkspaceRoot) {
(e.currentTarget as HTMLElement).style.opacity = "1";
} else if (!isActive && !isSelected && !showDropHighlight) {
(e.currentTarget as HTMLElement).style.background = "transparent";
}
}}
>
{/* Expand/collapse chevron intercept click so it only toggles without navigating */}
<span
className="flex-shrink-0 w-4 h-4 flex items-center justify-center"
style={{ opacity: isExpandable ? 1 : 0, cursor: isExpandable ? "pointer" : undefined }}
onClick={isExpandable ? (e) => { e.stopPropagation(); onToggleExpand(node.path); } : undefined}
>
{isExpandable && <ChevronIcon open={isExpanded} />}
</span>
{/* Icon */}
<span className="flex-shrink-0 flex items-center" style={{ color: isWorkspaceRoot ? "var(--color-accent)" : typeColor(node) }}>
{isWorkspaceRoot ? <WorkspaceGridIcon /> : <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}</span>
)}
{/* Workspace badge for the workspace root entry point */}
{isWorkspaceRoot && (
<span className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0 font-medium"
style={{ background: "var(--color-accent)", color: "white" }}>
workspace
</span>
)}
{/* Lock badge for system/virtual files (skip for workspace root -- it has its own badge) */}
{isProtected && !isWorkspaceRoot && !compact && (
<span className="flex-shrink-0 ml-1">
<LockBadge />
</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"
style={{ background: "var(--color-accent-light)", 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}
workspaceRoot={workspaceRoot}
/>
))}
</div>
)}
</div>
);
}
// --- Root drop zone (allows dropping items back to the top level) ---
function RootDropZone({ isDragging }: { isDragging: boolean }) {
const { setNodeRef, isOver } = useDroppable({
id: "drop-__root__",
data: { rootDrop: true },
});
const showHighlight = isOver && isDragging;
return (
<div
ref={setNodeRef}
className="flex-1 min-h-[48px]"
style={{
margin: isDragging ? "4px 8px" : undefined,
borderRadius: "6px",
border: showHighlight ? "1.5px dashed var(--color-accent)" : isDragging ? "1.5px dashed var(--color-border)" : "1.5px dashed transparent",
background: showHighlight ? "var(--color-accent-light)" : "transparent",
transition: "all 150ms ease",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{isDragging && (
<span className="text-[11px] select-none" style={{ color: showHighlight ? "var(--color-accent)" : "var(--color-text-muted)", opacity: showHighlight ? 1 : 0.6 }}>
Drop here to move to root
</span>
)}
</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, parentDir, onNavigateUp, browseDir: _browseDir, workspaceRoot, onExternalDrop }: 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);
// Track pointer position during @dnd-kit drags for cross-component drops.
// Installed synchronously in handleDragStart (not useEffect) to avoid
// missing early pointer moves. Capture-phase on window fires before
// @dnd-kit's own document-level listener.
const pointerPosRef = useRef({ x: 0, y: 0 });
const pointerListenerRef = useRef<((e: PointerEvent) => void) | null>(null);
const installPointerTracker = useCallback(() => {
const handler = (e: PointerEvent) => {
pointerPosRef.current = { x: e.clientX, y: e.clientY };
// Toggle visual drop indicator on external chat drop target
const el = document.elementFromPoint(e.clientX, e.clientY);
const target = el?.closest("[data-chat-drop-target]") as HTMLElement | null;
const prev = document.querySelector("[data-drag-hover]");
if (target && !target.hasAttribute("data-drag-hover")) {
target.setAttribute("data-drag-hover", "");
}
if (prev && prev !== target) {
prev.removeAttribute("data-drag-hover");
}
};
pointerListenerRef.current = handler;
window.addEventListener("pointermove", handler, true);
}, []);
const removePointerTracker = useCallback(() => {
if (pointerListenerRef.current) {
window.removeEventListener("pointermove", pointerListenerRef.current, true);
pointerListenerRef.current = null;
}
document.querySelector("[data-drag-hover]")?.removeAttribute("data-drag-hover");
}, []);
// Clean up on unmount
useEffect(() => removePointerTracker, [removePointerTracker]);
// 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.
// Keep ~skills and ~memories collapsed by default; always expand ~chats.
const collapsedByDefault = new Set(["~skills", "~memories"]);
useEffect(() => {
if (tree.length > 0 && expandedPaths.size === 0) {
const initial = new Set<string>();
for (const node of tree) {
if (collapsedByDefault.has(node.path)) {continue;}
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);
installPointerTracker();
}
}, [installPointerTracker]);
const handleDragOver = useCallback((event: DragOverEvent) => {
const overData = event.over?.data.current as { node?: TreeNode; rootDrop?: boolean } | undefined;
if (overData?.rootDrop) {
setDragOverPath("__root__");
} else 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);
removePointerTracker();
const activeData = event.active.data.current as { node: TreeNode } | undefined;
if (!activeData?.node) {return;}
const source = activeData.node;
// Check for external drop targets FIRST (e.g. chat input).
// closestCenter always returns a droppable even when the pointer is
// far outside the tree, so we can't rely on `event.over === null`.
if (onExternalDrop) {
const { x, y } = pointerPosRef.current;
const el = document.elementFromPoint(x, y);
if (el?.closest("[data-chat-drop-target]")) {
onExternalDrop(source);
return;
}
}
const overData = event.over?.data.current as { node?: TreeNode; rootDrop?: boolean } | undefined;
// Drop onto root level
if (overData?.rootDrop) {
// Already at root? No-op
if (parentPath(source.path) === ".") {return;}
const result = await apiMove(source.path, ".");
if (result.ok) {
onRefresh();
}
return;
}
if (!overData?.node) {return;}
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, onExternalDrop, removePointerTracker],
);
const handleDragCancel = useCallback(() => {
setActiveNode(null);
setDragOverPath(null);
removePointerTracker();
}, [removePointerTracker]);
// 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) : "";
setNewItemPrompt({ kind: "file", parentPath: parent });
break;
}
case "newFolder": {
const parent = target.kind === "folder" ? target.path : target.kind === "file" ? parentPath(target.path) : "";
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();
void navigator.clipboard.writeText(curNode.path);
} else if (e.key === "d" && curNode && !isSystemFile(curNode.path)) {
e.preventDefault();
void 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)
: "";
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 flex flex-col min-h-full"
tabIndex={0}
role="tree"
onKeyDown={handleKeyDown}
onContextMenu={handleEmptyContextMenu}
>
{/* ".." navigation entry for browsing up */}
{parentDir != null && onNavigateUp && (
<div
role="treeitem"
tabIndex={-1}
onClick={onNavigateUp}
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: "8px",
color: "var(--color-text-muted)",
borderRadius: "6px",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLElement).style.background = "var(--color-surface-hover)";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLElement).style.background = "transparent";
}}
>
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m15 18-6-6 6-6" />
</svg>
</span>
<span className="flex-shrink-0 flex items-center" style={{ color: "var(--color-text-muted)" }}>
<FolderIcon />
</span>
<span className="truncate flex-1">..</span>
</div>
)}
{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}
workspaceRoot={workspaceRoot}
/>
))}
{/* Root-level drop zone: fills remaining space so items can be moved to root */}
<RootDropZone isDragging={!!activeNode} />
</div>
{/* Drag overlay (ghost) — pointer-events:none so elementFromPoint sees through it */}
<DragOverlay dropAnimation={null} style={{ pointerEvents: "none" }}>
{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>
);
}