WorkspaceList.svelte
1 <script lang="ts"> 2 import type { Workspace, ParentWorkspace, ChildWorkspace, WorkspaceGroup } from '../../lib/types'; 3 import type { DragState, DropPosition } from '../../lib/dnd/types'; 4 import WorkspaceItem from './WorkspaceItem.svelte'; 5 import GroupHeader from './GroupHeader.svelte'; 6 7 interface Props { 8 workspaces: Workspace[]; 9 selectedId?: string | null; 10 showTranscendentSection?: boolean; 11 enableDragDrop?: boolean; 12 dragState?: DragState | null; 13 audibleWorkspaceIds?: Set<string>; 14 openWorkspaceIds?: Set<string>; 15 onselect?: (workspace: Workspace) => void; 16 oncontextmenu?: (e: MouseEvent, workspace: Workspace) => void; 17 ongroupcontextmenu?: (e: MouseEvent, group: WorkspaceGroup, parent: ParentWorkspace) => void; 18 ongrouptoggle?: (group: WorkspaceGroup, parent: ParentWorkspace) => void; 19 ongroupdrop?: (childId: string, groupId: string, parentId: string) => void; 20 ondragstart?: (workspace: Workspace) => void; 21 ondragover?: (workspace: Workspace, position: DropPosition) => void; 22 ondragleave?: () => void; 23 ondrop?: (workspace: Workspace) => void; 24 } 25 26 let { 27 workspaces, 28 selectedId = null, 29 showTranscendentSection = true, 30 enableDragDrop = false, 31 dragState = null, 32 audibleWorkspaceIds = new Set(), 33 openWorkspaceIds = new Set(), 34 onselect, 35 oncontextmenu, 36 ongroupcontextmenu, 37 ongrouptoggle, 38 ongroupdrop, 39 ondragstart, 40 ondragover, 41 ondragleave, 42 ondrop, 43 }: Props = $props(); 44 45 // Track which group is currently the drop target 46 let dropTargetGroupId = $state<string | null>(null); 47 48 // Determine if a workspace is draggable (standalones and children can be dragged) 49 function isDraggable(ws: Workspace): boolean { 50 if (!enableDragDrop) return false; 51 // Parents can't be dragged (they're containers) 52 // Standalones and children can be dragged 53 return ws.type === 'standalone' || ws.type === 'child'; 54 } 55 56 // Track expanded state for parent workspaces 57 // This includes both manually expanded parents and auto-expanded ones (due to open children) 58 let expandedIds = $state<Set<string>>(new Set()); 59 let manuallyCollapsedIds = $state<Set<string>>(new Set()); // Parents user explicitly collapsed 60 let transcendentExpanded = $state(true); 61 62 // Track which parents have open children (for auto-expand/collapse) 63 const parentsWithOpenChildren = $derived.by(() => { 64 const result = new Set<string>(); 65 for (const ws of workspaces) { 66 if (ws.type === 'child' && openWorkspaceIds.has(ws.id)) { 67 const child = ws as ChildWorkspace; 68 result.add(child.parentId); 69 } 70 } 71 return result; 72 }); 73 74 // Track which parents were auto-expanded (vs manually expanded by user clicking) 75 let autoExpandedIds = $state<Set<string>>(new Set()); 76 77 // Auto-expand/collapse parents based on their children's open window state 78 $effect(() => { 79 const newExpanded = new Set(expandedIds); 80 const newAutoExpanded = new Set(autoExpandedIds); 81 const newManuallyCollapsed = new Set(manuallyCollapsedIds); 82 let changed = false; 83 84 // Auto-expand parents with open children (unless user manually collapsed) 85 for (const parentId of parentsWithOpenChildren) { 86 if (!newExpanded.has(parentId) && !newManuallyCollapsed.has(parentId)) { 87 newExpanded.add(parentId); 88 newAutoExpanded.add(parentId); 89 changed = true; 90 } 91 } 92 93 // Auto-collapse parents that were auto-expanded but no longer have open children 94 for (const parentId of newAutoExpanded) { 95 if (!parentsWithOpenChildren.has(parentId)) { 96 newExpanded.delete(parentId); 97 newAutoExpanded.delete(parentId); 98 // Also clear from manually collapsed since there's no reason to track it anymore 99 newManuallyCollapsed.delete(parentId); 100 changed = true; 101 } 102 } 103 104 if (changed) { 105 expandedIds = newExpanded; 106 autoExpandedIds = newAutoExpanded; 107 manuallyCollapsedIds = newManuallyCollapsed; 108 } 109 }); 110 111 // Organize workspaces into hierarchy 112 const hierarchy = $derived.by(() => { 113 // Filter out archived workspaces first 114 const activeWorkspaces = workspaces.filter((ws) => !ws.archivedAt); 115 116 // Separate transcendent workspaces 117 const transcendent = activeWorkspaces.filter((ws) => ws.isTranscendent && ws.type !== 'parent'); 118 const nonTranscendent = activeWorkspaces.filter((ws) => !ws.isTranscendent || ws.type === 'parent'); 119 120 const parents = nonTranscendent.filter((ws): ws is ParentWorkspace => ws.type === 'parent'); 121 const standalone = nonTranscendent.filter((ws) => ws.type === 'standalone'); 122 const childMap = new Map<string, ChildWorkspace[]>(); 123 124 // Group children by parent (non-transcendent ones) 125 for (const ws of nonTranscendent) { 126 if (ws.type === 'child') { 127 const child = ws as ChildWorkspace; 128 const existing = childMap.get(child.parentId) || []; 129 existing.push(child); 130 childMap.set(child.parentId, existing); 131 } 132 } 133 134 // Also check for transcendent children that should show under parents 135 const transcendentChildren = activeWorkspaces.filter( 136 (ws) => ws.isTranscendent && ws.type === 'child' 137 ) as ChildWorkspace[]; 138 139 for (const child of transcendentChildren) { 140 const existing = childMap.get(child.parentId) || []; 141 if (!existing.find((c) => c.id === child.id)) { 142 existing.push(child); 143 childMap.set(child.parentId, existing); 144 } 145 } 146 147 return { transcendent, parents, standalone, childMap }; 148 }); 149 150 // Compute open counts for each section 151 const openCounts = $derived.by(() => { 152 // Transcendent: count how many are open 153 const transcendentOpen = hierarchy.transcendent.filter(ws => openWorkspaceIds.has(ws.id)).length; 154 155 // Standalone: count how many are open 156 const standaloneOpen = hierarchy.standalone.filter(ws => openWorkspaceIds.has(ws.id)).length; 157 158 // Contexts: count parents that have at least one open child 159 let parentsWithOpenChildren = 0; 160 for (const parent of hierarchy.parents) { 161 const children = hierarchy.childMap.get(parent.id) || []; 162 const hasOpenChild = children.some(child => openWorkspaceIds.has(child.id)); 163 if (hasOpenChild) parentsWithOpenChildren++; 164 } 165 166 return { transcendentOpen, standaloneOpen, parentsWithOpenChildren }; 167 }); 168 169 // Helper to get open/total for a parent's children 170 function getParentChildOpenCount(parentId: string): { open: number; total: number } { 171 const children = hierarchy.childMap.get(parentId) || []; 172 const open = children.filter(child => openWorkspaceIds.has(child.id)).length; 173 return { open, total: children.length }; 174 } 175 176 // Organize children by group within a parent 177 interface GroupedChildren { 178 groups: Array<{ 179 group: WorkspaceGroup; 180 children: ChildWorkspace[]; 181 }>; 182 ungrouped: ChildWorkspace[]; 183 } 184 185 function getGroupedChildren(parent: ParentWorkspace): GroupedChildren { 186 const children = hierarchy.childMap.get(parent.id) || []; 187 const groups: GroupedChildren['groups'] = []; 188 const ungrouped: ChildWorkspace[] = []; 189 190 // If no groups defined, all children are ungrouped 191 if (!parent.groups || parent.groups.length === 0) { 192 return { groups: [], ungrouped: children }; 193 } 194 195 // Create a map for quick group lookup 196 const groupMap = new Map<string, ChildWorkspace[]>(); 197 for (const group of parent.groups) { 198 groupMap.set(group.id, []); 199 } 200 201 // Distribute children into groups 202 for (const child of children) { 203 if (child.groupId && groupMap.has(child.groupId)) { 204 groupMap.get(child.groupId)!.push(child); 205 } else { 206 ungrouped.push(child); 207 } 208 } 209 210 // Build groups array in order 211 for (const group of parent.groups) { 212 groups.push({ 213 group, 214 children: groupMap.get(group.id) || [], 215 }); 216 } 217 218 return { groups, ungrouped }; 219 } 220 221 // Get open count for a specific group 222 function getGroupOpenCount(groupChildren: ChildWorkspace[]): { open: number; total: number } { 223 const open = groupChildren.filter(child => openWorkspaceIds.has(child.id)).length; 224 return { open, total: groupChildren.length }; 225 } 226 227 // Handle group toggle (collapse/expand) 228 function handleGroupToggle(group: WorkspaceGroup, parent: ParentWorkspace) { 229 ongrouptoggle?.(group, parent); 230 } 231 232 // Handle group context menu 233 function handleGroupContextMenu(e: MouseEvent, group: WorkspaceGroup, parent: ParentWorkspace) { 234 ongroupcontextmenu?.(e, group, parent); 235 } 236 237 // Group drag-and-drop handlers 238 function handleGroupDragOver(e: DragEvent, group: WorkspaceGroup, parent: ParentWorkspace) { 239 // Only allow drop if dragging a child of this parent 240 if (!dragState?.draggedId) return; 241 242 const draggedWorkspace = workspaces.find(ws => ws.id === dragState.draggedId); 243 if (!draggedWorkspace || draggedWorkspace.type !== 'child') return; 244 245 const draggedChild = draggedWorkspace as ChildWorkspace; 246 if (draggedChild.parentId !== parent.id) return; 247 248 // Don't show drop target if already in this group 249 if (draggedChild.groupId === group.id) return; 250 251 dropTargetGroupId = group.id; 252 } 253 254 function handleGroupDragLeave() { 255 dropTargetGroupId = null; 256 } 257 258 function handleGroupDrop(e: DragEvent, group: WorkspaceGroup, parent: ParentWorkspace) { 259 if (!dragState?.draggedId) { 260 dropTargetGroupId = null; 261 return; 262 } 263 264 const draggedWorkspace = workspaces.find(ws => ws.id === dragState.draggedId); 265 if (!draggedWorkspace || draggedWorkspace.type !== 'child') { 266 dropTargetGroupId = null; 267 return; 268 } 269 270 const draggedChild = draggedWorkspace as ChildWorkspace; 271 if (draggedChild.parentId !== parent.id) { 272 dropTargetGroupId = null; 273 return; 274 } 275 276 // Call the handler to move the child to the group 277 ongroupdrop?.(draggedChild.id, group.id, parent.id); 278 dropTargetGroupId = null; 279 } 280 281 function toggleExpanded(workspace: Workspace) { 282 if (workspace.type !== 'parent') return; 283 284 const newExpanded = new Set(expandedIds); 285 const newAutoExpanded = new Set(autoExpandedIds); 286 const newManuallyCollapsed = new Set(manuallyCollapsedIds); 287 288 if (newExpanded.has(workspace.id)) { 289 // User is collapsing - track this so auto-expand won't override 290 newExpanded.delete(workspace.id); 291 // Remove from auto-expanded since user took manual action 292 newAutoExpanded.delete(workspace.id); 293 // Only mark as manually collapsed if there are open children (to prevent auto-expand) 294 if (parentsWithOpenChildren.has(workspace.id)) { 295 newManuallyCollapsed.add(workspace.id); 296 } 297 } else { 298 // User is expanding manually - clear any manual collapse tracking 299 newExpanded.add(workspace.id); 300 newManuallyCollapsed.delete(workspace.id); 301 // Don't add to autoExpandedIds - this is a manual expansion 302 // so it won't auto-collapse when children close 303 } 304 305 expandedIds = newExpanded; 306 autoExpandedIds = newAutoExpanded; 307 manuallyCollapsedIds = newManuallyCollapsed; 308 } 309 310 function isExpanded(id: string): boolean { 311 return expandedIds.has(id); 312 } 313 314 function toggleTranscendentSection() { 315 transcendentExpanded = !transcendentExpanded; 316 } 317 </script> 318 319 <div class="workspace-list" role="tree" aria-label="Workspaces"> 320 <!-- Transcendent workspaces section (always visible) --> 321 {#if showTranscendentSection && hierarchy.transcendent.length > 0} 322 <div class="mb-4"> 323 <button 324 class="w-full flex items-center justify-between px-3 py-1.5 text-xs font-mono uppercase tracking-wider text-phosphor hover:bg-phosphor/10 rounded transition-colors" 325 onclick={toggleTranscendentSection} 326 > 327 <div class="flex items-center gap-2"> 328 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 329 <!-- Infinity symbol --> 330 <path d="M14.5 10c0 1.38-1.12 2.5-2.5 2.5-.83 0-1.57-.41-2.02-1.03L8.5 10l1.48-1.47A2.49 2.49 0 0112 7.5c1.38 0 2.5 1.12 2.5 2.5zm-9 0c0-1.38 1.12-2.5 2.5-2.5.83 0 1.57.41 2.02 1.03L11.5 10l-1.48 1.47A2.49 2.49 0 018 12.5c-1.38 0-2.5-1.12-2.5-2.5zm4.5 0l2-2c.78-.78 1.81-1.17 2.83-1.17 2.21 0 4 1.79 4 4s-1.79 4-4 4c-1.02 0-2.05-.39-2.83-1.17l-2-2-2 2c-.78.78-1.81 1.17-2.83 1.17-2.21 0-4-1.79-4-4s1.79-4 4-4c1.02 0 2.05.39 2.83 1.17l2 2z" /> 331 </svg> 332 <span>Transcendent</span> 333 <span class="px-1.5 py-0.5 text-[10px] bg-phosphor/20 rounded-full"> 334 {openCounts.transcendentOpen}/{hierarchy.transcendent.length} 335 </span> 336 </div> 337 <svg 338 class="w-4 h-4 transition-transform duration-200 {transcendentExpanded ? 'rotate-180' : ''}" 339 fill="none" 340 stroke="currentColor" 341 viewBox="0 0 24 24" 342 > 343 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /> 344 </svg> 345 </button> 346 347 {#if transcendentExpanded} 348 <div class="mt-1 border-l-2 border-phosphor/30 ml-3 pl-1"> 349 {#each hierarchy.transcendent as ws (ws.id)} 350 <WorkspaceItem 351 workspace={ws} 352 selected={selectedId === ws.id} 353 draggable={isDraggable(ws)} 354 isAudible={audibleWorkspaceIds.has(ws.id)} 355 isOpen={openWorkspaceIds.has(ws.id)} 356 {dragState} 357 {onselect} 358 {oncontextmenu} 359 {ondragstart} 360 {ondragover} 361 {ondragleave} 362 {ondrop} 363 /> 364 {/each} 365 </div> 366 {/if} 367 </div> 368 {/if} 369 370 <!-- Parent workspaces with their children --> 371 {#if hierarchy.parents.length > 0} 372 <div class="mb-4"> 373 <div class="px-3 py-1 text-xs font-mono uppercase tracking-wider text-text-muted flex items-center gap-2"> 374 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 375 <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /> 376 </svg> 377 <span>Contexts</span> 378 <span class="px-1.5 py-0.5 text-[10px] bg-surface-raised rounded-full"> 379 {openCounts.parentsWithOpenChildren}/{hierarchy.parents.length} 380 </span> 381 </div> 382 {#each hierarchy.parents as parent (parent.id)} 383 {@const childCounts = getParentChildOpenCount(parent.id)} 384 <WorkspaceItem 385 workspace={parent} 386 selected={selectedId === parent.id} 387 expanded={isExpanded(parent.id)} 388 draggable={false} 389 isAudible={audibleWorkspaceIds.has(parent.id)} 390 isOpen={openWorkspaceIds.has(parent.id)} 391 openChildCount={childCounts.open} 392 {dragState} 393 {onselect} 394 ontoggle={toggleExpanded} 395 {oncontextmenu} 396 {ondragover} 397 {ondragleave} 398 {ondrop} 399 /> 400 {#if isExpanded(parent.id)} 401 {@const grouped = getGroupedChildren(parent)} 402 {@const hasGroups = grouped.groups.length > 0} 403 <div class="mt-1 border-l-2 border-text-muted/30 ml-3 pl-1"> 404 <!-- Render groups with their children --> 405 {#each grouped.groups as { group, children: groupChildren } (group.id)} 406 {@const groupCounts = getGroupOpenCount(groupChildren)} 407 <div class="group-section"> 408 <GroupHeader 409 {group} 410 childCount={groupCounts.total} 411 openCount={groupCounts.open} 412 isDropTarget={dropTargetGroupId === group.id} 413 ontoggle={(g) => handleGroupToggle(g, parent)} 414 oncontextmenu={(e, g) => handleGroupContextMenu(e, g, parent)} 415 ondragover={(e, g) => handleGroupDragOver(e, g, parent)} 416 ondragleave={handleGroupDragLeave} 417 ondrop={(e, g) => handleGroupDrop(e, g, parent)} 418 /> 419 {#if !group.collapsed} 420 <div class="ml-4 border-l-2 border-text-muted/20 pl-1"> 421 {#each groupChildren as child (child.id)} 422 <WorkspaceItem 423 workspace={child} 424 selected={selectedId === child.id} 425 draggable={isDraggable(child)} 426 isAudible={audibleWorkspaceIds.has(child.id)} 427 isOpen={openWorkspaceIds.has(child.id)} 428 {dragState} 429 {onselect} 430 {oncontextmenu} 431 {ondragstart} 432 {ondragover} 433 {ondragleave} 434 {ondrop} 435 /> 436 {/each} 437 {#if groupChildren.length === 0} 438 <div class="py-1 text-xs text-text-muted italic pl-3"> 439 No workspaces in this group 440 </div> 441 {/if} 442 </div> 443 {/if} 444 </div> 445 {/each} 446 447 <!-- Render ungrouped children --> 448 {#if hasGroups && grouped.ungrouped.length > 0} 449 <div class="ungrouped-section mt-1"> 450 <div class="flex items-center gap-2 px-2 py-1 text-xs text-text-muted"> 451 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 452 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" /> 453 </svg> 454 <span>Ungrouped</span> 455 <span class="px-1.5 py-0.5 text-[10px] bg-surface-raised rounded-full"> 456 {grouped.ungrouped.filter(c => openWorkspaceIds.has(c.id)).length}/{grouped.ungrouped.length} 457 </span> 458 </div> 459 <div class="ml-4 border-l-2 border-text-muted/20 pl-1"> 460 {#each grouped.ungrouped as child (child.id)} 461 <WorkspaceItem 462 workspace={child} 463 selected={selectedId === child.id} 464 draggable={isDraggable(child)} 465 isAudible={audibleWorkspaceIds.has(child.id)} 466 isOpen={openWorkspaceIds.has(child.id)} 467 {dragState} 468 {onselect} 469 {oncontextmenu} 470 {ondragstart} 471 {ondragover} 472 {ondragleave} 473 {ondrop} 474 /> 475 {/each} 476 </div> 477 </div> 478 {/if} 479 480 <!-- If no groups, show children directly (backwards compatible) --> 481 {#if !hasGroups} 482 {#each grouped.ungrouped as child (child.id)} 483 <WorkspaceItem 484 workspace={child} 485 selected={selectedId === child.id} 486 draggable={isDraggable(child)} 487 isAudible={audibleWorkspaceIds.has(child.id)} 488 isOpen={openWorkspaceIds.has(child.id)} 489 {dragState} 490 {onselect} 491 {oncontextmenu} 492 {ondragstart} 493 {ondragover} 494 {ondragleave} 495 {ondrop} 496 /> 497 {/each} 498 {/if} 499 500 <!-- Empty state when no children at all --> 501 {#if grouped.groups.length === 0 && grouped.ungrouped.length === 0} 502 <div class="py-2 text-xs text-text-muted italic pl-3"> 503 No child workspaces 504 </div> 505 {/if} 506 </div> 507 {/if} 508 {/each} 509 </div> 510 {/if} 511 512 <!-- Standalone workspaces --> 513 {#if hierarchy.standalone.length > 0} 514 <div class="mb-4"> 515 <div class="px-3 py-1 text-xs font-mono uppercase tracking-wider text-text-muted flex items-center gap-2"> 516 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 517 <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM4.332 8.027a6.012 6.012 0 011.912-2.706C6.512 5.73 6.974 6 7.5 6A1.5 1.5 0 019 7.5V8a2 2 0 004 0 2 2 0 011.523-1.943A5.977 5.977 0 0116 10c0 .34-.028.675-.083 1H15a2 2 0 00-2 2v2.197A5.973 5.973 0 0110 16v-2a2 2 0 00-2-2 2 2 0 01-2-2 2 2 0 00-1.668-1.973z" clip-rule="evenodd" /> 518 </svg> 519 <span>Standalone</span> 520 <span class="px-1.5 py-0.5 text-[10px] bg-surface-raised rounded-full"> 521 {openCounts.standaloneOpen}/{hierarchy.standalone.length} 522 </span> 523 </div> 524 <div class="mt-1 border-l-2 border-text-muted/30 ml-3 pl-1"> 525 {#each hierarchy.standalone as ws (ws.id)} 526 <WorkspaceItem 527 workspace={ws} 528 selected={selectedId === ws.id} 529 draggable={isDraggable(ws)} 530 isAudible={audibleWorkspaceIds.has(ws.id)} 531 isOpen={openWorkspaceIds.has(ws.id)} 532 {dragState} 533 {onselect} 534 {oncontextmenu} 535 {ondragstart} 536 {ondragover} 537 {ondragleave} 538 {ondrop} 539 /> 540 {/each} 541 </div> 542 </div> 543 {/if} 544 545 <!-- Empty state --> 546 {#if hierarchy.parents.length === 0 && hierarchy.standalone.length === 0 && hierarchy.transcendent.length === 0} 547 <div class="flex flex-col items-center justify-center py-12 text-center"> 548 <svg class="w-12 h-12 text-text-muted mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 549 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /> 550 </svg> 551 <p class="text-text-secondary mb-1">No workspaces yet</p> 552 <p class="text-text-muted text-sm">Create your first workspace to get started</p> 553 </div> 554 {/if} 555 </div>