Vault.MCP / frontend /src /components /DirectoryTree.tsx
bigwolfeman
bugs
7068559
/**
* T077: Directory tree component with collapsible folders
*/
import React, { useState, useMemo, useEffect } from 'react';
import { ChevronRight, ChevronDown, Folder, File } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { NoteSummary } from '@/types/note';
import { cn } from '@/lib/utils';
interface TreeNode {
name: string;
path: string;
type: 'file' | 'folder';
children?: TreeNode[];
note?: NoteSummary;
}
interface DirectoryTreeProps {
notes: NoteSummary[];
selectedPath?: string;
onSelectNote: (path: string) => void;
onMoveNote?: (oldPath: string, newFolderPath: string) => void;
}
/**
* Build a tree structure from flat list of note paths
*/
function buildTree(notes: NoteSummary[]): TreeNode[] {
const root: TreeNode = { name: '', path: '', type: 'folder', children: [] };
for (const note of notes) {
const parts = note.note_path.split('/');
let current = root;
// Navigate/create folders
for (let i = 0; i < parts.length - 1; i++) {
const folderName = parts[i];
const folderPath = parts.slice(0, i + 1).join('/');
let folder = current.children?.find(
(child) => child.name === folderName && child.type === 'folder'
);
if (!folder) {
folder = {
name: folderName,
path: folderPath,
type: 'folder',
children: [],
};
current.children = current.children || [];
current.children.push(folder);
}
current = folder;
}
// Add file
const fileName = parts[parts.length - 1];
current.children = current.children || [];
current.children.push({
name: fileName,
path: note.note_path,
type: 'file',
note,
});
}
// Sort children: folders first, then files, alphabetically
const sortChildren = (node: TreeNode) => {
if (node.children) {
node.children.sort((a, b) => {
if (a.type !== b.type) {
return a.type === 'folder' ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
node.children.forEach(sortChildren);
}
};
sortChildren(root);
return root.children || [];
}
interface TreeNodeItemProps {
node: TreeNode;
depth: number;
selectedPath?: string;
onSelectNote: (path: string) => void;
onMoveNote?: (oldPath: string, newFolderPath: string) => void;
forceExpandState?: boolean;
}
function TreeNodeItem({ node, depth, selectedPath, onSelectNote, onMoveNote, forceExpandState }: TreeNodeItemProps) {
const [isOpen, setIsOpen] = useState(depth < 2); // Auto-expand first 2 levels
const [isDragOver, setIsDragOver] = useState(false);
// T014: Use forceExpandState if provided, otherwise use local isOpen state
// Sync local state with force state when it changes
const effectiveIsOpen = forceExpandState ?? isOpen;
// Update local state when force state is applied
useEffect(() => {
if (forceExpandState !== undefined) {
setIsOpen(forceExpandState);
}
}, [forceExpandState]);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (node.type === 'folder') {
setIsDragOver(true);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (node.type === 'folder') {
const draggedPath = e.dataTransfer.getData('application/note-path');
if (draggedPath && onMoveNote) {
onMoveNote(draggedPath, node.path);
}
}
};
const handleDragStart = (e: React.DragEvent) => {
if (node.type === 'file') {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('application/note-path', node.path);
}
};
if (node.type === 'folder') {
return (
<div>
<Button
variant="ghost"
className={cn(
"w-full justify-start font-normal px-2 h-8",
"hover:bg-accent transition-colors duration-200",
isDragOver && "bg-accent ring-2 ring-primary"
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => setIsOpen(!isOpen)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{effectiveIsOpen ? (
<ChevronDown className="h-4 w-4 mr-1 shrink-0" />
) : (
<ChevronRight className="h-4 w-4 mr-1 shrink-0" />
)}
<Folder className="h-4 w-4 mr-2 shrink-0 text-muted-foreground" />
<span className="truncate">{node.name}</span>
</Button>
{effectiveIsOpen && node.children && (
<div>
{node.children.map((child) => (
<TreeNodeItem
key={child.path}
node={child}
depth={depth + 1}
selectedPath={selectedPath}
onSelectNote={onSelectNote}
onMoveNote={onMoveNote}
forceExpandState={forceExpandState}
/>
))}
</div>
)}
</div>
);
}
// File node
const isSelected = node.path === selectedPath;
// Remove .md extension for display
const displayName = node.name.replace(/\.md$/, '');
return (
<Button
variant="ghost"
className={cn(
"w-full justify-start font-normal px-2 h-8",
"hover:bg-accent transition-colors duration-200",
isSelected && "bg-accent transition-colors duration-300 animate-highlight-pulse",
"cursor-move"
)}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onSelectNote(node.path)}
draggable
onDragStart={handleDragStart}
>
<File className="h-4 w-4 mr-2 shrink-0 text-muted-foreground transition-colors duration-200" />
<span className="truncate">{displayName}</span>
</Button>
);
}
export function DirectoryTree({ notes, selectedPath, onSelectNote, onMoveNote }: DirectoryTreeProps) {
const tree = useMemo(() => buildTree(notes), [notes]);
// T012: Add expandAll state to DirectoryTree component
// T013: Add collapseAll state to DirectoryTree component
// T015: Implement expand/collapse state propagation logic
const [expandAllState, setExpandAllState] = useState<boolean | undefined>(undefined);
const handleExpandAll = () => {
setExpandAllState(true);
// Reset after transition completes (300ms)
setTimeout(() => {
setExpandAllState(undefined);
}, 300);
};
const handleCollapseAll = () => {
setExpandAllState(false);
// Reset after transition completes (300ms)
setTimeout(() => {
setExpandAllState(undefined);
}, 300);
};
if (notes.length === 0) {
return (
<div className="p-4 text-sm text-muted-foreground text-center">
No notes found. Create your first note to get started.
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="py-2">
{/* T016: Add "Expand All" button above directory tree */}
{/* T017: Add "Collapse All" button above directory tree */}
<div className="flex gap-2 px-2 pb-2">
<Button
variant="outline"
size="sm"
onClick={handleExpandAll}
className="flex-1 text-xs"
aria-label="Expand all folders"
>
Expand All
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCollapseAll}
className="flex-1 text-xs"
aria-label="Collapse all folders"
>
Collapse All
</Button>
</div>
{tree.map((node) => (
<TreeNodeItem
key={node.path}
node={node}
depth={0}
selectedPath={selectedPath}
onSelectNote={onSelectNote}
onMoveNote={onMoveNote}
forceExpandState={expandAllState}
/>
))}
</div>
</ScrollArea>
);
}