diff --git a/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx b/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx index d13e7723a5b..5ff60db8657 100644 --- a/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx +++ b/apps/sim/app/w/components/sidebar/components/create-menu/create-menu.tsx @@ -4,14 +4,10 @@ import { useState } from 'react' import { File, Folder, Plus } from 'lucide-react' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' import { useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' @@ -24,15 +20,18 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) { const [showFolderDialog, setShowFolderDialog] = useState(false) const [folderName, setFolderName] = useState('') const [isCreating, setIsCreating] = useState(false) + const [isHoverOpen, setIsHoverOpen] = useState(false) const { activeWorkspaceId } = useWorkflowRegistry() const { createFolder } = useFolderStore() const handleCreateWorkflow = () => { + setIsHoverOpen(false) onCreateWorkflow() } const handleCreateFolder = () => { + setIsHoverOpen(false) setShowFolderDialog(true) } @@ -50,7 +49,6 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) { setShowFolderDialog(false) } catch (error) { console.error('Failed to create folder:', error) - // You could add toast notification here } finally { setIsCreating(false) } @@ -61,81 +59,61 @@ export function CreateMenu({ onCreateWorkflow, isCollapsed }: CreateMenuProps) { setShowFolderDialog(false) } - if (isCollapsed) { - return ( - <> - - - - - - - - New Workflow - - - - New Folder - - - - - {/* Folder creation dialog */} - - - - Create New Folder - -
-
- - setFolderName(e.target.value)} - placeholder='Enter folder name...' - autoFocus - required - /> -
-
- - -
-
-
-
- - ) - } - return ( <> - - - - - - - + + setIsHoverOpen(true)} + onMouseLeave={() => setIsHoverOpen(false)} + onOpenAutoFocus={(e) => e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + > + + + + {/* Folder creation dialog */} diff --git a/apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx b/apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx new file mode 100644 index 00000000000..2c39f63bc50 --- /dev/null +++ b/apps/sim/app/w/components/sidebar/components/folder-tree/components/folder-item.tsx @@ -0,0 +1,151 @@ +'use client' + +import { useCallback, useEffect, useRef } from 'react' +import clsx from 'clsx' +import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' +import { FolderContextMenu } from '../../folder-context-menu/folder-context-menu' + +interface FolderItemProps { + folder: FolderTreeNode + isCollapsed?: boolean + onCreateWorkflow: (folderId?: string) => void + dragOver?: boolean + onDragOver?: (e: React.DragEvent) => void + onDragLeave?: (e: React.DragEvent) => void + onDrop?: (e: React.DragEvent) => void +} + +export function FolderItem({ + folder, + isCollapsed, + onCreateWorkflow, + dragOver = false, + onDragOver, + onDragLeave, + onDrop, +}: FolderItemProps) { + const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore() + + const isExpanded = expandedFolders.has(folder.id) + const updateTimeoutRef = useRef | undefined>(undefined) + const pendingStateRef = useRef(null) + + const handleToggleExpanded = useCallback(() => { + const newExpandedState = !isExpanded + toggleExpanded(folder.id) + pendingStateRef.current = newExpandedState + + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current) + } + + updateTimeoutRef.current = setTimeout(() => { + if (pendingStateRef.current === newExpandedState) { + updateFolderAPI(folder.id, { isExpanded: newExpandedState }) + .catch(console.error) + .finally(() => { + pendingStateRef.current = null + }) + } + }, 300) + }, [folder.id, isExpanded, toggleExpanded, updateFolderAPI]) + + useEffect(() => { + return () => { + if (updateTimeoutRef.current) { + clearTimeout(updateTimeoutRef.current) + } + } + }, []) + + const handleRename = async (folderId: string, newName: string) => { + try { + await updateFolderAPI(folderId, { name: newName }) + } catch (error) { + console.error('Failed to rename folder:', error) + } + } + + const handleDelete = async (folderId: string) => { + if ( + confirm( + `Are you sure you want to delete "${folder.name}"? Child folders and workflows will be moved to the parent folder.` + ) + ) { + try { + await deleteFolder(folderId) + } catch (error) { + console.error('Failed to delete folder:', error) + } + } + } + + if (isCollapsed) { + return ( + + +
+
+ {isExpanded ? ( + + ) : ( + + )} +
+
+
+ +

{folder.name}

+
+
+ ) + } + + return ( +
+
+
+ {isExpanded ? : } +
+ +
+ {isExpanded ? ( + + ) : ( + + )} +
+ + + {folder.name} + + +
e.stopPropagation()}> + +
+
+
+ ) +} diff --git a/apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx b/apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx new file mode 100644 index 00000000000..07bf393d9bb --- /dev/null +++ b/apps/sim/app/w/components/sidebar/components/folder-tree/components/workflow-item.tsx @@ -0,0 +1,137 @@ +'use client' + +import { useRef, useState } from 'react' +import clsx from 'clsx' +import Link from 'next/link' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { useFolderStore, useIsWorkflowSelected } from '@/stores/folders/store' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' + +interface WorkflowItemProps { + workflow: WorkflowMetadata + active: boolean + isMarketplace?: boolean + isCollapsed?: boolean + level: number + isDragOver?: boolean +} + +export function WorkflowItem({ + workflow, + active, + isMarketplace, + isCollapsed, + level, + isDragOver = false, +}: WorkflowItemProps) { + const [isDragging, setIsDragging] = useState(false) + const dragStartedRef = useRef(false) + const { selectedWorkflows, selectOnly, toggleWorkflowSelection } = useFolderStore() + const isSelected = useIsWorkflowSelected(workflow.id) + + const handleClick = (e: React.MouseEvent) => { + if (dragStartedRef.current) { + e.preventDefault() + return + } + + if (e.shiftKey) { + e.preventDefault() + toggleWorkflowSelection(workflow.id) + } else { + if (!isSelected || selectedWorkflows.size > 1) { + selectOnly(workflow.id) + } + } + } + + const handleDragStart = (e: React.DragEvent) => { + if (isMarketplace) return + + dragStartedRef.current = true + setIsDragging(true) + + let workflowIds: string[] + if (isSelected && selectedWorkflows.size > 1) { + workflowIds = Array.from(selectedWorkflows) + } else { + workflowIds = [workflow.id] + } + + e.dataTransfer.setData('workflow-ids', JSON.stringify(workflowIds)) + e.dataTransfer.effectAllowed = 'move' + } + + const handleDragEnd = () => { + setIsDragging(false) + requestAnimationFrame(() => { + dragStartedRef.current = false + }) + } + + if (isCollapsed) { + return ( + + + 1 && !active && !isDragOver + ? 'bg-accent/70' + : '', + isDragging ? 'opacity-50' : '' + )} + draggable={!isMarketplace} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onClick={handleClick} + > +
+ + + +

+ {workflow.name} + {isMarketplace && ' (Preview)'} +

+
+ + ) + } + + return ( + 1 && !active && !isDragOver ? 'bg-accent/70' : '', + isDragging ? 'opacity-50' : '', + !isMarketplace ? 'cursor-move' : '' + )} + style={{ paddingLeft: isCollapsed ? '0px' : `${(level + 1) * 20 + 8}px` }} + draggable={!isMarketplace} + onDragStart={handleDragStart} + onDragEnd={handleDragEnd} + onClick={handleClick} + > +
+ + {workflow.name} + {isMarketplace && ' (Preview)'} + + + ) +} diff --git a/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx b/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx index 92a906c3260..818dd1d3b3c 100644 --- a/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx +++ b/apps/sim/app/w/components/sidebar/components/folder-tree/folder-tree.tsx @@ -2,244 +2,145 @@ import { useEffect, useState } from 'react' import clsx from 'clsx' -import { ChevronDown, ChevronRight, Folder, FolderOpen } from 'lucide-react' -import Link from 'next/link' import { usePathname } from 'next/navigation' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { type FolderTreeNode, useFolderStore } from '@/stores/folders/store' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { FolderContextMenu } from '../folder-context-menu/folder-context-menu' +import { FolderItem } from './components/folder-item' +import { WorkflowItem } from './components/workflow-item' -interface FolderItemProps { +interface FolderSectionProps { folder: FolderTreeNode - isCollapsed?: boolean + level: number + isCollapsed: boolean onCreateWorkflow: (folderId?: string) => void + workflowsByFolder: Record + expandedFolders: Set + pathname: string + updateWorkflow: (id: string, updates: Partial) => void + renderFolderTree: ( + nodes: FolderTreeNode[], + level: number, + parentDragOver?: boolean + ) => React.ReactNode[] + parentDragOver?: boolean } -function FolderItem({ folder, isCollapsed, onCreateWorkflow }: FolderItemProps) { - const [dragOver, setDragOver] = useState(false) - const { expandedFolders, toggleExpanded, updateFolderAPI, deleteFolder } = useFolderStore() - const { updateWorkflow } = useWorkflowRegistry() +function FolderSection({ + folder, + level, + isCollapsed, + onCreateWorkflow, + workflowsByFolder, + expandedFolders, + pathname, + updateWorkflow, + renderFolderTree, + parentDragOver = false, +}: FolderSectionProps) { + const { isDragOver, handleDragOver, handleDragLeave, handleDrop } = useDragHandlers( + updateWorkflow, + folder.id, + `Moved workflow(s) to folder ${folder.id}` + ) - const isExpanded = expandedFolders.has(folder.id) + const workflowsInFolder = workflowsByFolder[folder.id] || [] + const isAnyDragOver = isDragOver || parentDragOver - const handleToggleExpanded = () => { - toggleExpanded(folder.id) - // Persist to server - updateFolderAPI(folder.id, { isExpanded: !isExpanded }).catch(console.error) - } + return ( +
+ {/* Render folder */} +
+ +
- const handleRename = async (folderId: string, newName: string) => { - try { - await updateFolderAPI(folderId, { name: newName }) - } catch (error) { - console.error('Failed to rename folder:', error) - } - } + {/* Render workflows in this folder */} + {expandedFolders.has(folder.id) && workflowsInFolder.length > 0 && ( +
+ {workflowsInFolder.map((workflow) => ( + + ))} +
+ )} - const handleDelete = async (folderId: string) => { - if ( - confirm( - `Are you sure you want to delete "${folder.name}"? Child folders and workflows will be moved to the parent folder.` - ) - ) { - try { - await deleteFolder(folderId) - } catch (error) { - console.error('Failed to delete folder:', error) - } - } - } + {/* Render child folders */} + {expandedFolders.has(folder.id) && folder.children.length > 0 && ( +
{renderFolderTree(folder.children, level + 1, isAnyDragOver)}
+ )} +
+ ) +} + +// Custom hook for drag and drop handling +function useDragHandlers( + updateWorkflow: (id: string, updates: Partial) => void, + targetFolderId: string | null, // null for root + logMessage?: string +) { + const [isDragOver, setIsDragOver] = useState(false) - // Drag and drop handlers const handleDragOver = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragOver(true) + setIsDragOver(true) } const handleDragLeave = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragOver(false) + setIsDragOver(false) } - const handleDrop = async (e: React.DragEvent) => { + const handleDrop = (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() - setDragOver(false) + setIsDragOver(false) + + const workflowIdsData = e.dataTransfer.getData('workflow-ids') + if (workflowIdsData) { + const workflowIds = JSON.parse(workflowIdsData) as string[] - const workflowId = e.dataTransfer.getData('workflow-id') - if (workflowId && workflowId !== folder.id) { try { - // Update workflow to be in this folder - await updateWorkflow(workflowId, { folderId: folder.id }) - console.log(`Moved workflow ${workflowId} to folder ${folder.id}`) + workflowIds.forEach((workflowId) => + updateWorkflow(workflowId, { folderId: targetFolderId }) + ) + console.log(logMessage || `Moved ${workflowIds.length} workflow(s)`) } catch (error) { - console.error('Failed to move workflow to folder:', error) + console.error('Failed to move workflows:', error) } } } - if (isCollapsed) { - return ( - - -
-
- {isExpanded ? ( - - ) : ( - - )} -
-
-
- -

{folder.name}

-
-
- ) - } - - return ( -
-
-
- {isExpanded ? : } -
- -
- {isExpanded ? ( - - ) : ( - - )} -
- - - {folder.name} - - -
e.stopPropagation()}> - -
-
-
- ) -} - -interface WorkflowItemProps { - workflow: WorkflowMetadata - active: boolean - isMarketplace?: boolean - isCollapsed?: boolean - level: number -} - -function WorkflowItem({ workflow, active, isMarketplace, isCollapsed, level }: WorkflowItemProps) { - const [isDragging, setIsDragging] = useState(false) - - const handleDragStart = (e: React.DragEvent) => { - if (isMarketplace) return // Don't allow dragging marketplace workflows - - e.dataTransfer.setData('workflow-id', workflow.id) - e.dataTransfer.effectAllowed = 'move' - setIsDragging(true) - } - - const handleDragEnd = () => { - setIsDragging(false) + return { + isDragOver, + handleDragOver, + handleDragLeave, + handleDrop, } - - if (isCollapsed) { - return ( - - - -
- - - -

- {workflow.name} - {isMarketplace && ' (Preview)'} -

-
- - ) - } - - return ( - -
- - {workflow.name} - {isMarketplace && ' (Preview)'} - - - ) } interface FolderTreeProps { @@ -264,7 +165,9 @@ export function FolderTree({ expandedFolders, fetchFolders, isLoading: foldersLoading, + clearSelection, } = useFolderStore() + const { updateWorkflow } = useWorkflowRegistry() // Fetch folders when workspace changes useEffect(() => { @@ -273,6 +176,10 @@ export function FolderTree({ } }, [activeWorkspaceId, fetchFolders]) + useEffect(() => { + clearSelection() + }, [activeWorkspaceId, clearSelection]) + const folderTree = activeWorkspaceId ? getFolderTree(activeWorkspaceId) : [] // Group workflows by folder @@ -286,63 +193,74 @@ export function FolderTree({ {} as Record ) - const renderFolderTree = (nodes: FolderTreeNode[], level = 0): React.ReactNode[] => { - const result: React.ReactNode[] = [] - - nodes.forEach((folder) => { - // Render folder - result.push( -
- -
- ) - - // Render workflows in this folder - const workflowsInFolder = workflowsByFolder[folder.id] || [] - if (expandedFolders.has(folder.id) && workflowsInFolder.length > 0) { - workflowsInFolder.forEach((workflow) => { - result.push( - - ) - }) - } - - // Render child folders - if (expandedFolders.has(folder.id) && folder.children.length > 0) { - result.push(...renderFolderTree(folder.children, level + 1)) - } - }) - - return result + const { + isDragOver: rootDragOver, + handleDragOver: handleRootDragOver, + handleDragLeave: handleRootDragLeave, + handleDrop: handleRootDrop, + } = useDragHandlers(updateWorkflow, null, 'Moved workflow(s) to root') + + const renderFolderTree = ( + nodes: FolderTreeNode[], + level = 0, + parentDragOver = false + ): React.ReactNode[] => { + return nodes.map((folder) => ( + + )) } const showLoading = isLoading || foldersLoading return ( -
+
{/* Folder tree */} {renderFolderTree(folderTree)} {/* Root level workflows (no folder) */} - {(workflowsByFolder.root || []).map((workflow) => ( - - ))} +
+ {(workflowsByFolder.root || []).map((workflow) => ( + + ))} +
{/* Marketplace workflows */} {marketplaceWorkflows.length > 0 && ( @@ -362,6 +280,7 @@ export function FolderTree({ isMarketplace isCollapsed={isCollapsed} level={-1} + isDragOver={false} /> ))}
diff --git a/apps/sim/stores/folders/store.ts b/apps/sim/stores/folders/store.ts index de063d4ddc1..1c2cedbf236 100644 --- a/apps/sim/stores/folders/store.ts +++ b/apps/sim/stores/folders/store.ts @@ -23,6 +23,7 @@ interface FolderState { folders: Record isLoading: boolean expandedFolders: Set + selectedWorkflows: Set // Actions setFolders: (folders: WorkflowFolder[]) => void @@ -33,6 +34,14 @@ interface FolderState { toggleExpanded: (folderId: string) => void setExpanded: (folderId: string, expanded: boolean) => void + // Selection actions + selectWorkflow: (workflowId: string) => void + deselectWorkflow: (workflowId: string) => void + toggleWorkflowSelection: (workflowId: string) => void + clearSelection: () => void + selectOnly: (workflowId: string) => void + isWorkflowSelected: (workflowId: string) => boolean + // Computed values getFolderTree: (workspaceId: string) => FolderTreeNode[] getFolderById: (id: string) => WorkflowFolder | undefined @@ -57,6 +66,7 @@ export const useFolderStore = create()( folders: {}, isLoading: false, expandedFolders: new Set(), + selectedWorkflows: new Set(), setFolders: (folders) => set(() => ({ @@ -113,6 +123,44 @@ export const useFolderStore = create()( return { expandedFolders: newExpanded } }), + // Selection actions + selectWorkflow: (workflowId) => + set((state) => { + const newSelected = new Set(state.selectedWorkflows) + newSelected.add(workflowId) + return { selectedWorkflows: newSelected } + }), + + deselectWorkflow: (workflowId) => + set((state) => { + const newSelected = new Set(state.selectedWorkflows) + newSelected.delete(workflowId) + return { selectedWorkflows: newSelected } + }), + + toggleWorkflowSelection: (workflowId) => + set((state) => { + const newSelected = new Set(state.selectedWorkflows) + if (newSelected.has(workflowId)) { + newSelected.delete(workflowId) + } else { + newSelected.add(workflowId) + } + return { selectedWorkflows: newSelected } + }), + + clearSelection: () => + set(() => ({ + selectedWorkflows: new Set(), + })), + + selectOnly: (workflowId) => + set(() => ({ + selectedWorkflows: new Set([workflowId]), + })), + + isWorkflowSelected: (workflowId) => get().selectedWorkflows.has(workflowId), + getFolderTree: (workspaceId) => { const folders = Object.values(get().folders).filter((f) => f.workspaceId === workspaceId) @@ -235,11 +283,6 @@ export const useFolderStore = create()( get().updateFolder(id, processedFolder) - // Update expanded state if isExpanded was changed - if (updates.isExpanded !== undefined) { - get().setExpanded(id, updates.isExpanded) - } - return processedFolder }, @@ -268,3 +311,7 @@ export const useFolderStore = create()( { name: 'folder-store' } ) ) + +// Selector hook for checking if a workflow is selected (avoids get() calls) +export const useIsWorkflowSelected = (workflowId: string) => + useFolderStore((state) => state.selectedWorkflows.has(workflowId))