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