App.svelte
1 <script lang="ts"> 2 import { onMount } from 'svelte'; 3 import { Button, ContextMenu, Modal, Input, ConfirmDialog } from '../../components/common'; 4 import { SaveTabsModal } from '../../components/workspace'; 5 import type { Workspace, ParentWorkspace, ChildWorkspace, SavedTab } from '../../lib/types'; 6 7 let workspaces = $state<Workspace[]>([]); 8 let loading = $state(true); 9 let error = $state<string | null>(null); 10 let savingTabs = $state(false); 11 let showSaveModal = $state(false); 12 let pendingTabs = $state<SavedTab[]>([]); 13 14 // Context menu state 15 let contextMenu = $state<{ workspace: Workspace; x: number; y: number } | null>(null); 16 17 // Rename modal state 18 let renameModalOpen = $state(false); 19 let renameValue = $state(''); 20 let workspaceToRename = $state<Workspace | null>(null); 21 22 // Move to parent modal state 23 let moveModalOpen = $state(false); 24 let workspaceToMove = $state<Workspace | null>(null); 25 let selectedParentId = $state<string | null>(null); 26 27 // Delete confirmation state 28 let deleteConfirmOpen = $state(false); 29 let workspaceToDelete = $state<Workspace | null>(null); 30 31 // Context switch confirmation state 32 let contextSwitchConfirmOpen = $state(false); 33 let pendingContextSwitch = $state<Workspace | null>(null); 34 let contextSwitchImpact = $state<{ windowsToClose: number; transcendentToKeep: number }>({ windowsToClose: 0, transcendentToKeep: 0 }); 35 36 // Drag and drop state 37 let draggedWorkspace = $state<Workspace | null>(null); 38 let dropTargetId = $state<string | null>(null); 39 let dropPosition = $state<'inside' | 'standalone' | null>(null); 40 41 // Open windows tracking 42 let openWorkspaceIds = $state<Set<string>>(new Set()); 43 let activeContextId = $state<string | null>(null); 44 45 // Current window workspace (if this window is assigned) 46 let currentWindowWorkspaceId = $state<string | null>(null); 47 48 // Collapsed parent contexts (collapsed by default) 49 let expandedParents = $state<Set<string>>(new Set()); 50 51 // Playing media tab (if any) 52 let playingTab = $state<{ tabId: number; windowId: number; title: string; favIconUrl?: string } | null>(null); 53 54 function toggleParentExpanded(parentId: string) { 55 const newSet = new Set(expandedParents); 56 if (newSet.has(parentId)) { 57 newSet.delete(parentId); 58 } else { 59 newSet.add(parentId); 60 } 61 expandedParents = newSet; 62 } 63 64 // Derived current window workspace 65 const currentWindowWorkspace = $derived( 66 currentWindowWorkspaceId ? workspaces.find(ws => ws.id === currentWindowWorkspaceId) : null 67 ); 68 69 // Computed - standalone count for empty state check 70 const standaloneCount = $derived(workspaces.filter(ws => ws.type === 'standalone').length); 71 72 // Parent workspaces (for save modal and move modal) 73 const parentWorkspaces = $derived( 74 workspaces.filter((ws): ws is ParentWorkspace => ws.type === 'parent') 75 .sort((a, b) => a.name.localeCompare(b.name)) 76 ); 77 78 // Transcendent workspaces (always visible section) 79 const transcendentWorkspaces = $derived( 80 workspaces.filter(ws => ws.isTranscendent) 81 .sort((a, b) => a.name.localeCompare(b.name)) 82 ); 83 84 // Recent non-transcendent workspaces 85 const recentWorkspaces = $derived( 86 [...workspaces] 87 .filter(ws => !ws.isTranscendent) 88 .sort((a, b) => new Date(b.metadata.lastAccessed).getTime() - new Date(a.metadata.lastAccessed).getTime()) 89 .slice(0, 5) 90 ); 91 92 // Active context workspace 93 const activeContextWorkspace = $derived( 94 activeContextId ? workspaces.find(ws => ws.id === activeContextId) : null 95 ); 96 97 // Helper to check if a workspace is open 98 function isWorkspaceOpen(ws: Workspace): boolean { 99 if (ws.type === 'parent') { 100 return workspaces.some(child => 101 child.type === 'child' && 102 (child as ChildWorkspace).parentId === ws.id && 103 openWorkspaceIds.has(child.id) 104 ); 105 } 106 return openWorkspaceIds.has(ws.id); 107 } 108 109 // Count of open children for a parent 110 function getOpenChildCount(parentWs: Workspace): number { 111 if (parentWs.type !== 'parent') return 0; 112 return workspaces.filter(child => 113 child.type === 'child' && 114 (child as ChildWorkspace).parentId === parentWs.id && 115 openWorkspaceIds.has(child.id) 116 ).length; 117 } 118 119 // Check if all children that would open in context switch are already open 120 function areAllChildrenOpen(parentWs: Workspace): boolean { 121 if (parentWs.type !== 'parent') return false; 122 const children = workspaces.filter(child => 123 child.type === 'child' && 124 (child as ChildWorkspace).parentId === parentWs.id && 125 (child.openInContextSwitch !== false) 126 ); 127 if (children.length === 0) return true; 128 return children.every(child => openWorkspaceIds.has(child.id)); 129 } 130 131 // Context menu items 132 const contextMenuItems = $derived.by(() => { 133 if (!contextMenu) return []; 134 const ws = contextMenu.workspace; 135 const items: Array<{ id: string; label: string; icon?: string; danger?: boolean; separator?: boolean; disabled?: boolean }> = []; 136 137 // "Switch to Context" only for parent workspaces (contexts) 138 if (ws.type === 'parent') { 139 const childCount = workspaces.filter(w => w.type === 'child' && (w as ChildWorkspace).parentId === ws.id).length; 140 const allOpen = areAllChildrenOpen(ws); 141 items.push({ 142 id: 'switch-context', 143 label: allOpen ? 'All Windows Open' : 'Switch to Context', 144 icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>', 145 disabled: childCount === 0 || allOpen 146 }); 147 items.push({ 148 id: 'open-all-windows', 149 label: 'Open All in New Windows', 150 icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"/></svg>', 151 disabled: childCount === 0 || allOpen 152 }); 153 items.push({ id: 'sep0', label: '', separator: true }); 154 } 155 156 // "Open in New Window" for standalone and child workspaces (they have tabs) 157 if (ws.type !== 'parent') { 158 items.push({ 159 id: 'open-new-window', 160 label: 'Open in New Window', 161 icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>' 162 }); 163 items.push({ id: 'sep0', label: '', separator: true }); 164 } 165 166 items.push({ 167 id: 'rename', 168 label: 'Rename', 169 icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/></svg>' 170 }); 171 172 // Add "Move to Parent" for standalones and children 173 if (ws.type === 'standalone' || ws.type === 'child') { 174 items.push({ 175 id: 'move', 176 label: ws.type === 'child' ? 'Move to Different Parent' : 'Add to Parent', 177 icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>' 178 }); 179 } 180 181 // Add "Convert to Parent" for standalones 182 if (ws.type === 'standalone') { 183 items.push({ 184 id: 'convert-to-parent', 185 label: 'Convert to Parent', 186 icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14v6m-3-3h6M6 10h2a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2zm10 0h2a2 2 0 002-2V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v2a2 2 0 002 2zM6 20h2a2 2 0 002-2v-2a2 2 0 00-2-2H6a2 2 0 00-2 2v2a2 2 0 002 2z"/></svg>' 187 }); 188 } 189 190 // Add "Make Standalone" for children 191 if (ws.type === 'child') { 192 items.push({ 193 id: 'make-standalone', 194 label: 'Make Standalone', 195 icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>' 196 }); 197 } 198 199 items.push({ id: 'sep1', label: '', separator: true }); 200 items.push({ 201 id: 'delete', 202 label: 'Delete', 203 icon: '<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>', 204 danger: true 205 }); 206 207 return items; 208 }); 209 210 // Message listener for tab updates from background 211 function handleTabUpdates(message: { type: string; payload?: { workspaceId: string; windowId: number; tabCount: number } }) { 212 if (message.type === 'TABS_UPDATED') { 213 // Reload workspaces when tabs are auto-saved 214 loadWorkspaces(); 215 } 216 } 217 218 onMount(async () => { 219 // Check if dashboard tab exists in current window 220 const dashboardUrl = browser.runtime.getURL('/workspace-dashboard.html'); 221 const currentWindowTabs = await browser.tabs.query({ currentWindow: true }); 222 const dashboardTab = currentWindowTabs.find(tab => tab.url?.startsWith(dashboardUrl)); 223 224 if (!dashboardTab) { 225 // No dashboard in current window - open and pin one, then close popup 226 const newTab = await browser.tabs.create({ 227 url: dashboardUrl, 228 pinned: true, 229 active: true, 230 }); 231 // Move the pinned tab to the beginning 232 if (newTab.id) { 233 await browser.tabs.move(newTab.id, { index: 0 }); 234 } 235 window.close(); 236 return; 237 } 238 239 // Parse dashboard tab URL to get workspace assignment 240 if (dashboardTab.url) { 241 try { 242 const url = new URL(dashboardTab.url); 243 const wsId = url.searchParams.get('workspaceId'); 244 if (wsId) { 245 currentWindowWorkspaceId = wsId; 246 } 247 } catch { 248 // Invalid URL, ignore 249 } 250 } 251 252 // Dashboard exists - load workspaces and show popup 253 await loadWorkspaces(); 254 255 // Listen for tab updates from background (auto-save sync) 256 browser.runtime.onMessage.addListener(handleTabUpdates); 257 258 return () => { 259 browser.runtime.onMessage.removeListener(handleTabUpdates); 260 }; 261 }); 262 263 async function loadWorkspaces() { 264 loading = true; 265 error = null; 266 try { 267 const response = await browser.runtime.sendMessage({ type: 'GET_WORKSPACES' }); 268 workspaces = response || []; 269 // Also scan for open windows and playing media after loading workspaces 270 await scanOpenWindows(); 271 await scanForPlayingMedia(); 272 } catch (e) { 273 error = e instanceof Error ? e.message : 'Failed to load workspaces'; 274 } finally { 275 loading = false; 276 } 277 } 278 279 async function scanOpenWindows() { 280 try { 281 const windows = await browser.windows.getAll({ populate: true }); 282 const dashboardBaseUrl = browser.runtime.getURL('workspace-dashboard.html'); 283 const openIds = new Set<string>(); 284 const openParentIds = new Set<string>(); 285 286 for (const win of windows) { 287 if (!win.tabs) continue; 288 for (const tab of win.tabs) { 289 if (tab.url?.startsWith(dashboardBaseUrl)) { 290 const url = new URL(tab.url); 291 const wsId = url.searchParams.get('workspaceId'); 292 const wsParentId = url.searchParams.get('parentId'); 293 if (wsId) { 294 openIds.add(wsId); 295 if (wsParentId) { 296 openParentIds.add(wsParentId); 297 } 298 } 299 } 300 } 301 } 302 303 // Determine active context 304 let detectedContext: string | null = null; 305 if (openParentIds.size === 1) { 306 detectedContext = [...openParentIds][0]; 307 } 308 309 openWorkspaceIds = openIds; 310 activeContextId = detectedContext; 311 } catch (e) { 312 console.error('Failed to scan open windows:', e); 313 } 314 } 315 316 async function scanForPlayingMedia() { 317 try { 318 const allTabs = await browser.tabs.query({ audible: true }); 319 if (allTabs.length > 0) { 320 // Get the first audible tab 321 const tab = allTabs[0]; 322 if (tab.id && tab.windowId) { 323 playingTab = { 324 tabId: tab.id, 325 windowId: tab.windowId, 326 title: tab.title || 'Playing media', 327 favIconUrl: tab.favIconUrl, 328 }; 329 } 330 } else { 331 playingTab = null; 332 } 333 } catch (e) { 334 console.error('Failed to scan for playing media:', e); 335 playingTab = null; 336 } 337 } 338 339 async function goToPlayingTab() { 340 if (!playingTab) return; 341 try { 342 // Focus the window first 343 await browser.windows.update(playingTab.windowId, { focused: true }); 344 // Then activate the tab 345 await browser.tabs.update(playingTab.tabId, { active: true }); 346 window.close(); 347 } catch (e) { 348 console.error('Failed to go to playing tab:', e); 349 } 350 } 351 352 function openDashboard() { 353 browser.tabs.create({ 354 url: browser.runtime.getURL('/workspace-dashboard.html'), 355 }); 356 window.close(); 357 } 358 359 function openOptions() { 360 browser.runtime.openOptionsPage(); 361 } 362 363 async function saveCurrentTabs() { 364 console.log('[Popup] saveCurrentTabs called'); 365 savingTabs = true; 366 try { 367 const tabs = await browser.tabs.query({ currentWindow: true }); 368 const tabData: SavedTab[] = tabs 369 .filter(tab => tab.url && !tab.url.startsWith('chrome-extension://') && !tab.url.startsWith('moz-extension://')) 370 .map(tab => ({ 371 url: tab.url!, 372 title: tab.title || tab.url!, 373 pinned: tab.pinned || false, 374 })); 375 376 console.log('[Popup] Captured', tabData.length, 'tabs'); 377 378 if (tabData.length === 0) { 379 error = 'No tabs to save'; 380 savingTabs = false; 381 return; 382 } 383 384 pendingTabs = tabData; 385 console.log('[Popup] Opening SaveTabsModal with', pendingTabs.length, 'tabs'); 386 showSaveModal = true; 387 } catch (e) { 388 console.error('[Popup] Error in saveCurrentTabs:', e); 389 error = e instanceof Error ? e.message : 'Failed to get tabs'; 390 } finally { 391 savingTabs = false; 392 } 393 } 394 395 async function handleSaveWorkspace(name: string, parentId: string | null, options: { openInContextSwitch: boolean; syncEnabled: boolean }) { 396 try { 397 let newWorkspace: Workspace | null = null; 398 399 // Convert proxy objects to plain objects for Firefox compatibility 400 const tabs = JSON.parse(JSON.stringify(pendingTabs)); 401 402 if (parentId) { 403 const result = await browser.runtime.sendMessage({ 404 type: 'CREATE_CHILD', 405 payload: { parentId, name, tabs, openInContextSwitch: options.openInContextSwitch, syncEnabled: options.syncEnabled }, 406 }); 407 if (!result.success) throw new Error(result.error || 'Failed to create child workspace'); 408 newWorkspace = result.workspace; 409 } else { 410 const result = await browser.runtime.sendMessage({ 411 type: 'CREATE_STANDALONE', 412 payload: { name, tabs, openInContextSwitch: options.openInContextSwitch, syncEnabled: options.syncEnabled }, 413 }); 414 if (!result.success) throw new Error(result.error || 'Failed to create standalone workspace'); 415 newWorkspace = result.workspace; 416 } 417 418 // Auto-assign window: Update the dashboard tab URL to reflect the new workspace assignment 419 if (newWorkspace) { 420 const dashboardUrl = browser.runtime.getURL('/workspace-dashboard.html'); 421 const currentWindowTabs = await browser.tabs.query({ currentWindow: true }); 422 const dashboardTab = currentWindowTabs.find(tab => tab.url?.startsWith(dashboardUrl)); 423 424 if (dashboardTab?.id) { 425 // Build new URL with workspace assignment 426 const newDashboardUrl = new URL(dashboardUrl); 427 newDashboardUrl.searchParams.set('workspaceId', newWorkspace.id); 428 newDashboardUrl.searchParams.set('type', newWorkspace.type); 429 if (newWorkspace.isTranscendent) { 430 newDashboardUrl.searchParams.set('transcendent', 'true'); 431 } 432 if (newWorkspace.type === 'child' && (newWorkspace as ChildWorkspace).parentId) { 433 newDashboardUrl.searchParams.set('parentId', (newWorkspace as ChildWorkspace).parentId); 434 } 435 436 await browser.tabs.update(dashboardTab.id, { url: newDashboardUrl.toString() }); 437 currentWindowWorkspaceId = newWorkspace.id; 438 } 439 440 // Auto-generate summary in background (fire and forget) 441 browser.runtime.sendMessage({ 442 type: 'GENERATE_WORKSPACE_SUMMARY', 443 payload: { workspaceId: newWorkspace.id }, 444 }).catch(() => { 445 // Silently ignore errors - summary generation is optional 446 }); 447 } 448 449 await loadWorkspaces(); 450 showSaveModal = false; 451 pendingTabs = []; 452 } catch (e) { 453 error = e instanceof Error ? e.message : 'Failed to save workspace'; 454 } 455 } 456 457 function handleCancelSave() { 458 showSaveModal = false; 459 pendingTabs = []; 460 } 461 462 function selectWorkspace(workspace: Workspace) { 463 browser.tabs.create({ 464 url: browser.runtime.getURL(`/workspace-dashboard.html?workspaceId=${workspace.id}&type=${workspace.type}`), 465 }); 466 window.close(); 467 } 468 469 // Context menu handlers 470 function handleContextMenu(e: MouseEvent, workspace: Workspace) { 471 e.preventDefault(); 472 e.stopPropagation(); 473 contextMenu = { workspace, x: e.clientX, y: e.clientY }; 474 } 475 476 function handleContextMenuSelect(id: string) { 477 if (!contextMenu) return; 478 const ws = contextMenu.workspace; 479 480 switch (id) { 481 case 'switch-context': 482 switchToContext(ws); 483 break; 484 case 'open-all-windows': 485 openAllInNewWindows(ws); 486 break; 487 case 'open-new-window': 488 openInNewWindow(ws); 489 break; 490 case 'rename': 491 workspaceToRename = ws; 492 renameValue = ws.name; 493 renameModalOpen = true; 494 break; 495 case 'move': 496 workspaceToMove = ws; 497 selectedParentId = ws.type === 'child' ? (ws as ChildWorkspace).parentId : null; 498 moveModalOpen = true; 499 break; 500 case 'make-standalone': 501 makeStandalone(ws); 502 break; 503 case 'convert-to-parent': 504 convertToParent(ws); 505 break; 506 case 'delete': 507 // Check if this is a parent with transcendent children 508 if (ws.type === 'parent') { 509 const transcendentChildren = workspaces.filter( 510 w => w.type === 'child' && 511 (w as ChildWorkspace).parentId === ws.id && 512 w.isTranscendent 513 ); 514 if (transcendentChildren.length > 0) { 515 error = `Cannot delete: has ${transcendentChildren.length} transcendent child workspace(s)`; 516 break; 517 } 518 } 519 workspaceToDelete = ws; 520 deleteConfirmOpen = true; 521 break; 522 } 523 524 contextMenu = null; 525 } 526 527 async function handleRename() { 528 if (!workspaceToRename || !renameValue.trim()) return; 529 530 const trimmedName = renameValue.trim(); 531 532 // Skip validation if name hasn't changed 533 if (trimmedName.toLowerCase() === workspaceToRename.name.toLowerCase()) { 534 renameModalOpen = false; 535 return; 536 } 537 538 // Check for duplicate name within the same scope 539 const duplicateExists = workspaces.some(ws => { 540 if (ws.id === workspaceToRename!.id) return false; 541 if (ws.name.toLowerCase() === trimmedName.toLowerCase()) { 542 if (workspaceToRename!.type === 'child' && ws.type === 'child') { 543 return (ws as ChildWorkspace).parentId === (workspaceToRename as ChildWorkspace).parentId; 544 } 545 if (workspaceToRename!.type !== 'child' && ws.type !== 'child') { 546 return true; 547 } 548 } 549 return false; 550 }); 551 552 if (duplicateExists) { 553 error = `A workspace named "${trimmedName}" already exists`; 554 return; 555 } 556 557 try { 558 await browser.runtime.sendMessage({ 559 type: 'RENAME_WORKSPACE', 560 payload: { id: workspaceToRename.id, name: trimmedName } 561 }); 562 await loadWorkspaces(); 563 renameModalOpen = false; 564 } catch (e) { 565 error = e instanceof Error ? e.message : 'Failed to rename workspace'; 566 } 567 } 568 569 async function handleMove() { 570 if (!workspaceToMove || !selectedParentId) return; 571 572 try { 573 await browser.runtime.sendMessage({ 574 type: 'MOVE_WORKSPACE', 575 payload: { id: workspaceToMove.id, newParentId: selectedParentId } 576 }); 577 await loadWorkspaces(); 578 moveModalOpen = false; 579 } catch (e) { 580 error = e instanceof Error ? e.message : 'Failed to move workspace'; 581 } 582 } 583 584 async function makeStandalone(workspace: Workspace) { 585 try { 586 await browser.runtime.sendMessage({ 587 type: 'MOVE_WORKSPACE', 588 payload: { id: workspace.id, newParentId: null } 589 }); 590 await loadWorkspaces(); 591 } catch (e) { 592 error = e instanceof Error ? e.message : 'Failed to make standalone'; 593 } 594 } 595 596 async function convertToParent(workspace: Workspace) { 597 if (workspace.type !== 'standalone') return; 598 599 try { 600 await browser.runtime.sendMessage({ 601 type: 'CONVERT_TO_PARENT', 602 payload: { id: workspace.id } 603 }); 604 await loadWorkspaces(); 605 } catch (e) { 606 error = e instanceof Error ? e.message : 'Failed to convert to parent'; 607 } 608 } 609 610 async function calculateContextSwitchImpact(): Promise<{ windowsToClose: number; transcendentToKeep: number; windowIds: number[] }> { 611 const windows = await browser.windows.getAll({ populate: true }); 612 const dashboardBaseUrl = browser.runtime.getURL('workspace-dashboard.html'); 613 const transcendentIds = new Set(workspaces.filter(ws => ws.isTranscendent).map(ws => ws.id)); 614 615 let windowsToClose = 0; 616 let transcendentToKeep = 0; 617 const windowIdsToClose: number[] = []; 618 619 for (const win of windows) { 620 if (!win.tabs || !win.id) continue; 621 622 const dashboardTab = win.tabs.find(tab => tab.url?.startsWith(dashboardBaseUrl)); 623 if (dashboardTab) { 624 const url = new URL(dashboardTab.url!); 625 const wsId = url.searchParams.get('workspaceId'); 626 if (wsId && transcendentIds.has(wsId)) { 627 transcendentToKeep++; 628 continue; 629 } 630 } 631 632 windowsToClose++; 633 windowIdsToClose.push(win.id); 634 } 635 636 return { windowsToClose, transcendentToKeep, windowIds: windowIdsToClose }; 637 } 638 639 async function initiateContextSwitch(workspace: Workspace) { 640 if (workspace.type !== 'parent') { 641 error = 'Context switch is only available for parent workspaces'; 642 return; 643 } 644 645 const children = workspaces.filter(w => w.type === 'child' && (w as ChildWorkspace).parentId === workspace.id); 646 if (children.length === 0) { 647 error = 'This parent context has no child workspaces'; 648 return; 649 } 650 651 const impact = await calculateContextSwitchImpact(); 652 contextSwitchImpact = { windowsToClose: impact.windowsToClose, transcendentToKeep: impact.transcendentToKeep }; 653 pendingContextSwitch = workspace; 654 contextSwitchConfirmOpen = true; 655 } 656 657 async function executeContextSwitch() { 658 if (!pendingContextSwitch || pendingContextSwitch.type !== 'parent') return; 659 660 try { 661 // Only open children that have openInContextSwitch enabled (or undefined, defaulting to true) 662 const children = workspaces.filter(w => 663 w.type === 'child' && 664 (w as ChildWorkspace).parentId === pendingContextSwitch!.id && 665 (w.openInContextSwitch !== false) // Default to true if not set 666 ); 667 const impact = await calculateContextSwitchImpact(); 668 669 // Close non-transcendent windows 670 const currentWindow = await browser.windows.getCurrent(); 671 for (const windowId of impact.windowIds) { 672 if (windowId !== currentWindow.id) { 673 try { 674 await browser.windows.remove(windowId); 675 } catch (e) { 676 console.warn('Failed to close window:', windowId, e); 677 } 678 } 679 } 680 681 // Open child workspaces in new windows with dashboard tabs 682 for (const child of children) { 683 const childTabs = (child as { tabs?: { url: string; title: string; pinned?: boolean }[] }).tabs || []; 684 if (childTabs.length > 0) { 685 const dashboardUrl = new URL(browser.runtime.getURL('workspace-dashboard.html')); 686 dashboardUrl.searchParams.set('workspaceId', child.id); 687 dashboardUrl.searchParams.set('type', 'child'); 688 dashboardUrl.searchParams.set('transcendent', child.isTranscendent.toString()); 689 dashboardUrl.searchParams.set('parentId', (child as ChildWorkspace).parentId); 690 691 const newWindow = await browser.windows.create({ 692 url: dashboardUrl.toString(), 693 focused: false, 694 }); 695 696 if (newWindow.id) { 697 const tabs = await browser.tabs.query({ windowId: newWindow.id }); 698 if (tabs[0]?.id) { 699 await browser.tabs.update(tabs[0].id, { pinned: true }); 700 } 701 702 for (const tab of childTabs) { 703 await browser.tabs.create({ 704 windowId: newWindow.id, 705 url: tab.url, 706 pinned: tab.pinned || false, 707 active: false, 708 }); 709 } 710 } 711 } 712 } 713 714 // Save active context 715 await browser.storage.local.set({ activeContextId: pendingContextSwitch.id }); 716 717 // Close current window if needed 718 if (currentWindow.id && impact.windowIds.includes(currentWindow.id)) { 719 await browser.windows.remove(currentWindow.id); 720 } else { 721 window.close(); 722 } 723 } catch (e) { 724 error = e instanceof Error ? e.message : 'Failed to switch context'; 725 } finally { 726 contextSwitchConfirmOpen = false; 727 pendingContextSwitch = null; 728 } 729 } 730 731 function cancelContextSwitch() { 732 contextSwitchConfirmOpen = false; 733 pendingContextSwitch = null; 734 } 735 736 async function switchToContext(workspace: Workspace) { 737 await initiateContextSwitch(workspace); 738 } 739 740 async function openInNewWindow(workspace: Workspace) { 741 if (workspace.type === 'parent') return; 742 743 const tabs = (workspace as { tabs?: { url: string; title: string; pinned?: boolean }[] }).tabs || []; 744 745 try { 746 // Create dashboard URL for this workspace 747 const dashboardUrl = new URL(browser.runtime.getURL('workspace-dashboard.html')); 748 dashboardUrl.searchParams.set('workspaceId', workspace.id); 749 dashboardUrl.searchParams.set('type', workspace.type); 750 dashboardUrl.searchParams.set('transcendent', workspace.isTranscendent.toString()); 751 if (workspace.type === 'child') { 752 dashboardUrl.searchParams.set('parentId', (workspace as ChildWorkspace).parentId); 753 } 754 755 // Create new window with dashboard tab 756 const newWindow = await browser.windows.create({ 757 url: dashboardUrl.toString(), 758 focused: true, 759 }); 760 761 if (newWindow.id) { 762 // Pin the dashboard tab 763 const windowTabs = await browser.tabs.query({ windowId: newWindow.id }); 764 if (windowTabs[0]?.id) { 765 await browser.tabs.update(windowTabs[0].id, { pinned: true }); 766 } 767 768 // Add workspace tabs 769 for (const tab of tabs) { 770 await browser.tabs.create({ 771 windowId: newWindow.id, 772 url: tab.url, 773 pinned: tab.pinned || false, 774 active: false, 775 }); 776 } 777 } 778 779 window.close(); 780 } catch (e) { 781 error = e instanceof Error ? e.message : 'Failed to open in new window'; 782 } 783 } 784 785 async function openAllInNewWindows(parentWorkspace: Workspace) { 786 if (parentWorkspace.type !== 'parent') return; 787 788 const children = workspaces.filter( 789 w => w.type === 'child' && (w as ChildWorkspace).parentId === parentWorkspace.id 790 ); 791 792 if (children.length === 0) { 793 error = 'This parent has no child workspaces'; 794 return; 795 } 796 797 try { 798 for (const child of children) { 799 const childTabs = (child as { tabs?: { url: string; title: string; pinned?: boolean }[] }).tabs || []; 800 801 // Create dashboard URL for this child 802 const dashboardUrl = new URL(browser.runtime.getURL('workspace-dashboard.html')); 803 dashboardUrl.searchParams.set('workspaceId', child.id); 804 dashboardUrl.searchParams.set('type', 'child'); 805 dashboardUrl.searchParams.set('transcendent', child.isTranscendent.toString()); 806 dashboardUrl.searchParams.set('parentId', (child as ChildWorkspace).parentId); 807 808 // Create new window with dashboard tab 809 const newWindow = await browser.windows.create({ 810 url: dashboardUrl.toString(), 811 focused: false, 812 }); 813 814 if (newWindow.id) { 815 // Pin the dashboard tab 816 const windowTabs = await browser.tabs.query({ windowId: newWindow.id }); 817 if (windowTabs[0]?.id) { 818 await browser.tabs.update(windowTabs[0].id, { pinned: true }); 819 } 820 821 // Add workspace tabs 822 for (const tab of childTabs) { 823 await browser.tabs.create({ 824 windowId: newWindow.id, 825 url: tab.url, 826 pinned: tab.pinned || false, 827 active: false, 828 }); 829 } 830 } 831 } 832 833 window.close(); 834 } catch (e) { 835 error = e instanceof Error ? e.message : 'Failed to open windows'; 836 } 837 } 838 839 async function handleDelete() { 840 if (!workspaceToDelete) return; 841 842 try { 843 await browser.runtime.sendMessage({ 844 type: 'DELETE_WORKSPACE', 845 payload: { id: workspaceToDelete.id, keepChildren: true } 846 }); 847 await loadWorkspaces(); 848 } catch (e) { 849 error = e instanceof Error ? e.message : 'Failed to delete workspace'; 850 } 851 } 852 853 // Drag and drop handlers 854 function handleDragStart(e: DragEvent, workspace: Workspace) { 855 if (workspace.type === 'parent') { 856 e.preventDefault(); 857 return; 858 } 859 draggedWorkspace = workspace; 860 e.dataTransfer?.setData('text/plain', workspace.id); 861 if (e.dataTransfer) { 862 e.dataTransfer.effectAllowed = 'move'; 863 } 864 } 865 866 function handleDragOver(e: DragEvent, targetId: string, position: 'inside' | 'standalone') { 867 if (!draggedWorkspace) return; 868 if (draggedWorkspace.id === targetId) return; 869 870 e.preventDefault(); 871 if (e.dataTransfer) { 872 e.dataTransfer.dropEffect = 'move'; 873 } 874 dropTargetId = targetId; 875 dropPosition = position; 876 } 877 878 function handleDragLeave() { 879 dropTargetId = null; 880 dropPosition = null; 881 } 882 883 async function handleDrop(e: DragEvent, targetId: string, position: 'inside' | 'standalone') { 884 e.preventDefault(); 885 if (!draggedWorkspace) return; 886 887 try { 888 if (position === 'inside') { 889 // Move into parent 890 await browser.runtime.sendMessage({ 891 type: 'MOVE_WORKSPACE', 892 payload: { id: draggedWorkspace.id, newParentId: targetId } 893 }); 894 } else { 895 // Make standalone (dropped on standalone zone) 896 await browser.runtime.sendMessage({ 897 type: 'MOVE_WORKSPACE', 898 payload: { id: draggedWorkspace.id, newParentId: null } 899 }); 900 } 901 await loadWorkspaces(); 902 } catch (e) { 903 error = e instanceof Error ? (e as Error).message : 'Failed to move workspace'; 904 } finally { 905 draggedWorkspace = null; 906 dropTargetId = null; 907 dropPosition = null; 908 } 909 } 910 911 function handleDragEnd() { 912 draggedWorkspace = null; 913 dropTargetId = null; 914 dropPosition = null; 915 } 916 917 function isDraggable(ws: Workspace): boolean { 918 return ws.type === 'standalone' || ws.type === 'child'; 919 } 920 </script> 921 922 <div class="popup"> 923 <!-- Header --> 924 <header class="popup-header"> 925 <div class="flex items-center gap-2"> 926 <div class="logo-mark">M</div> 927 <div> 928 <h1 class="popup-title">Mnemonic</h1> 929 <p class="popup-subtitle">Workspace Manager</p> 930 </div> 931 </div> 932 {#if playingTab} 933 <button class="go-to-playing" onclick={goToPlayingTab} title={playingTab.title}> 934 <svg class="playing-icon" viewBox="0 0 24 24" fill="currentColor"> 935 <path d="M8 5v14l11-7z"/> 936 </svg> 937 <span>Go to Playing</span> 938 </button> 939 {/if} 940 </header> 941 942 <main class="popup-content"> 943 {#if loading} 944 <div class="loading"> 945 <div class="loading-spinner"></div> 946 <span class="loading-text">Loading...</span> 947 </div> 948 {:else if error} 949 <div class="error"> 950 <svg class="w-5 h-5 mb-2" fill="currentColor" viewBox="0 0 20 20"> 951 <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clip-rule="evenodd" /> 952 </svg> 953 <span>{error}</span> 954 <button class="retry-btn" onclick={() => { error = null; loadWorkspaces(); }}>Retry</button> 955 </div> 956 {:else} 957 <!-- Quick Actions --> 958 <div class="quick-actions"> 959 {#if currentWindowWorkspace} 960 <div class="active-workspace-display"> 961 <svg class="w-5 h-5 text-phosphor" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 962 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> 963 </svg> 964 <span class="active-workspace-name">{currentWindowWorkspace.name}</span> 965 {#if currentWindowWorkspace.isTranscendent} 966 <svg class="w-4 h-4 text-phosphor" fill="currentColor" viewBox="0 0 20 20"> 967 <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.5z" /> 968 </svg> 969 {/if} 970 </div> 971 {:else} 972 <button class="quick-action" onclick={saveCurrentTabs} disabled={savingTabs}> 973 <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 974 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" /> 975 </svg> 976 <span>{savingTabs ? 'Saving...' : 'Save Current Tabs'}</span> 977 </button> 978 {/if} 979 </div> 980 981 <!-- Transcendent Workspaces --> 982 {#if transcendentWorkspaces.length > 0} 983 <div class="transcendent-section"> 984 <h2 class="section-title section-title-transcendent"> 985 <svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"> 986 <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" /> 987 </svg> 988 Transcendent 989 </h2> 990 <div class="transcendent-list"> 991 {#each transcendentWorkspaces as ws (ws.id)} 992 <button 993 class="transcendent-item" 994 class:dragging={draggedWorkspace?.id === ws.id} 995 draggable={isDraggable(ws)} 996 onclick={() => selectWorkspace(ws)} 997 oncontextmenu={(e) => handleContextMenu(e, ws)} 998 ondragstart={(e) => handleDragStart(e, ws)} 999 ondragend={handleDragEnd} 1000 > 1001 <svg class="w-4 h-4 text-phosphor" fill="currentColor" viewBox="0 0 20 20"> 1002 <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" /> 1003 </svg> 1004 <span class="transcendent-name">{ws.name}</span> 1005 </button> 1006 {/each} 1007 </div> 1008 </div> 1009 {/if} 1010 1011 <!-- Parent Workspaces (Contexts) with drop zones --> 1012 {#if parentWorkspaces.length > 0} 1013 <div class="section"> 1014 <h2 class="section-title">Contexts</h2> 1015 <div class="parent-list"> 1016 {#each parentWorkspaces as parent (parent.id)} 1017 {@const children = workspaces.filter(ws => ws.type === 'child' && (ws as ChildWorkspace).parentId === parent.id)} 1018 {@const isExpanded = expandedParents.has(parent.id)} 1019 <div 1020 class="parent-item" 1021 class:drop-target={dropTargetId === parent.id && dropPosition === 'inside'} 1022 role="listitem" 1023 ondragover={(e) => handleDragOver(e, parent.id, 'inside')} 1024 ondragleave={handleDragLeave} 1025 ondrop={(e) => handleDrop(e, parent.id, 'inside')} 1026 > 1027 <div 1028 class="parent-header" 1029 role="group" 1030 oncontextmenu={(e) => handleContextMenu(e, parent)} 1031 > 1032 <button 1033 class="parent-toggle" 1034 onclick={() => toggleParentExpanded(parent.id)} 1035 title={isExpanded ? "Collapse" : "Expand"} 1036 > 1037 <svg class="chevron-icon" class:expanded={isExpanded} fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1038 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 1039 </svg> 1040 </button> 1041 {#if isWorkspaceOpen(parent)} 1042 <span class="open-indicator" title="{getOpenChildCount(parent)} window(s) open"></span> 1043 {/if} 1044 <button class="parent-name-btn" onclick={() => selectWorkspace(parent)}> 1045 <svg class="w-4 h-4 text-phosphor" fill="currentColor" viewBox="0 0 20 20"> 1046 <path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z" /> 1047 </svg> 1048 <span class="parent-name">{parent.name}</span> 1049 </button> 1050 <span class="parent-count">{children.length}</span> 1051 </div> 1052 {#if isExpanded && children.length > 0} 1053 <div class="children-list"> 1054 {#each children as child (child.id)} 1055 <button 1056 class="child-item" 1057 class:dragging={draggedWorkspace?.id === child.id} 1058 class:is-open={isWorkspaceOpen(child)} 1059 draggable={true} 1060 onclick={() => selectWorkspace(child)} 1061 oncontextmenu={(e) => handleContextMenu(e, child)} 1062 ondragstart={(e) => handleDragStart(e, child)} 1063 ondragend={handleDragEnd} 1064 > 1065 {#if isWorkspaceOpen(child)} 1066 <span class="open-indicator" title="Window open"></span> 1067 {/if} 1068 <span class="child-name">{child.name}</span> 1069 <span class="child-tabs">{(child as {tabs: unknown[]}).tabs?.length || 0} tabs</span> 1070 </button> 1071 {/each} 1072 </div> 1073 {/if} 1074 </div> 1075 {/each} 1076 </div> 1077 </div> 1078 {/if} 1079 1080 <!-- Standalone Workspaces with drop zone --> 1081 <div 1082 class="section standalone-section" 1083 class:drop-target={dropPosition === 'standalone'} 1084 role="region" 1085 aria-label="Standalone workspaces" 1086 ondragover={(e) => { if (draggedWorkspace?.type === 'child') handleDragOver(e, 'standalone-zone', 'standalone'); }} 1087 ondragleave={handleDragLeave} 1088 ondrop={(e) => handleDrop(e, 'standalone-zone', 'standalone')} 1089 > 1090 <h2 class="section-title">Standalone</h2> 1091 {#if workspaces.filter(ws => ws.type === 'standalone').length > 0} 1092 <div class="standalone-list"> 1093 {#each workspaces.filter(ws => ws.type === 'standalone') as ws (ws.id)} 1094 <button 1095 class="standalone-item" 1096 class:dragging={draggedWorkspace?.id === ws.id} 1097 class:transcendent={ws.isTranscendent} 1098 class:is-open={isWorkspaceOpen(ws)} 1099 draggable={true} 1100 onclick={() => selectWorkspace(ws)} 1101 oncontextmenu={(e) => handleContextMenu(e, ws)} 1102 ondragstart={(e) => handleDragStart(e, ws)} 1103 ondragend={handleDragEnd} 1104 > 1105 {#if isWorkspaceOpen(ws)} 1106 <span class="open-indicator" title="Window open"></span> 1107 {/if} 1108 {#if ws.isTranscendent} 1109 <svg class="w-4 h-4 text-phosphor" fill="currentColor" viewBox="0 0 20 20"> 1110 <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" /> 1111 </svg> 1112 {:else} 1113 <svg class="w-4 h-4 text-text-muted" fill="currentColor" viewBox="0 0 20 20"> 1114 <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" /> 1115 </svg> 1116 {/if} 1117 <span class="standalone-name">{ws.name}</span> 1118 <span class="standalone-tabs">{(ws as {tabs: unknown[]}).tabs?.length || 0} tabs</span> 1119 </button> 1120 {/each} 1121 </div> 1122 {:else} 1123 <p class="empty-hint">Drop workspaces here to make standalone</p> 1124 {/if} 1125 </div> 1126 1127 {#if parentWorkspaces.length === 0 && standaloneCount === 0 && transcendentWorkspaces.length === 0} 1128 <div class="empty-state"> 1129 <p class="empty-text">No workspaces yet</p> 1130 <p class="empty-hint">Open the dashboard to create your first workspace</p> 1131 </div> 1132 {/if} 1133 {/if} 1134 </main> 1135 1136 <!-- Footer Actions --> 1137 <footer class="popup-actions"> 1138 <Button variant="primary" onclick={openDashboard}> 1139 Open Dashboard 1140 </Button> 1141 <Button variant="ghost" onclick={openOptions}> 1142 <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1143 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" /> 1144 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /> 1145 </svg> 1146 </Button> 1147 </footer> 1148 </div> 1149 1150 <!-- Context Menu --> 1151 {#if contextMenu} 1152 <ContextMenu 1153 items={contextMenuItems} 1154 x={contextMenu.x} 1155 y={contextMenu.y} 1156 onSelect={handleContextMenuSelect} 1157 onClose={() => contextMenu = null} 1158 /> 1159 {/if} 1160 1161 <!-- Save Tabs Modal --> 1162 <SaveTabsModal 1163 bind:open={showSaveModal} 1164 parents={parentWorkspaces} 1165 tabs={pendingTabs} 1166 onSave={handleSaveWorkspace} 1167 onCancel={handleCancelSave} 1168 /> 1169 1170 <!-- Rename Modal --> 1171 <Modal bind:open={renameModalOpen} title="Rename Workspace"> 1172 <Input 1173 bind:value={renameValue} 1174 label="New Name" 1175 placeholder="Enter new name" 1176 /> 1177 1178 {#snippet footer()} 1179 <Button variant="ghost" onclick={() => renameModalOpen = false}>Cancel</Button> 1180 <Button variant="primary" onclick={handleRename} disabled={!renameValue.trim()}>Rename</Button> 1181 {/snippet} 1182 </Modal> 1183 1184 <!-- Move to Parent Modal --> 1185 <Modal bind:open={moveModalOpen} title="Move to Parent"> 1186 <div class="move-form"> 1187 <label class="field-label" for="parent-select">Select Parent Workspace</label> 1188 <select id="parent-select" class="select" bind:value={selectedParentId}> 1189 <option value={null} disabled>Choose a parent...</option> 1190 {#each parentWorkspaces.filter(p => workspaceToMove?.type !== 'child' || (workspaceToMove as ChildWorkspace).parentId !== p.id) as parent (parent.id)} 1191 <option value={parent.id}>{parent.name}</option> 1192 {/each} 1193 </select> 1194 </div> 1195 1196 {#snippet footer()} 1197 <Button variant="ghost" onclick={() => moveModalOpen = false}>Cancel</Button> 1198 <Button variant="primary" onclick={handleMove} disabled={!selectedParentId}>Move</Button> 1199 {/snippet} 1200 </Modal> 1201 1202 <!-- Delete Confirmation --> 1203 <ConfirmDialog 1204 bind:open={deleteConfirmOpen} 1205 title="Delete Workspace" 1206 message="Are you sure you want to delete '{workspaceToDelete?.name}'? {workspaceToDelete?.type === 'parent' ? 'Child workspaces will become standalone.' : ''}" 1207 variant="danger" 1208 confirmText="Delete" 1209 onconfirm={handleDelete} 1210 /> 1211 1212 <!-- Context Switch Confirmation --> 1213 <Modal bind:open={contextSwitchConfirmOpen} title="Switch Context"> 1214 <div class="context-switch-confirm"> 1215 <div class="confirm-header"> 1216 <svg class="w-5 h-5 text-status-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1217 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /> 1218 </svg> 1219 <span class="text-sm">Switch to "{pendingContextSwitch?.name}"?</span> 1220 </div> 1221 1222 <div class="confirm-impact"> 1223 {#if contextSwitchImpact.windowsToClose > 0} 1224 <div class="impact-item text-status-error"> 1225 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1226 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> 1227 </svg> 1228 <span>Close {contextSwitchImpact.windowsToClose} window{contextSwitchImpact.windowsToClose !== 1 ? 's' : ''}</span> 1229 </div> 1230 {/if} 1231 {#if contextSwitchImpact.transcendentToKeep > 0} 1232 <div class="impact-item text-phosphor"> 1233 <svg class="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20"> 1234 <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.5z" /> 1235 </svg> 1236 <span>Keep {contextSwitchImpact.transcendentToKeep} transcendent</span> 1237 </div> 1238 {/if} 1239 <div class="impact-item text-status-success"> 1240 <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 1241 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" /> 1242 </svg> 1243 <span>Open {pendingContextSwitch ? workspaces.filter(w => w.type === 'child' && (w as ChildWorkspace).parentId === pendingContextSwitch.id && w.openInContextSwitch !== false).length : 0} workspace(s)</span> 1244 </div> 1245 </div> 1246 1247 <p class="text-xs text-text-muted">Unsaved work may be lost.</p> 1248 </div> 1249 1250 {#snippet footer()} 1251 <Button variant="ghost" size="sm" onclick={cancelContextSwitch}> 1252 Cancel 1253 </Button> 1254 <Button variant="primary" size="sm" onclick={executeContextSwitch}> 1255 Switch 1256 </Button> 1257 {/snippet} 1258 </Modal> 1259 1260 <style> 1261 .context-switch-confirm { 1262 display: flex; 1263 flex-direction: column; 1264 gap: 0.75rem; 1265 } 1266 1267 .confirm-header { 1268 display: flex; 1269 align-items: center; 1270 gap: 0.5rem; 1271 } 1272 1273 .confirm-impact { 1274 background: rgba(15, 61, 61, 0.3); 1275 border-radius: 6px; 1276 padding: 0.625rem; 1277 display: flex; 1278 flex-direction: column; 1279 gap: 0.375rem; 1280 } 1281 1282 .impact-item { 1283 display: flex; 1284 align-items: center; 1285 gap: 0.375rem; 1286 font-size: 0.75rem; 1287 } 1288 1289 .popup { 1290 width: 340px; 1291 min-height: 280px; 1292 max-height: 580px; 1293 display: flex; 1294 flex-direction: column; 1295 background-color: var(--color-bg); 1296 overflow: hidden; 1297 position: relative; 1298 } 1299 1300 .popup-header { 1301 display: flex; 1302 align-items: center; 1303 justify-content: space-between; 1304 padding: 0.875rem 1rem; 1305 border-bottom: 1px solid rgba(217, 137, 46, 0.2); 1306 background-color: var(--color-bg-light); 1307 } 1308 1309 .logo-mark { 1310 width: 32px; 1311 height: 32px; 1312 display: flex; 1313 align-items: center; 1314 justify-content: center; 1315 background-color: var(--color-phosphor); 1316 color: var(--color-bg); 1317 font-weight: bold; 1318 font-size: 1.125rem; 1319 border-radius: 6px; 1320 } 1321 1322 .popup-title { 1323 font-size: 1rem; 1324 font-weight: 600; 1325 color: var(--color-phosphor); 1326 margin: 0; 1327 line-height: 1.2; 1328 } 1329 1330 .popup-subtitle { 1331 font-size: 0.625rem; 1332 color: var(--color-text-muted); 1333 margin: 0; 1334 text-transform: uppercase; 1335 letter-spacing: 0.05em; 1336 } 1337 1338 .go-to-playing { 1339 display: flex; 1340 align-items: center; 1341 gap: 0.375rem; 1342 padding: 0.375rem 0.625rem; 1343 background: rgba(16, 185, 129, 0.15); 1344 border: 1px solid rgba(16, 185, 129, 0.4); 1345 border-radius: 6px; 1346 color: #10b981; 1347 font-size: 0.6875rem; 1348 font-weight: 500; 1349 cursor: pointer; 1350 transition: all 0.15s; 1351 white-space: nowrap; 1352 } 1353 1354 .go-to-playing:hover { 1355 background: rgba(16, 185, 129, 0.25); 1356 border-color: #10b981; 1357 } 1358 1359 .playing-icon { 1360 width: 12px; 1361 height: 12px; 1362 animation: pulse-play 1.5s ease-in-out infinite; 1363 } 1364 1365 @keyframes pulse-play { 1366 0%, 100% { 1367 opacity: 1; 1368 } 1369 50% { 1370 opacity: 0.5; 1371 } 1372 } 1373 1374 .popup-content { 1375 flex: 1; 1376 overflow-y: auto; 1377 padding: 0.75rem; 1378 } 1379 1380 .loading, .error, .empty-state { 1381 display: flex; 1382 flex-direction: column; 1383 align-items: center; 1384 justify-content: center; 1385 padding: 2rem 1rem; 1386 text-align: center; 1387 } 1388 1389 .loading-spinner { 1390 width: 24px; 1391 height: 24px; 1392 border: 2px solid var(--color-bg-light); 1393 border-top-color: var(--color-phosphor); 1394 border-radius: 50%; 1395 animation: spin 0.8s linear infinite; 1396 margin-bottom: 0.5rem; 1397 } 1398 1399 @keyframes spin { 1400 to { transform: rotate(360deg); } 1401 } 1402 1403 .loading-text { 1404 color: var(--color-text-secondary); 1405 font-size: 0.875rem; 1406 } 1407 1408 .error { 1409 color: #f87171; 1410 } 1411 1412 .retry-btn { 1413 margin-top: 0.5rem; 1414 padding: 0.25rem 0.75rem; 1415 font-size: 0.75rem; 1416 background: transparent; 1417 border: 1px solid currentColor; 1418 border-radius: 4px; 1419 cursor: pointer; 1420 transition: background-color 0.2s; 1421 } 1422 1423 .retry-btn:hover { 1424 background-color: rgba(248, 113, 113, 0.1); 1425 } 1426 1427 .quick-actions { 1428 margin-bottom: 0.75rem; 1429 } 1430 1431 .quick-action { 1432 width: 100%; 1433 display: flex; 1434 align-items: center; 1435 justify-content: center; 1436 gap: 0.5rem; 1437 padding: 0.625rem; 1438 background-color: var(--color-bg-light); 1439 border: 1px solid rgba(217, 137, 46, 0.3); 1440 border-radius: 6px; 1441 color: var(--color-phosphor); 1442 font-size: 0.875rem; 1443 cursor: pointer; 1444 transition: all 0.2s; 1445 } 1446 1447 .quick-action:hover:not(:disabled) { 1448 background-color: var(--color-phosphor); 1449 color: var(--color-bg); 1450 } 1451 1452 .quick-action:disabled { 1453 opacity: 0.6; 1454 cursor: not-allowed; 1455 } 1456 1457 .active-workspace-display { 1458 width: 100%; 1459 display: flex; 1460 align-items: center; 1461 justify-content: center; 1462 gap: 0.5rem; 1463 padding: 0.625rem; 1464 background-color: rgba(217, 137, 46, 0.15); 1465 border: 1px solid rgba(217, 137, 46, 0.4); 1466 border-radius: 6px; 1467 color: var(--color-phosphor); 1468 font-size: 0.875rem; 1469 } 1470 1471 .active-workspace-name { 1472 font-weight: 500; 1473 white-space: nowrap; 1474 overflow: hidden; 1475 text-overflow: ellipsis; 1476 max-width: 180px; 1477 } 1478 1479 .section { 1480 margin-bottom: 0.75rem; 1481 } 1482 1483 .section-title { 1484 font-size: 0.625rem; 1485 font-weight: 500; 1486 text-transform: uppercase; 1487 letter-spacing: 0.05em; 1488 color: var(--color-text-muted); 1489 margin-bottom: 0.5rem; 1490 padding-left: 0.25rem; 1491 } 1492 1493 .transcendent-section { 1494 margin-bottom: 0.75rem; 1495 padding: 0.5rem; 1496 background: linear-gradient(135deg, rgba(217, 137, 46, 0.1) 0%, rgba(217, 137, 46, 0.05) 100%); 1497 border: 1px solid rgba(217, 137, 46, 0.2); 1498 border-radius: 6px; 1499 } 1500 1501 .section-title-transcendent { 1502 display: flex; 1503 align-items: center; 1504 gap: 0.375rem; 1505 color: var(--color-phosphor); 1506 } 1507 1508 .transcendent-list { 1509 display: flex; 1510 flex-wrap: wrap; 1511 gap: 0.375rem; 1512 margin-top: 0.375rem; 1513 } 1514 1515 .transcendent-item { 1516 display: flex; 1517 align-items: center; 1518 gap: 0.375rem; 1519 padding: 0.375rem 0.625rem; 1520 background: rgba(217, 137, 46, 0.15); 1521 border: 1px solid rgba(217, 137, 46, 0.3); 1522 border-radius: 9999px; 1523 cursor: pointer; 1524 transition: all 0.15s; 1525 font-size: 0.75rem; 1526 } 1527 1528 .transcendent-item:hover { 1529 background: var(--color-phosphor); 1530 border-color: var(--color-phosphor); 1531 } 1532 1533 .transcendent-item:hover .transcendent-name { 1534 color: var(--color-bg); 1535 } 1536 1537 .transcendent-item:hover :global(svg) { 1538 color: var(--color-bg); 1539 } 1540 1541 .transcendent-item.dragging { 1542 opacity: 0.5; 1543 } 1544 1545 .transcendent-name { 1546 color: var(--color-phosphor); 1547 white-space: nowrap; 1548 } 1549 1550 /* Parent workspaces */ 1551 .parent-list { 1552 display: flex; 1553 flex-direction: column; 1554 gap: 0.5rem; 1555 } 1556 1557 .parent-item { 1558 border: 1px solid rgba(217, 137, 46, 0.2); 1559 border-radius: 6px; 1560 overflow: hidden; 1561 transition: all 0.2s; 1562 } 1563 1564 .parent-item.drop-target { 1565 border-color: var(--color-phosphor); 1566 background-color: rgba(217, 137, 46, 0.1); 1567 } 1568 1569 .parent-header { 1570 display: flex; 1571 align-items: center; 1572 gap: 0.375rem; 1573 padding: 0.375rem 0.5rem; 1574 background-color: var(--color-bg-light); 1575 width: 100%; 1576 transition: background-color 0.15s; 1577 } 1578 1579 .parent-header:hover { 1580 background-color: rgba(217, 137, 46, 0.15); 1581 } 1582 1583 .parent-toggle { 1584 display: flex; 1585 align-items: center; 1586 justify-content: center; 1587 width: 20px; 1588 height: 20px; 1589 padding: 0; 1590 background: none; 1591 border: none; 1592 cursor: pointer; 1593 color: var(--color-text-muted); 1594 border-radius: 3px; 1595 transition: all 0.15s; 1596 } 1597 1598 .parent-toggle:hover { 1599 background-color: rgba(217, 137, 46, 0.2); 1600 color: var(--color-phosphor); 1601 } 1602 1603 .chevron-icon { 1604 width: 14px; 1605 height: 14px; 1606 transition: transform 0.2s ease; 1607 } 1608 1609 .chevron-icon.expanded { 1610 transform: rotate(90deg); 1611 } 1612 1613 .parent-name-btn { 1614 display: flex; 1615 align-items: center; 1616 gap: 0.375rem; 1617 flex: 1; 1618 padding: 0.25rem 0.375rem; 1619 background: none; 1620 border: none; 1621 cursor: pointer; 1622 border-radius: 4px; 1623 transition: background-color 0.15s; 1624 text-align: left; 1625 } 1626 1627 .parent-name-btn:hover { 1628 background-color: rgba(217, 137, 46, 0.15); 1629 } 1630 1631 .parent-name { 1632 font-size: 0.8125rem; 1633 color: var(--color-text-primary); 1634 font-weight: 500; 1635 } 1636 1637 .parent-count { 1638 font-size: 0.625rem; 1639 color: var(--color-text-muted); 1640 background-color: rgba(217, 137, 46, 0.2); 1641 padding: 0.125rem 0.375rem; 1642 border-radius: 9999px; 1643 } 1644 1645 .children-list { 1646 border-top: 1px solid rgba(217, 137, 46, 0.15); 1647 } 1648 1649 .child-item { 1650 display: flex; 1651 align-items: center; 1652 gap: 0.5rem; 1653 padding: 0.375rem 0.625rem 0.375rem 1.5rem; 1654 background: transparent; 1655 border: none; 1656 width: 100%; 1657 cursor: grab; 1658 transition: background-color 0.15s; 1659 text-align: left; 1660 } 1661 1662 .child-item:hover { 1663 background-color: rgba(217, 137, 46, 0.1); 1664 } 1665 1666 .child-item.dragging { 1667 opacity: 0.5; 1668 cursor: grabbing; 1669 } 1670 1671 .child-name { 1672 flex: 1; 1673 font-size: 0.75rem; 1674 color: var(--color-text-secondary); 1675 } 1676 1677 .child-tabs { 1678 font-size: 0.625rem; 1679 color: var(--color-text-muted); 1680 } 1681 1682 /* Open indicator */ 1683 .open-indicator { 1684 width: 6px; 1685 height: 6px; 1686 border-radius: 50%; 1687 background-color: #10b981; 1688 animation: pulse 2s infinite; 1689 flex-shrink: 0; 1690 } 1691 1692 @keyframes pulse { 1693 0%, 100% { 1694 opacity: 1; 1695 } 1696 50% { 1697 opacity: 0.5; 1698 } 1699 } 1700 1701 .child-item.is-open, 1702 .standalone-item.is-open { 1703 background-color: rgba(16, 185, 129, 0.1); 1704 } 1705 1706 .child-item.is-open:hover, 1707 .standalone-item.is-open:hover { 1708 background-color: rgba(16, 185, 129, 0.15); 1709 } 1710 1711 /* Standalone workspaces */ 1712 .standalone-section { 1713 padding: 0.5rem; 1714 border: 1px dashed rgba(217, 137, 46, 0.2); 1715 border-radius: 6px; 1716 min-height: 60px; 1717 transition: all 0.2s; 1718 } 1719 1720 .standalone-section.drop-target { 1721 border-color: var(--color-phosphor); 1722 background-color: rgba(217, 137, 46, 0.1); 1723 } 1724 1725 .standalone-list { 1726 display: flex; 1727 flex-direction: column; 1728 gap: 0.25rem; 1729 } 1730 1731 .standalone-item { 1732 display: flex; 1733 align-items: center; 1734 gap: 0.5rem; 1735 padding: 0.5rem 0.625rem; 1736 background: transparent; 1737 border: none; 1738 border-radius: 4px; 1739 cursor: grab; 1740 transition: background-color 0.15s; 1741 text-align: left; 1742 width: 100%; 1743 } 1744 1745 .standalone-item:hover { 1746 background-color: var(--color-bg-light); 1747 } 1748 1749 .standalone-item.dragging { 1750 opacity: 0.5; 1751 cursor: grabbing; 1752 } 1753 1754 .standalone-item.transcendent { 1755 background-color: rgba(217, 137, 46, 0.1); 1756 border-left: 2px solid var(--color-phosphor); 1757 } 1758 1759 .standalone-name { 1760 flex: 1; 1761 font-size: 0.8125rem; 1762 color: var(--color-text-primary); 1763 white-space: nowrap; 1764 overflow: hidden; 1765 text-overflow: ellipsis; 1766 } 1767 1768 .standalone-tabs { 1769 font-size: 0.625rem; 1770 color: var(--color-text-muted); 1771 white-space: nowrap; 1772 } 1773 1774 .empty-text { 1775 color: var(--color-text-secondary); 1776 margin-bottom: 0.25rem; 1777 } 1778 1779 .empty-hint { 1780 font-size: 0.75rem; 1781 color: var(--color-text-muted); 1782 text-align: center; 1783 } 1784 1785 .popup-actions { 1786 display: flex; 1787 gap: 0.5rem; 1788 padding: 0.75rem; 1789 border-top: 1px solid rgba(217, 137, 46, 0.2); 1790 background-color: var(--color-bg-dark); 1791 } 1792 1793 .popup-actions :global(button:first-child) { 1794 flex: 1; 1795 } 1796 1797 /* Move modal styles */ 1798 .move-form { 1799 display: flex; 1800 flex-direction: column; 1801 gap: 0.5rem; 1802 } 1803 1804 .field-label { 1805 font-size: 0.875rem; 1806 font-weight: 500; 1807 color: var(--color-text-secondary); 1808 } 1809 1810 .select { 1811 width: 100%; 1812 padding: 0.5rem 0.75rem; 1813 background-color: var(--color-bg-dark); 1814 color: var(--color-text-primary); 1815 border: 1px solid rgba(217, 137, 46, 0.3); 1816 border-radius: 6px; 1817 font-size: 0.875rem; 1818 cursor: pointer; 1819 } 1820 1821 .select:focus { 1822 outline: none; 1823 border-color: var(--color-phosphor); 1824 } 1825 </style>