ArchivedWorkspacesModal.svelte
1 <script lang="ts"> 2 import type { Workspace, ParentWorkspace, ChildWorkspace } from '../../lib/types'; 3 import { Modal, Button, ConfirmDialog } from '../common'; 4 5 interface Props { 6 open: boolean; 7 onclose: () => void; 8 onRestored: () => void; 9 } 10 11 let { open = $bindable(), onclose, onRestored }: Props = $props(); 12 13 // State 14 let loading = $state(false); 15 let error = $state<string | null>(null); 16 let archivedWorkspaces = $state<Workspace[]>([]); 17 let expandedParents = $state<Set<string>>(new Set()); 18 let confirmDeleteOpen = $state(false); 19 let workspaceToDelete = $state<Workspace | null>(null); 20 let restoringIds = $state<Set<string>>(new Set()); 21 let deletingIds = $state<Set<string>>(new Set()); 22 23 // Collapsible section state 24 let contextsExpanded = $state(true); 25 let standalonesExpanded = $state(true); 26 let orphansExpanded = $state(true); 27 28 // Load archived workspaces when modal opens 29 $effect(() => { 30 if (open) { 31 loadArchived(); 32 } else { 33 // Reset state when closed 34 error = null; 35 expandedParents = new Set(); 36 } 37 }); 38 39 async function loadArchived() { 40 loading = true; 41 error = null; 42 try { 43 const response = await browser.runtime.sendMessage({ type: 'GET_ARCHIVED_WORKSPACES' }); 44 if (Array.isArray(response)) { 45 archivedWorkspaces = response; 46 } else if (response.error) { 47 error = response.error; 48 } 49 } catch (e) { 50 error = e instanceof Error ? e.message : 'Failed to load archived workspaces'; 51 } finally { 52 loading = false; 53 } 54 } 55 56 // Organize into hierarchy 57 const hierarchy = $derived.by(() => { 58 const parents = archivedWorkspaces.filter(ws => ws.type === 'parent') as ParentWorkspace[]; 59 const standalone = archivedWorkspaces.filter(ws => ws.type === 'standalone'); 60 61 // Orphan children: archived children whose parent is NOT archived (parent was deleted or restored) 62 const parentIds = new Set(parents.map(p => p.id)); 63 const orphanChildren = archivedWorkspaces.filter(ws => 64 ws.type === 'child' && 65 !parentIds.has((ws as ChildWorkspace).parentId) 66 ); 67 68 return { parents, standalone, orphanChildren }; 69 }); 70 71 // Get archived children for a parent 72 function getArchivedChildren(parentId: string): ChildWorkspace[] { 73 return archivedWorkspaces.filter( 74 ws => ws.type === 'child' && (ws as ChildWorkspace).parentId === parentId 75 ) as ChildWorkspace[]; 76 } 77 78 // Toggle parent expansion 79 function toggleParentExpanded(id: string) { 80 const newSet = new Set(expandedParents); 81 if (newSet.has(id)) { 82 newSet.delete(id); 83 } else { 84 newSet.add(id); 85 } 86 expandedParents = newSet; 87 } 88 89 // Restore a single workspace 90 async function restoreWorkspace(id: string) { 91 restoringIds = new Set([...restoringIds, id]); 92 try { 93 const response = await browser.runtime.sendMessage({ 94 type: 'RESTORE_WORKSPACE', 95 payload: { id } 96 }); 97 if (response.success) { 98 await loadArchived(); 99 onRestored(); 100 } else { 101 error = response.error || 'Failed to restore workspace'; 102 } 103 } catch (e) { 104 error = e instanceof Error ? e.message : 'Failed to restore workspace'; 105 } finally { 106 restoringIds = new Set([...restoringIds].filter(i => i !== id)); 107 } 108 } 109 110 // Restore an entire context 111 async function restoreContext(parentId: string) { 112 restoringIds = new Set([...restoringIds, parentId]); 113 try { 114 const response = await browser.runtime.sendMessage({ 115 type: 'RESTORE_CONTEXT', 116 payload: { parentId } 117 }); 118 if (response.success) { 119 await loadArchived(); 120 onRestored(); 121 } else { 122 error = response.error || 'Failed to restore context'; 123 } 124 } catch (e) { 125 error = e instanceof Error ? e.message : 'Failed to restore context'; 126 } finally { 127 restoringIds = new Set([...restoringIds].filter(i => i !== parentId)); 128 } 129 } 130 131 // Open confirm dialog for permanent delete 132 function confirmPermanentDelete(ws: Workspace) { 133 workspaceToDelete = ws; 134 confirmDeleteOpen = true; 135 } 136 137 // Permanently delete workspace 138 async function handlePermanentDelete() { 139 if (!workspaceToDelete) return; 140 141 const id = workspaceToDelete.id; 142 deletingIds = new Set([...deletingIds, id]); 143 144 try { 145 const response = await browser.runtime.sendMessage({ 146 type: 'PERMANENTLY_DELETE_WORKSPACE', 147 payload: { id } 148 }); 149 if (response.success) { 150 await loadArchived(); 151 } else { 152 error = response.error || 'Failed to delete workspace'; 153 } 154 } catch (e) { 155 error = e instanceof Error ? e.message : 'Failed to delete workspace'; 156 } finally { 157 deletingIds = new Set([...deletingIds].filter(i => i !== id)); 158 workspaceToDelete = null; 159 confirmDeleteOpen = false; 160 } 161 } 162 163 // Format relative date 164 function formatRelativeDate(isoString: string): string { 165 const date = new Date(isoString); 166 const now = new Date(); 167 const diffMs = now.getTime() - date.getTime(); 168 const diffDays = Math.floor(diffMs / 86400000); 169 170 if (diffDays === 0) return 'Today'; 171 if (diffDays === 1) return 'Yesterday'; 172 if (diffDays < 7) return `${diffDays} days ago`; 173 if (diffDays < 30) return `${Math.floor(diffDays / 7)} week${diffDays >= 14 ? 's' : ''} ago`; 174 return date.toLocaleDateString(); 175 } 176 177 // Get workspace description 178 function getWorkspaceDescription(ws: Workspace): string { 179 if (ws.type === 'parent') { 180 const childCount = getArchivedChildren(ws.id).length; 181 return `Context with ${childCount} archived children`; 182 } 183 const tabCount = 'tabs' in ws ? (ws.tabs?.length || 0) : 0; 184 return `${tabCount} tab${tabCount !== 1 ? 's' : ''}`; 185 } 186 187 // Get total count for a section 188 const contextCount = $derived(hierarchy.parents.length); 189 const standaloneCount = $derived(hierarchy.standalone.length); 190 const orphanCount = $derived(hierarchy.orphanChildren.length); 191 const totalCount = $derived(archivedWorkspaces.length); 192 </script> 193 194 <Modal bind:open title="Archived Workspaces" {onclose}> 195 <div class="archived-content"> 196 {#if error} 197 <div class="error-banner"> 198 <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"> 199 <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" /> 200 </svg> 201 <span>{error}</span> 202 <button onclick={() => error = null} class="ml-auto text-xs hover:underline">Dismiss</button> 203 </div> 204 {/if} 205 206 {#if loading} 207 <div class="loading-state"> 208 <svg class="animate-spin w-6 h-6 text-phosphor" fill="none" viewBox="0 0 24 24"> 209 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 210 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> 211 </svg> 212 <span class="text-text-muted">Loading archived workspaces...</span> 213 </div> 214 {:else if totalCount === 0} 215 <div class="empty-state"> 216 <svg class="w-12 h-12 text-text-muted opacity-50 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 217 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" /> 218 </svg> 219 <p>No archived workspaces</p> 220 <p class="text-sm mt-1">Archived workspaces will appear here</p> 221 </div> 222 {:else} 223 <!-- Archived Contexts Section --> 224 {#if contextCount > 0} 225 <section class="workspace-section"> 226 <button class="section-header" onclick={() => contextsExpanded = !contextsExpanded}> 227 <div class="section-header-left"> 228 <svg class="chevron-icon" class:expanded={contextsExpanded} fill="none" stroke="currentColor" viewBox="0 0 24 24"> 229 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 230 </svg> 231 <h3 class="section-title">Archived Contexts</h3> 232 <span class="section-count">{contextCount}</span> 233 </div> 234 </button> 235 236 {#if contextsExpanded} 237 <div class="workspace-list"> 238 {#each hierarchy.parents as parent (parent.id)} 239 {@const children = getArchivedChildren(parent.id)} 240 <div class="workspace-group"> 241 <div class="workspace-item parent-item"> 242 <button class="expand-toggle" onclick={() => toggleParentExpanded(parent.id)}> 243 <svg class="chevron-small" class:expanded={expandedParents.has(parent.id)} fill="none" stroke="currentColor" viewBox="0 0 24 24"> 244 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 245 </svg> 246 </button> 247 <div class="workspace-info"> 248 <span class="workspace-name">{parent.name}</span> 249 <span class="workspace-meta"> 250 {getWorkspaceDescription(parent)} - Archived {formatRelativeDate(parent.archivedAt!)} 251 </span> 252 </div> 253 <div class="workspace-actions"> 254 <Button 255 variant="ghost" 256 size="sm" 257 onclick={() => restoreContext(parent.id)} 258 disabled={restoringIds.has(parent.id)} 259 > 260 {#if restoringIds.has(parent.id)} 261 <svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"> 262 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 263 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> 264 </svg> 265 {:else} 266 Restore All 267 {/if} 268 </Button> 269 <Button 270 variant="ghost" 271 size="sm" 272 onclick={() => confirmPermanentDelete(parent)} 273 disabled={deletingIds.has(parent.id)} 274 > 275 <svg class="w-4 h-4 text-status-error" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 276 <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" /> 277 </svg> 278 </Button> 279 </div> 280 </div> 281 282 {#if expandedParents.has(parent.id) && children.length > 0} 283 <div class="children-list"> 284 {#each children as child (child.id)} 285 <div class="workspace-item child-item"> 286 <div class="workspace-info"> 287 <span class="workspace-name">{child.name}</span> 288 <span class="workspace-meta">{child.tabs?.length || 0} tabs</span> 289 </div> 290 <Button 291 variant="ghost" 292 size="sm" 293 onclick={() => restoreWorkspace(child.id)} 294 disabled={restoringIds.has(child.id)} 295 > 296 {#if restoringIds.has(child.id)} 297 <svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"> 298 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 299 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> 300 </svg> 301 {:else} 302 Restore 303 {/if} 304 </Button> 305 </div> 306 {/each} 307 </div> 308 {/if} 309 </div> 310 {/each} 311 </div> 312 {/if} 313 </section> 314 {/if} 315 316 <!-- Archived Standalone Section --> 317 {#if standaloneCount > 0} 318 <section class="workspace-section"> 319 <button class="section-header" onclick={() => standalonesExpanded = !standalonesExpanded}> 320 <div class="section-header-left"> 321 <svg class="chevron-icon" class:expanded={standalonesExpanded} fill="none" stroke="currentColor" viewBox="0 0 24 24"> 322 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 323 </svg> 324 <h3 class="section-title">Archived Workspaces</h3> 325 <span class="section-count">{standaloneCount}</span> 326 </div> 327 </button> 328 329 {#if standalonesExpanded} 330 <div class="workspace-list"> 331 {#each hierarchy.standalone as ws (ws.id)} 332 <div class="workspace-item"> 333 <div class="workspace-info"> 334 <span class="workspace-name">{ws.name}</span> 335 <span class="workspace-meta"> 336 {getWorkspaceDescription(ws)} - Archived {formatRelativeDate(ws.archivedAt!)} 337 </span> 338 </div> 339 <div class="workspace-actions"> 340 <Button 341 variant="ghost" 342 size="sm" 343 onclick={() => restoreWorkspace(ws.id)} 344 disabled={restoringIds.has(ws.id)} 345 > 346 {#if restoringIds.has(ws.id)} 347 <svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"> 348 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 349 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> 350 </svg> 351 {:else} 352 Restore 353 {/if} 354 </Button> 355 <Button 356 variant="ghost" 357 size="sm" 358 onclick={() => confirmPermanentDelete(ws)} 359 disabled={deletingIds.has(ws.id)} 360 > 361 <svg class="w-4 h-4 text-status-error" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 362 <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" /> 363 </svg> 364 </Button> 365 </div> 366 </div> 367 {/each} 368 </div> 369 {/if} 370 </section> 371 {/if} 372 373 <!-- Orphan Children Section --> 374 {#if orphanCount > 0} 375 <section class="workspace-section"> 376 <button class="section-header" onclick={() => orphansExpanded = !orphansExpanded}> 377 <div class="section-header-left"> 378 <svg class="chevron-icon" class:expanded={orphansExpanded} fill="none" stroke="currentColor" viewBox="0 0 24 24"> 379 <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /> 380 </svg> 381 <h3 class="section-title">Orphan Children</h3> 382 <span class="section-count">{orphanCount}</span> 383 </div> 384 </button> 385 386 {#if orphansExpanded} 387 <div class="workspace-list"> 388 <p class="orphan-notice">These children's parent context was deleted or restored without them.</p> 389 {#each hierarchy.orphanChildren as ws (ws.id)} 390 <div class="workspace-item"> 391 <div class="workspace-info"> 392 <span class="workspace-name">{ws.name}</span> 393 <span class="workspace-meta"> 394 {(ws as ChildWorkspace).tabs?.length || 0} tabs - Archived {formatRelativeDate(ws.archivedAt!)} 395 </span> 396 </div> 397 <div class="workspace-actions"> 398 <Button 399 variant="ghost" 400 size="sm" 401 onclick={() => restoreWorkspace(ws.id)} 402 disabled={restoringIds.has(ws.id)} 403 title="Restore as standalone workspace" 404 > 405 {#if restoringIds.has(ws.id)} 406 <svg class="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"> 407 <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> 408 <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path> 409 </svg> 410 {:else} 411 Restore 412 {/if} 413 </Button> 414 <Button 415 variant="ghost" 416 size="sm" 417 onclick={() => confirmPermanentDelete(ws)} 418 disabled={deletingIds.has(ws.id)} 419 > 420 <svg class="w-4 h-4 text-status-error" fill="none" stroke="currentColor" viewBox="0 0 24 24"> 421 <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" /> 422 </svg> 423 </Button> 424 </div> 425 </div> 426 {/each} 427 </div> 428 {/if} 429 </section> 430 {/if} 431 {/if} 432 </div> 433 </Modal> 434 435 <ConfirmDialog 436 bind:open={confirmDeleteOpen} 437 title="Permanently Delete?" 438 message={workspaceToDelete?.type === 'parent' 439 ? `This will permanently delete "${workspaceToDelete?.name}" and all its children. This action cannot be undone.` 440 : `This will permanently delete "${workspaceToDelete?.name}". This action cannot be undone.`} 441 confirmText="Delete Forever" 442 variant="danger" 443 onconfirm={handlePermanentDelete} 444 oncancel={() => { confirmDeleteOpen = false; workspaceToDelete = null; }} 445 /> 446 447 <style> 448 .archived-content { 449 display: flex; 450 flex-direction: column; 451 gap: 1rem; 452 min-height: 200px; 453 max-height: 450px; 454 } 455 456 .error-banner { 457 display: flex; 458 align-items: center; 459 gap: 0.5rem; 460 padding: 0.75rem; 461 background-color: rgba(239, 68, 68, 0.15); 462 border: 1px solid rgba(239, 68, 68, 0.3); 463 border-radius: 0.375rem; 464 color: #fca5a5; 465 font-size: 0.875rem; 466 } 467 468 .loading-state { 469 display: flex; 470 flex-direction: column; 471 align-items: center; 472 justify-content: center; 473 gap: 0.75rem; 474 padding: 2rem; 475 } 476 477 .empty-state { 478 text-align: center; 479 padding: 2rem; 480 color: var(--color-text-muted); 481 font-size: 0.875rem; 482 } 483 484 .workspace-section { 485 display: flex; 486 flex-direction: column; 487 gap: 0.5rem; 488 } 489 490 .section-header { 491 display: flex; 492 align-items: center; 493 justify-content: space-between; 494 width: 100%; 495 padding: 0.5rem; 496 border: none; 497 background-color: rgba(13, 61, 61, 0.3); 498 border-radius: 0.375rem; 499 cursor: pointer; 500 transition: background-color 0.15s; 501 } 502 503 .section-header:hover { 504 background-color: rgba(217, 137, 46, 0.15); 505 } 506 507 .section-header-left { 508 display: flex; 509 align-items: center; 510 gap: 0.5rem; 511 } 512 513 .chevron-icon { 514 width: 1rem; 515 height: 1rem; 516 color: var(--color-text-muted); 517 transition: transform 0.2s ease; 518 } 519 520 .chevron-icon.expanded { 521 transform: rotate(90deg); 522 } 523 524 .chevron-small { 525 width: 0.875rem; 526 height: 0.875rem; 527 color: var(--color-text-muted); 528 transition: transform 0.2s ease; 529 } 530 531 .chevron-small.expanded { 532 transform: rotate(90deg); 533 } 534 535 .section-title { 536 font-size: 0.875rem; 537 font-weight: 600; 538 color: var(--color-phosphor); 539 text-transform: uppercase; 540 letter-spacing: 0.05em; 541 } 542 543 .section-count { 544 font-size: 0.75rem; 545 color: var(--color-text-muted); 546 background-color: rgba(217, 137, 46, 0.2); 547 padding: 0.125rem 0.5rem; 548 border-radius: 9999px; 549 } 550 551 .workspace-list { 552 display: flex; 553 flex-direction: column; 554 gap: 0.25rem; 555 max-height: 200px; 556 overflow-y: auto; 557 background-color: rgba(13, 61, 61, 0.3); 558 border-radius: 0.375rem; 559 padding: 0.5rem; 560 } 561 562 .workspace-group { 563 display: flex; 564 flex-direction: column; 565 } 566 567 .workspace-item { 568 display: flex; 569 align-items: center; 570 gap: 0.5rem; 571 padding: 0.5rem; 572 border-radius: 0.25rem; 573 transition: background-color 0.15s; 574 } 575 576 .workspace-item:hover { 577 background-color: rgba(217, 137, 46, 0.1); 578 } 579 580 .parent-item { 581 background-color: rgba(217, 137, 46, 0.05); 582 } 583 584 .expand-toggle { 585 padding: 0.25rem; 586 border: none; 587 background: transparent; 588 cursor: pointer; 589 display: flex; 590 align-items: center; 591 justify-content: center; 592 } 593 594 .workspace-info { 595 display: flex; 596 flex-direction: column; 597 flex: 1; 598 min-width: 0; 599 } 600 601 .workspace-name { 602 font-size: 0.875rem; 603 color: var(--color-text-primary); 604 white-space: nowrap; 605 overflow: hidden; 606 text-overflow: ellipsis; 607 } 608 609 .workspace-meta { 610 font-size: 0.75rem; 611 color: var(--color-text-muted); 612 } 613 614 .workspace-actions { 615 display: flex; 616 align-items: center; 617 gap: 0.25rem; 618 flex-shrink: 0; 619 } 620 621 .children-list { 622 margin-left: 1.5rem; 623 padding-left: 0.5rem; 624 border-left: 2px solid rgba(217, 137, 46, 0.2); 625 display: flex; 626 flex-direction: column; 627 gap: 0.25rem; 628 } 629 630 .child-item { 631 padding: 0.375rem 0.5rem; 632 } 633 634 .orphan-notice { 635 font-size: 0.75rem; 636 color: var(--color-text-muted); 637 padding: 0.5rem; 638 background-color: rgba(217, 137, 46, 0.1); 639 border-radius: 0.25rem; 640 margin-bottom: 0.5rem; 641 } 642 </style>