/ src / components / workspace / WorkspaceItem.svelte
WorkspaceItem.svelte
  1  <script lang="ts">
  2    import type { Workspace, ChildWorkspace, TabWorkspace } from '../../lib/types';
  3    import { isTabWorkspace } from '../../lib/types';
  4    import type { DragState, DropPosition } from '../../lib/dnd/types';
  5    import WorkspaceIcon from './WorkspaceIcon.svelte';
  6    import TranscendentBadge from './TranscendentBadge.svelte';
  7  
  8    interface Props {
  9      workspace: Workspace;
 10      selected?: boolean;
 11      depth?: number;
 12      expanded?: boolean;
 13      draggable?: boolean;
 14      dragState?: DragState | null;
 15      isAudible?: boolean;
 16      isOpen?: boolean;
 17      openChildCount?: number;
 18      onselect?: (workspace: Workspace) => void;
 19      ontoggle?: (workspace: Workspace) => void;
 20      oncontextmenu?: (e: MouseEvent, workspace: Workspace) => void;
 21      ondragstart?: (workspace: Workspace) => void;
 22      ondragover?: (workspace: Workspace, position: DropPosition) => void;
 23      ondragleave?: () => void;
 24      ondrop?: (workspace: Workspace) => void;
 25    }
 26  
 27    let {
 28      workspace,
 29      selected = false,
 30      depth = 0,
 31      expanded = false,
 32      draggable = false,
 33      dragState = null,
 34      isAudible = false,
 35      isOpen = false,
 36      openChildCount = 0,
 37      onselect,
 38      ontoggle,
 39      oncontextmenu,
 40      ondragstart,
 41      ondragover,
 42      ondragleave,
 43      ondrop,
 44    }: Props = $props();
 45  
 46    // Determine drop feedback class based on drag state
 47    const dropClass = $derived.by(() => {
 48      if (!dragState || dragState.dropTargetId !== workspace.id) return '';
 49      switch (dragState.dropPosition) {
 50        case 'before': return 'drop-indicator-before';
 51        case 'after': return 'drop-indicator-after';
 52        case 'inside': return 'drop-indicator-inside';
 53        default: return '';
 54      }
 55    });
 56  
 57    const isDragging = $derived(dragState?.draggedId === workspace.id);
 58  
 59    const tabCount = $derived(
 60      isTabWorkspace(workspace) ? (workspace as TabWorkspace).tabs.length : 0
 61    );
 62  
 63    function handleClick() {
 64      onselect?.(workspace);
 65    }
 66  
 67    function handleToggle(e: MouseEvent) {
 68      e.stopPropagation();
 69      ontoggle?.(workspace);
 70    }
 71  
 72    function handleContextMenu(e: MouseEvent) {
 73      e.preventDefault();
 74      oncontextmenu?.(e, workspace);
 75    }
 76  
 77    function handleKeydown(e: KeyboardEvent) {
 78      if (e.key === 'Enter' || e.key === ' ') {
 79        e.preventDefault();
 80        onselect?.(workspace);
 81      }
 82      if (e.key === 'ArrowRight' && workspace.type === 'parent' && !expanded) {
 83        ontoggle?.(workspace);
 84      }
 85      if (e.key === 'ArrowLeft' && workspace.type === 'parent' && expanded) {
 86        ontoggle?.(workspace);
 87      }
 88    }
 89  
 90    function handleDragStart(e: DragEvent) {
 91      if (!draggable) {
 92        e.preventDefault();
 93        return;
 94      }
 95      // Set drag data
 96      e.dataTransfer?.setData('text/plain', workspace.id);
 97      if (e.dataTransfer) {
 98        e.dataTransfer.effectAllowed = 'move';
 99      }
100      ondragstart?.(workspace);
101    }
102  
103    function handleDragOver(e: DragEvent) {
104      e.preventDefault();
105      if (!e.dataTransfer) return;
106      e.dataTransfer.dropEffect = 'move';
107  
108      // Calculate position based on mouse Y relative to element
109      const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
110      const y = e.clientY - rect.top;
111      const height = rect.height;
112  
113      let position: DropPosition;
114      if (workspace.type === 'parent') {
115        // Parents have 3 zones: top 25% = before, middle 50% = inside, bottom 25% = after
116        if (y < height * 0.25) {
117          position = 'before';
118        } else if (y > height * 0.75) {
119          position = 'after';
120        } else {
121          position = 'inside';
122        }
123      } else {
124        // Non-parents only have before/after
125        position = y < height / 2 ? 'before' : 'after';
126      }
127  
128      ondragover?.(workspace, position);
129    }
130  
131    function handleDragLeave(e: DragEvent) {
132      // Only fire if actually leaving this element (not entering a child)
133      const relatedTarget = e.relatedTarget as HTMLElement;
134      const currentTarget = e.currentTarget as HTMLElement;
135      if (!relatedTarget || !currentTarget.contains(relatedTarget)) {
136        ondragleave?.();
137      }
138    }
139  
140    function handleDrop(e: DragEvent) {
141      e.preventDefault();
142      ondrop?.(workspace);
143    }
144  
145    function handleDragEnd() {
146      ondragleave?.();
147    }
148  </script>
149  
150  <div
151    class="workspace-item group flex items-center gap-2 pr-3 py-2 rounded cursor-pointer transition-all duration-150
152      {selected ? 'bg-surface-raised border-l-2 border-phosphor' : 'hover:bg-surface-raised/50 border-l-2 border-transparent'}
153      {isDragging ? 'opacity-50' : ''}
154      {dropClass}"
155    style="padding-left: {4 + depth * 8}px"
156    role="treeitem"
157    tabindex="0"
158    aria-selected={selected}
159    aria-expanded={workspace.type === 'parent' ? expanded : undefined}
160    draggable={draggable}
161    onclick={handleClick}
162    oncontextmenu={handleContextMenu}
163    onkeydown={handleKeydown}
164    ondragstart={handleDragStart}
165    ondragover={handleDragOver}
166    ondragleave={handleDragLeave}
167    ondrop={handleDrop}
168    ondragend={handleDragEnd}
169  >
170    <!-- Expand/collapse toggle for parents only -->
171    {#if workspace.type === 'parent'}
172      <button
173        class="flex-shrink-0 p-0.5 rounded hover:bg-phosphor/20 transition-colors"
174        onclick={handleToggle}
175        aria-label={expanded ? 'Collapse' : 'Expand'}
176      >
177        <svg
178          class="w-4 h-4 text-text-muted transition-transform duration-150 {expanded ? 'rotate-90' : ''}"
179          fill="none"
180          stroke="currentColor"
181          viewBox="0 0 24 24"
182        >
183          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
184        </svg>
185      </button>
186    {/if}
187  
188    <!-- Icon -->
189    <WorkspaceIcon
190      type={workspace.type}
191      isTranscendent={workspace.isTranscendent}
192      {expanded}
193    />
194  
195    <!-- Name and metadata -->
196    <div class="flex-1 min-w-0">
197      <div class="flex items-center gap-2">
198        {#if isOpen}
199          <span class="flex-shrink-0 w-2 h-2 rounded-full bg-status-success" title="Window open"></span>
200        {/if}
201        <span class="text-sm font-medium whitespace-nowrap {workspace.isTranscendent ? 'text-phosphor font-semibold' : (selected ? 'text-phosphor' : 'text-text-primary')}">
202          {workspace.name}
203        </span>
204        {#if isAudible}
205          <span class="flex-shrink-0 text-status-success animate-pulse" title="Playing audio">
206            <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
207              <path fill-rule="evenodd" d="M9.383 3.076A1 1 0 0110 4v12a1 1 0 01-1.707.707L4.586 13H2a1 1 0 01-1-1V8a1 1 0 011-1h2.586l3.707-3.707a1 1 0 011.09-.217zM14.657 2.929a1 1 0 011.414 0A9.972 9.972 0 0119 10a9.972 9.972 0 01-2.929 7.071 1 1 0 01-1.414-1.414A7.971 7.971 0 0017 10c0-2.21-.894-4.208-2.343-5.657a1 1 0 010-1.414zm-2.829 2.828a1 1 0 011.415 0A5.983 5.983 0 0115 10a5.984 5.984 0 01-1.757 4.243 1 1 0 01-1.415-1.415A3.984 3.984 0 0013 10a3.983 3.983 0 00-1.172-2.828 1 1 0 010-1.415z" clip-rule="evenodd" />
208            </svg>
209          </span>
210        {/if}
211      </div>
212    </div>
213  
214    <!-- Tab count for child/standalone -->
215    {#if workspace.type !== 'parent' && tabCount > 0}
216      <span class="text-xs text-text-muted font-mono">
217        {tabCount} tab{tabCount === 1 ? '' : 's'}
218      </span>
219    {/if}
220  
221    <!-- Child count for parents -->
222    {#if workspace.type === 'parent'}
223      {@const childCount = (workspace as { children: string[] }).children.length}
224      <span class="text-xs text-text-muted font-mono">
225        {openChildCount}/{childCount} children
226      </span>
227    {/if}
228  
229    <!-- Action buttons (visible on hover) -->
230    <div class="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
231      <button
232        class="p-1 rounded hover:bg-phosphor/20 text-text-muted hover:text-phosphor transition-colors"
233        onclick={(e) => { e.stopPropagation(); handleContextMenu(e); }}
234        title="More options"
235      >
236        <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
237          <path d="M10 6a2 2 0 110-4 2 2 0 010 4zM10 12a2 2 0 110-4 2 2 0 010 4zM10 18a2 2 0 110-4 2 2 0 010 4z" />
238        </svg>
239      </button>
240    </div>
241  </div>
242  
243  <style>
244    /* Drop indicator styles */
245    .drop-indicator-before {
246      box-shadow: 0 -2px 0 0 var(--color-phosphor);
247    }
248  
249    .drop-indicator-after {
250      box-shadow: 0 2px 0 0 var(--color-phosphor);
251    }
252  
253    .drop-indicator-inside {
254      background-color: rgba(217, 137, 46, 0.2);
255      outline: 2px dashed var(--color-phosphor);
256      outline-offset: -2px;
257    }
258  
259    /* Ensure draggable cursor */
260    .workspace-item[draggable="true"] {
261      cursor: grab;
262    }
263  
264    .workspace-item[draggable="true"]:active {
265      cursor: grabbing;
266    }
267  </style>