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:
parent
538c1ff44b
commit
024ec42c5e
2 changed files with 111 additions and 1 deletions
46
src/App.tsx
46
src/App.tsx
|
|
@ -274,6 +274,50 @@ end)`,
|
||||||
captureEvent('file_delete', { id });
|
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) => {
|
const handleFileClose = (id: string) => {
|
||||||
setOpenFiles((prev) => (prev || []).filter((f) => f.id !== id));
|
setOpenFiles((prev) => (prev || []).filter((f) => f.id !== id));
|
||||||
if (activeFileId === id) {
|
if (activeFileId === id) {
|
||||||
|
|
@ -335,6 +379,7 @@ end)`,
|
||||||
onFileCreate={handleFileCreate}
|
onFileCreate={handleFileCreate}
|
||||||
onFileRename={handleFileRename}
|
onFileRename={handleFileRename}
|
||||||
onFileDelete={handleFileDelete}
|
onFileDelete={handleFileDelete}
|
||||||
|
onFileMove={handleFileMove}
|
||||||
selectedFileId={activeFileId}
|
selectedFileId={activeFileId}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
@ -369,6 +414,7 @@ end)`,
|
||||||
onFileCreate={handleFileCreate}
|
onFileCreate={handleFileCreate}
|
||||||
onFileRename={handleFileRename}
|
onFileRename={handleFileRename}
|
||||||
onFileDelete={handleFileDelete}
|
onFileDelete={handleFileDelete}
|
||||||
|
onFileMove={handleFileMove}
|
||||||
selectedFileId={activeFileId}
|
selectedFileId={activeFileId}
|
||||||
/>
|
/>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ interface FileTreeProps {
|
||||||
onFileCreate: (name: string, parentId?: string) => void;
|
onFileCreate: (name: string, parentId?: string) => void;
|
||||||
onFileRename: (id: string, newName: string) => void;
|
onFileRename: (id: string, newName: string) => void;
|
||||||
onFileDelete: (id: string) => void;
|
onFileDelete: (id: string) => void;
|
||||||
|
onFileMove?: (fileId: string, targetParentId: string) => void;
|
||||||
selectedFileId?: string;
|
selectedFileId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,11 +43,14 @@ export function FileTree({
|
||||||
onFileCreate,
|
onFileCreate,
|
||||||
onFileRename,
|
onFileRename,
|
||||||
onFileDelete,
|
onFileDelete,
|
||||||
|
onFileMove,
|
||||||
selectedFileId,
|
selectedFileId,
|
||||||
}: FileTreeProps) {
|
}: FileTreeProps) {
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['root']));
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set(['root']));
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editingName, setEditingName] = useState('');
|
const [editingName, setEditingName] = useState('');
|
||||||
|
const [draggedId, setDraggedId] = useState<string | null>(null);
|
||||||
|
const [dropTargetId, setDropTargetId] = useState<string | null>(null);
|
||||||
|
|
||||||
const toggleFolder = (id: string) => {
|
const toggleFolder = (id: string) => {
|
||||||
setExpandedFolders((prev) => {
|
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 renderNode = (node: FileNode, depth: number = 0) => {
|
||||||
const isExpanded = expandedFolders.has(node.id);
|
const isExpanded = expandedFolders.has(node.id);
|
||||||
const isSelected = selectedFileId === node.id;
|
const isSelected = selectedFileId === node.id;
|
||||||
const isEditing = editingId === node.id;
|
const isEditing = editingId === node.id;
|
||||||
|
const isDragging = draggedId === node.id;
|
||||||
|
const isDropTarget = dropTargetId === node.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={node.id}>
|
<div key={node.id}>
|
||||||
<div
|
<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 ${
|
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'
|
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` }}
|
style={{ paddingLeft: `${depth * 10 + 8}px` }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (node.type === 'folder') {
|
if (node.type === 'folder') {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue