Add drag-and-drop file organization to FileTree

Implemented intuitive drag-and-drop functionality:
- Drag files and folders to reorganize the project structure
- Visual feedback with opacity and border highlights during drag
- Prevents invalid drops (e.g., dropping on itself)
- Toast notifications for successful moves
- Seamless integration with existing file tree state management
This commit is contained in:
Claude 2026-01-17 22:16:30 +00:00
parent 538c1ff44b
commit 024ec42c5e
No known key found for this signature in database
2 changed files with 111 additions and 1 deletions

View file

@ -274,6 +274,50 @@ end)`,
captureEvent('file_delete', { id });
};
const handleFileMove = (fileId: string, targetParentId: string) => {
setFiles((prev) => {
let movedNode: FileNode | null = null;
// First, find and remove the node from its current location
const removeNode = (nodes: FileNode[]): FileNode[] => {
return nodes.filter((node) => {
if (node.id === fileId) {
movedNode = node;
return false;
}
if (node.children) {
node.children = removeNode(node.children);
}
return true;
});
};
// Then, add the node to the target folder
const addToTarget = (nodes: FileNode[]): FileNode[] => {
return nodes.map((node) => {
if (node.id === targetParentId && node.type === 'folder') {
return {
...node,
children: [...(node.children || []), movedNode!],
};
}
if (node.children) {
return { ...node, children: addToTarget(node.children) };
}
return node;
});
};
const withoutMoved = removeNode(prev || []);
if (movedNode) {
return addToTarget(withoutMoved);
}
return prev || [];
});
captureEvent('file_move', { fileId, targetParentId });
};
const handleFileClose = (id: string) => {
setOpenFiles((prev) => (prev || []).filter((f) => f.id !== id));
if (activeFileId === id) {
@ -335,6 +379,7 @@ end)`,
onFileCreate={handleFileCreate}
onFileRename={handleFileRename}
onFileDelete={handleFileDelete}
onFileMove={handleFileMove}
selectedFileId={activeFileId}
/>
</TabsContent>
@ -369,6 +414,7 @@ end)`,
onFileCreate={handleFileCreate}
onFileRename={handleFileRename}
onFileDelete={handleFileDelete}
onFileMove={handleFileMove}
selectedFileId={activeFileId}
/>
</ResizablePanel>

View file

@ -33,6 +33,7 @@ interface FileTreeProps {
onFileCreate: (name: string, parentId?: string) => void;
onFileRename: (id: string, newName: string) => void;
onFileDelete: (id: string) => void;
onFileMove?: (fileId: string, targetParentId: string) => void;
selectedFileId?: string;
}
@ -42,11 +43,14 @@ export function FileTree({
onFileCreate,
onFileRename,
onFileDelete,
onFileMove,
selectedFileId,
}: FileTreeProps) {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['root']));
const [editingId, setEditingId] = useState<string | null>(null);
const [editingName, setEditingName] = useState('');
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
const toggleFolder = (id: string) => {
setExpandedFolders((prev) => {
@ -81,17 +85,77 @@ export function FileTree({
}
};
const handleDragStart = (e: React.DragEvent, node: FileNode) => {
e.stopPropagation();
setDraggedId(node.id);
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', node.id);
};
const handleDragOver = (e: React.DragEvent, node: FileNode) => {
e.preventDefault();
e.stopPropagation();
// Only allow dropping on folders
if (node.type === 'folder' && draggedId !== node.id) {
e.dataTransfer.dropEffect = 'move';
setDropTargetId(node.id);
}
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDropTargetId(null);
};
const handleDrop = (e: React.DragEvent, targetNode: FileNode) => {
e.preventDefault();
e.stopPropagation();
if (!draggedId || !onFileMove || targetNode.type !== 'folder') {
setDraggedId(null);
setDropTargetId(null);
return;
}
// Prevent dropping on itself or its children
if (draggedId === targetNode.id) {
setDraggedId(null);
setDropTargetId(null);
return;
}
onFileMove(draggedId, targetNode.id);
toast.success('File moved');
setDraggedId(null);
setDropTargetId(null);
};
const handleDragEnd = () => {
setDraggedId(null);
setDropTargetId(null);
};
const renderNode = (node: FileNode, depth: number = 0) => {
const isExpanded = expandedFolders.has(node.id);
const isSelected = selectedFileId === node.id;
const isEditing = editingId === node.id;
const isDragging = draggedId === node.id;
const isDropTarget = dropTargetId === node.id;
return (
<div key={node.id}>
<div
draggable={!isEditing}
onDragStart={(e) => handleDragStart(e, node)}
onDragOver={(e) => handleDragOver(e, node)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, node)}
onDragEnd={handleDragEnd}
className={`flex items-center gap-1 px-2 py-1 hover:bg-muted/60 cursor-pointer group rounded-sm transition-colors ${
isSelected ? 'bg-accent/30 text-accent font-semibold' : 'text-foreground'
}`}
} ${isDragging ? 'opacity-50' : ''} ${isDropTarget && node.type === 'folder' ? 'bg-blue-500/20 border-2 border-blue-500 border-dashed' : ''}`}
style={{ paddingLeft: `${depth * 10 + 8}px` }}
onClick={() => {
if (node.type === 'folder') {