/ src / components / workspace / WorkspaceList.svelte
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>