From f7a3e609af7520a048bd1a0474c3340b5e175e3d Mon Sep 17 00:00:00 2001 From: Rod Boev <106971+rodboev@users.noreply.github.com> Date: Thu, 29 Oct 2026 22:20:14 -0500 Subject: [PATCH 01/61] feat(web): unify files, sources, and deletion review Files and archived accounts need the same navigation and review controls as Messages. Keep source schedules visible when they are configured outside the settings catalog. Reviewing a Gmail deletion should keep the selected messages in view. Cancellation still needs confirmation and must reach the daemon as soon as the user confirms it. --- web/src/App.svelte | 3 + .../lib/components/common/SidePanel.svelte | 55 ++ .../lib/components/common/StatusChip.svelte | 11 + web/src/lib/components/common/run-state.ts | 11 + .../deletions/DeletionReview.svelte | 162 ++++ .../deletions/DeletionReview.test.ts | 82 ++ .../deletions/DeletionsWorkspace.svelte | 789 +++++++----------- .../deletions/DeletionsWorkspace.test.ts | 570 ++----------- .../lib/components/explore/ContextBar.svelte | 8 +- ...nBar.svelte => ExploreSelectionBar.svelte} | 2 +- ...ar.test.ts => ExploreSelectionBar.test.ts} | 46 +- .../lib/components/explore/FilterMenu.svelte | 5 +- .../lib/components/files/FileFilters.svelte | 38 + .../lib/components/files/FileViewer.svelte | 6 +- .../lib/components/files/FileViewer.test.ts | 20 +- .../components/files/FilesWorkspace.svelte | 172 ++-- .../components/files/FilesWorkspace.test.ts | 126 +-- web/src/lib/components/files/filter-chips.ts | 14 + .../kata/KataFileIssueAction.svelte | 33 +- .../kata/KataFileIssueAction.test.ts | 32 +- .../settings/SettingsWorkspace.svelte | 16 +- web/src/lib/components/shell/AppShell.svelte | 64 +- web/src/lib/components/shell/AppShell.test.ts | 39 +- .../lib/components/shell/AppSidebar.svelte | 6 +- .../shell/EverythingWorkspace.svelte | 4 +- web/src/lib/components/shell/navigation.ts | 5 +- .../shell/shell-data.svelte.test.ts | 21 + .../lib/components/shell/shell-data.svelte.ts | 12 +- .../lib/components/sources/CommandLine.svelte | 33 + .../components/sources/ContactPanel.svelte | 121 +++ .../lib/components/sources/SourcePanel.svelte | 351 ++++++++ .../sources/SourcesWorkspace.svelte | 451 +++++----- .../sources/SourcesWorkspace.test.ts | 342 +++++--- .../components/tasks/AddToTaskDialog.svelte | 10 +- web/src/lib/search/visual-ready.svelte.ts | 17 + web/src/lib/settings/draft.svelte.ts | 134 +++ web/src/lib/sources/contacts.ts | 51 ++ web/src/lib/sources/labels.ts | 86 +- web/tests/archive-management.spec.ts | 64 +- web/tests/files-viewer.spec.ts | 8 +- 40 files changed, 2368 insertions(+), 1652 deletions(-) create mode 100644 web/src/lib/components/common/SidePanel.svelte create mode 100644 web/src/lib/components/common/StatusChip.svelte create mode 100644 web/src/lib/components/common/run-state.ts create mode 100644 web/src/lib/components/deletions/DeletionReview.svelte create mode 100644 web/src/lib/components/deletions/DeletionReview.test.ts rename web/src/lib/components/explore/{SelectionBar.svelte => ExploreSelectionBar.svelte} (99%) rename web/src/lib/components/explore/{SelectionBar.test.ts => ExploreSelectionBar.test.ts} (89%) create mode 100644 web/src/lib/components/files/FileFilters.svelte create mode 100644 web/src/lib/components/files/filter-chips.ts create mode 100644 web/src/lib/components/sources/CommandLine.svelte create mode 100644 web/src/lib/components/sources/ContactPanel.svelte create mode 100644 web/src/lib/components/sources/SourcePanel.svelte create mode 100644 web/src/lib/search/visual-ready.svelte.ts create mode 100644 web/src/lib/settings/draft.svelte.ts create mode 100644 web/src/lib/sources/contacts.ts diff --git a/web/src/App.svelte b/web/src/App.svelte index cbb8326c5d..2792490d29 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -8,6 +8,7 @@ import { receiveGoogleContactsCallback } from './lib/settings/google-authorization'; import { createSessionController, type SessionController } from './lib/api/session.svelte'; import { provideKataReadiness } from './lib/kata/kata-ready.svelte'; + import { provideVisualReadiness } from './lib/search/visual-ready.svelte'; import { provideMediaReadiness } from './lib/search/media-ready.svelte'; import Login from './lib/components/auth/Login.svelte'; import SettingsWorkspace from './lib/components/settings/SettingsWorkspace.svelte'; @@ -33,6 +34,8 @@ provideKataReadiness(session.client); // svelte-ignore state_referenced_locally provideMediaReadiness(session.client); + // svelte-ignore state_referenced_locally + provideVisualReadiness(session.client); let oauthCallback = $state(false); let pathname = $state(window.location.pathname); const messageID = $derived(Number(/^\/messages\/([1-9]\d*)\/?$/.exec(pathname)?.[1]) || undefined); diff --git a/web/src/lib/components/common/SidePanel.svelte b/web/src/lib/components/common/SidePanel.svelte new file mode 100644 index 0000000000..8875563213 --- /dev/null +++ b/web/src/lib/components/common/SidePanel.svelte @@ -0,0 +1,55 @@ + + + +
+ + {#snippet header()} + {title} + + + {/snippet} + {@render children()} + +
+ + diff --git a/web/src/lib/components/common/StatusChip.svelte b/web/src/lib/components/common/StatusChip.svelte new file mode 100644 index 0000000000..29ec166d33 --- /dev/null +++ b/web/src/lib/components/common/StatusChip.svelte @@ -0,0 +1,11 @@ + + +{entry.label} diff --git a/web/src/lib/components/common/run-state.ts b/web/src/lib/components/common/run-state.ts new file mode 100644 index 0000000000..f72e3ce7b7 --- /dev/null +++ b/web/src/lib/components/common/run-state.ts @@ -0,0 +1,11 @@ +import type { ChipTone } from '@kenn-io/kit-ui'; + +export type RunState = 'running' | 'succeeded' | 'partial' | 'failed'; + +/** The one run-state vocabulary: label and tone for every job, sync, and run. */ +export const RUN_STATES: Record = { + running: { label: 'Running', tone: 'info' }, + succeeded: { label: 'Succeeded', tone: 'success' }, + partial: { label: 'Partial', tone: 'warning' }, + failed: { label: 'Failed', tone: 'danger' }, +}; diff --git a/web/src/lib/components/deletions/DeletionReview.svelte b/web/src/lib/components/deletions/DeletionReview.svelte new file mode 100644 index 0000000000..b218a1353d --- /dev/null +++ b/web/src/lib/components/deletions/DeletionReview.svelte @@ -0,0 +1,162 @@ + + + void stage()} + > + + diff --git a/web/src/lib/components/deletions/DeletionReview.test.ts b/web/src/lib/components/deletions/DeletionReview.test.ts new file mode 100644 index 0000000000..2db2ba7945 --- /dev/null +++ b/web/src/lib/components/deletions/DeletionReview.test.ts @@ -0,0 +1,82 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createAPIClient } from '../../api/client'; +import type { ExploreSelection } from '../../api/generated/models'; +import DeletionReview from './DeletionReview.svelte'; + +const selection: ExploreSelection = { + mode: 'explicit', predicate: { presentation: 'table' }, row_keys: ['source:1:message:m1'], + cache_revision: 'cache-1', search_provenance: {} +}; + +function preflight(overrides: Record = {}) { + return { + count: 3, deletable_count: 2, estimated_bytes: 3 * 1024, cache_revision: 'cache-1', search_provenance: {}, + unavailable_actions: [], operation_token: 'token-1', expires_at: '2026-07-19T10:05:00Z', ...overrides + }; +} + +interface Seen { method: string; path: string; body?: Record } + +function api(review = preflight(), seen: Seen[] = []) { + return createAPIClient(vi.fn(async (input) => { + const request = input instanceof Request ? input : new Request(input); + const path = new URL(request.url).pathname; + const body = request.method === 'POST' ? await request.clone().json() : undefined; + seen.push({ method: request.method, path, body }); + if (path.endsWith('/explore/preflight')) return Response.json(review); + if (path === '/api/v1/deletions' && request.method === 'POST') { + return body?.dry_run + ? Response.json({ dry_run: true, message_count: 2, matched_count: 3 }) + : Response.json({ dry_run: false, id: 'batch-9', message_count: 2, matched_count: 3 }, { status: 201 }); + } + if (request.method === 'DELETE') return Response.json({ id: 'batch-9', status: 'cancelled' }); + return Response.json({}, { status: 404 }); + })); +} + +afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); +}); + +describe('DeletionReview', () => { + it('shows counts and a Stage button with no confirmation modal on top', async () => { + render(DeletionReview, { client: api(), selection, onclose: vi.fn() }); + expect(await screen.findByText('3 messages · 3 KB')).toBeDefined(); + expect(screen.getByText("1 can't be deleted from Gmail and stay.")).toBeDefined(); + expect(screen.getAllByRole('dialog')).toHaveLength(1); + expect(screen.getByRole('button', { name: 'Stage 2 messages' })).toBeDefined(); + expect(screen.queryByText(/manifest|execute|Microsoft Graph/i)).toBeNull(); + }); + + it('stages the reviewed selection and closes after daemon acknowledgement', async () => { + const seen: Seen[] = []; + const onStaged = vi.fn(), onclose = vi.fn(); + render(DeletionReview, { client: api(preflight(), seen), selection, onclose, onStaged }); + await fireEvent.click(await screen.findByRole('button', { name: 'Stage 2 messages' })); + await waitFor(() => expect(onStaged).toHaveBeenCalledWith('batch-9')); + expect(onclose).toHaveBeenCalledOnce(); + expect(seen.find(entry => entry.path === '/api/v1/deletions')?.body).toMatchObject({ selection, operation_token: 'token-1', dry_run: false }); + }); + + it('runs a dry run from the menu without staging anything', async () => { + const seen: Seen[] = []; + render(DeletionReview, { client: api(preflight(), seen), selection, onclose: vi.fn() }); + await screen.findByText('3 messages · 3 KB'); + await fireEvent.click(screen.getByRole('button', { name: 'Dry run' })); + expect(await screen.findByText('Dry run: 2 messages would be staged.')).toBeDefined(); + expect(seen.filter((entry) => entry.method === 'POST' && entry.body?.dry_run === false)).toEqual([]); + }); + + it('disables staging and says why when nothing qualifies', async () => { + const review = preflight({ + deletable_count: 0, + unavailable_actions: [{ action: 'stage_deletion', reason: 'no_deletable_messages' }] + }); + render(DeletionReview, { client: api(review), selection, onclose: vi.fn() }); + const stage = await screen.findByRole('button', { name: 'Stage 0 messages' }); + expect((stage as HTMLButtonElement).disabled).toBe(true); + }); +}); diff --git a/web/src/lib/components/deletions/DeletionsWorkspace.svelte b/web/src/lib/components/deletions/DeletionsWorkspace.svelte index bc9e8cd01d..488b31a9b5 100644 --- a/web/src/lib/components/deletions/DeletionsWorkspace.svelte +++ b/web/src/lib/components/deletions/DeletionsWorkspace.svelte @@ -2,17 +2,17 @@ import type { ChipTone } from '@kenn-io/kit-ui'; import { sentenceCase } from '../../explore/labels'; - const MANIFEST_STATUS: Readonly> = { - pending: { label: 'Pending', tone: 'info' }, - in_progress: { label: 'In progress', tone: 'info' }, - completed: { label: 'Completed', tone: 'success' }, + const BATCH_STATUS: Readonly> = { + pending: { label: 'Staged', tone: 'info' }, + in_progress: { label: 'Deleting', tone: 'info' }, + completed: { label: 'Deleted', tone: 'success' }, failed: { label: 'Failed', tone: 'danger' }, cancelled: { label: 'Cancelled', tone: 'muted' }, }; - function manifestStatusChip(status: string): { label: string; tone: ChipTone } { - return Object.hasOwn(MANIFEST_STATUS, status) - ? MANIFEST_STATUS[status]! + function batchStatus(status: string): { label: string; tone: ChipTone } { + return Object.hasOwn(BATCH_STATUS, status) + ? BATCH_STATUS[status]! : { label: sentenceCase(status), tone: 'neutral' }; } @@ -22,585 +22,356 @@ cancelDeletion as generatedCancelDeletion, getDeletion as generatedGetDeletion, listDeletions as generatedListDeletions, - stageDeletion as generatedStageDeletion, } from '../../api/generated/api/api'; - import { preflightExploreSelection as generatedPreflightExploreSelection } from '../../api/generated/exploration/exploration'; - import { - Button, - Card, - Chip, - EmptyState, - Modal, - Table, - TableHeaderCell, - appShortcuts, - } from '@kenn-io/kit-ui'; - import { onDestroy, onMount, tick } from 'svelte'; + import { Button, Checkbox, Chip, CopyButton, Table, TableHeaderCell } from '@kenn-io/kit-ui'; + import { onDestroy, onMount } from 'svelte'; import type { APIClient } from '../../api/client'; - import { preflightReasonLabel } from '../../explore/labels'; - import { formatBytes, formatDateTime, formatRelativeTime } from '../../util/format'; - import PageHeader from '../shell/PageHeader.svelte'; import type { DeletionManifestDetail as GeneratedDeletionManifestDetail, DeletionManifestSummary as GeneratedDeletionManifestSummary, - ExplorePreflightResponse as GeneratedExplorePreflightResponse, - ExploreSelection as GeneratedExploreSelection, - StageDeletionResponse as GeneratedStageDeletionResponse, } from '../../api/generated/models'; - type ExploreSelection = GeneratedExploreSelection; - type Preflight = GeneratedExplorePreflightResponse; - type ManifestSummary = GeneratedDeletionManifestSummary; - type ManifestDetail = GeneratedDeletionManifestDetail; - type StageDeletionResponse = GeneratedStageDeletionResponse; - let { - client, - selection = undefined, - reviewOnMount = false, - onReviewStarted = () => undefined, - now = () => new Date(), - }: { - client: APIClient; - selection?: ExploreSelection; - reviewOnMount?: boolean; - onReviewStarted?: () => void; - now?: () => Date; - } = $props(); - let manifests = $state([]); - let detail = $state(); - let manifestsElement = $state(); - let reviewed = $state(); - let reviewedFingerprint = ''; + import { formatBytes, formatDateTime } from '../../util/format'; + import { EmptyState } from '@kenn-io/kit-ui'; + import SidePanel from '../common/SidePanel.svelte'; + import Dialog from '../common/Dialog.svelte'; + import PageHeader from '../shell/PageHeader.svelte'; + import CommandLine from '../sources/CommandLine.svelte'; + + type Batch = GeneratedDeletionManifestSummary; + type Detail = GeneratedDeletionManifestDetail; + const DELETE_COMMAND = 'msgvault delete-staged'; + // Account lookups share a small worker pool across the batch history. + const ACCOUNT_LOOKUPS = 4; + + let { client, onChanged = () => undefined }: { client: APIClient; onChanged?: () => void } = $props(); + + let batches = $state([]); + let accounts = $state>({}); + let selectedID = $state(); + let detail = $state(); + let showCancelled = $state(false); let loading = $state(true); - let pending = $state(false); let error = $state(''); - let preview = $state(); - let confirmStage = $state<'explicit' | 'all_matching'>(); - let confirmCancel = $state(); - let listController: AbortController | undefined; - onMount(() => { - void loadManifests(); - if (reviewOnMount && selection) { - onReviewStarted(); - void reviewSelection(true); - } - const releaseScope = appShortcuts.pushScope('deletions'); - const unregister = [ - appShortcuts.register( - 'd', - () => { - if (selection?.mode === 'explicit') void reviewSelection(true); - }, - { scope: 'deletions', description: 'Stage selected messages for deletion' }, - ), - appShortcuts.register( - 'shift+d', - () => { - if (selection?.mode === 'all_matching') void reviewSelection(true); - }, - { scope: 'deletions', description: 'Stage all matching messages for deletion' }, - ), - ]; - return () => { - for (const remove of unregister.reverse()) remove(); - releaseScope(); - }; - }); - onDestroy(() => listController?.abort()); - function fingerprint(value: ExploreSelection | undefined): string { - return value ? JSON.stringify(value) : ''; - } - function reviewedIsCurrent(): boolean { - if (!reviewed || reviewedFingerprint !== fingerprint(selection)) { - reviewed = undefined; - preview = undefined; - error = 'The selection changed. Review it again before continuing.'; - return false; - } - return true; - } - async function loadManifests(): Promise { - listController?.abort(); - const controller = new AbortController(); - listController = controller; + let confirmCancel = $state(); + let cancelling = $state(false); + let controller: AbortController | undefined; + + const cancelledCount = $derived(batches.filter((batch) => batch.status === 'cancelled').length); + const rows = $derived( + batches.filter( + (batch) => showCancelled || batch.status !== 'cancelled', + ), + ); + + onMount(() => void load()); + onDestroy(() => controller?.abort()); + + async function load(): Promise { + controller?.abort(); + loading = true; + const current = (controller = new AbortController()); try { const { data, error: responseError } = await generatedListDeletions(undefined, { ...client, - signal: controller.signal, + signal: current.signal, }); - if (!data) throw new Error(messageFor(responseError, 'Unable to load deletion manifests.')); - manifests = data.manifests ?? []; + if (!data) throw new Error(messageFor(responseError, "Can't load staged deletions.")); + if (current.signal.aborted) return; + batches = data.manifests ?? []; error = ''; + void loadAccounts(batches, current.signal); } catch (cause) { - if (!controller.signal.aborted) - error = cause instanceof Error ? cause.message : 'Unable to load deletion manifests.'; - } finally { - if (listController === controller) { - listController = undefined; - loading = false; - } - } - } - async function reviewSelection(openConfirmation = false): Promise { - if (!selection || pending) return; - pending = true; - error = ''; - preview = undefined; - const candidate = selection; - const candidateFingerprint = fingerprint(candidate); - try { - const { data, error: responseError } = await generatedPreflightExploreSelection({ selection: candidate }, client); - if (!data) throw new Error(messageFor(responseError, 'Unable to review this selection.')); - if (typeof data.deletable_count !== 'number') { - throw new Error('Deletion review requires daemon API schema 2.18.0 or newer. Upgrade the daemon and review again.'); - } - if (candidateFingerprint !== fingerprint(selection)) { - throw new Error('The selection changed while it was being reviewed. Review it again.'); - } - reviewed = data; - reviewedFingerprint = candidateFingerprint; - if (openConfirmation && !unavailableReason('stage_deletion')) confirmStage = candidate.mode; - } catch (cause) { - error = cause instanceof Error ? cause.message : 'Unable to review this selection.'; + if (!current.signal.aborted) error = cause instanceof Error ? cause.message : "Can't load staged deletions."; } finally { - pending = false; + if (controller === current) loading = false; } } - function unavailableReason(action: string): string | undefined { - return reviewed?.unavailable_actions.find((item) => item.action === action)?.reason; - } - async function dryRun(): Promise { - if (!selection || !reviewedIsCurrent()) return; - pending = true; - error = ''; - const dryRunSelection = selection; - const dryRunFingerprint = fingerprint(dryRunSelection); - try { - const { data, error: responseError } = await generatedStageDeletion( - { - selection: dryRunSelection, - operation_token: reviewed!.operation_token, - dry_run: true, - }, - client, - ); - if (!data) throw new Error(messageFor(responseError, 'Unable to run the deletion preview.')); - if (dryRunFingerprint !== fingerprint(selection)) { - throw new Error('The selection changed while it was being reviewed. Review it again.'); + + async function loadAccounts(list: Batch[], signal: AbortSignal): Promise { + const found: Record = {}; + let index = 0; + await Promise.all(Array.from({ length: Math.min(ACCOUNT_LOOKUPS, list.length) }, async () => { + while (index < list.length && !signal.aborted) { + const batch = list[index++]; + try { + const { data } = await generatedGetDeletion({ id: batch.id }, { ...client, signal }); + if (data?.account) found[batch.id] = data.account; + } catch { /* A row still opens when its account lookup fails. */ } } - preview = data; - } catch (cause) { - preview = undefined; - error = cause instanceof Error ? cause.message : 'Unable to run the deletion preview.'; - } finally { - pending = false; - } + })); + if (!signal.aborted) accounts = found; } - async function stage(): Promise { - if (!selection || !reviewedIsCurrent()) return; - pending = true; - error = ''; - const stagedSelection = selection; - try { - const { - data, - error: responseError, - response, - } = await generatedStageDeletion( - { - selection: stagedSelection, - operation_token: reviewed!.operation_token, - description: - stagedSelection.mode === 'all_matching' ? 'reviewed matching selection' : 'reviewed explicit selection', - dry_run: false, - }, - client, - ); - if (!data || response.status !== 201) - throw new Error(messageFor(responseError, 'Unable to stage this deletion.')); - reviewed = undefined; - preview = data; - confirmStage = undefined; - await loadManifests(); - } catch (cause) { - preview = undefined; - error = cause instanceof Error ? cause.message : 'Unable to stage this deletion.'; - } finally { - pending = false; + + async function open(batch: Batch): Promise { + if (selectedID === batch.id) { + selectedID = detail = undefined; + return; } - } - async function closeDetail(): Promise { - const id = detail?.id; + selectedID = batch.id; detail = undefined; - await tick(); - const rows = manifestsElement?.querySelectorAll('tr[data-manifest-id]') ?? []; - const row = Array.from(rows).find((candidate) => candidate.dataset['manifestId'] === id); - row?.querySelector('.inspect-action')?.focus(); - } - async function inspect(manifest: ManifestSummary): Promise { - pending = true; - error = ''; try { - const { data, error: responseError } = await generatedGetDeletion({ id: manifest.id }, client); - if (!data) throw new Error(messageFor(responseError, 'Unable to inspect this deletion manifest.')); - detail = data; + const { data, error: responseError } = await generatedGetDeletion({ id: batch.id }, { ...client, signal: controller?.signal }); + if (!data) throw new Error(messageFor(responseError, "Can't open this batch.")); + if (selectedID === batch.id) detail = data; } catch (cause) { - error = cause instanceof Error ? cause.message : 'Unable to inspect this deletion manifest.'; - } finally { - pending = false; + if (selectedID !== batch.id || controller?.signal.aborted) return; + error = cause instanceof Error ? cause.message : "Can't open this batch."; + selectedID = undefined; } } + + function close(): void { + selectedID = detail = undefined; + } + async function cancelManifest(): Promise { - if (!confirmCancel) return; - const target = confirmCancel; - pending = true; - error = ''; + if (!confirmCancel || cancelling) return; + const batch = confirmCancel; + cancelling = true; try { - const { data, error: responseError } = await generatedCancelDeletion({ id: target.id }, client); - if (!data) throw new Error(messageFor(responseError, 'Unable to cancel this deletion manifest.')); - manifests = manifests.map((manifest) => - manifest.id === data.id ? { ...manifest, status: data.status } : manifest, - ); + const { data, error: responseError } = await generatedCancelDeletion({ id: batch.id }, client); + if (!data) throw new Error(messageFor(responseError, "Can't cancel the batch.")); + batches = batches.map((item) => item.id === data.id ? { ...item, status: data.status } : item); if (detail?.id === data.id) detail = { ...detail, status: data.status }; confirmCancel = undefined; + onChanged(); } catch (cause) { - error = cause instanceof Error ? cause.message : 'Unable to cancel this deletion manifest.'; - } finally { - pending = false; - } - } - function stageCounts(value: StageDeletionResponse): { matched: number; staged: number; skipped: number } { - const staged = value.message_count; - const skipped = value.skipped_count ?? Math.max((value.matched_count ?? staged) - staged, 0); - const matched = value.matched_count ?? staged + skipped; - return { matched, staged, skipped }; - } - function resultSummary(value: StageDeletionResponse): string { - const { matched, staged, skipped } = stageCounts(value); - const prefix = value.dry_run ? 'Dry run' : 'Staged'; - const account = value.account ? ` in ${value.account}` : ''; - const batch = !value.dry_run && value.id ? ` · Batch ID: ${value.id}` : ''; - return `${prefix}: Matched: ${matched.toLocaleString()} · Staged: ${staged.toLocaleString()} · Skipped: ${skipped.toLocaleString()}${account}${batch}`; - } - function partialWarning(value: StageDeletionResponse): string { - const { staged, skipped } = stageCounts(value); - if (value.dry_run) { - return `Partial staging: only the deletable Gmail and Microsoft Graph mail subset (${staged.toLocaleString()}) will be staged; ${skipped.toLocaleString()} unsupported ${skipped === 1 ? 'match will be' : 'matches will be'} skipped.`; - } - return `Partial staging: only the deletable Gmail and Microsoft Graph mail subset (${staged.toLocaleString()}) was staged; ${skipped.toLocaleString()} unsupported ${skipped === 1 ? 'match was' : 'matches were'} skipped.`; - } - function selectionExclusions(): string { - const count = selection?.exclusions?.length ?? 0; - return selection?.mode === 'all_matching' && count > 0 - ? ` After ${count.toLocaleString()} ${count === 1 ? 'exclusion' : 'exclusions'}.` - : ''; - } - function confirmationDescription(): string { - const counts = preview?.dry_run - ? resultSummary(preview) - : `Matched: ${reviewed!.count.toLocaleString()} · Will stage: ${reviewed!.deletable_count.toLocaleString()} · Will skip: ${(reviewed!.count - reviewed!.deletable_count).toLocaleString()}`; - return `${counts}.${selectionExclusions()} Only deletable Gmail and Microsoft Graph mail messages will be staged. This creates a staged manifest; it does not execute deletion.`; + error = cause instanceof Error ? cause.message : "Can't cancel the batch."; + } finally { cancelling = false; } } + function messageFor(value: unknown, fallback: string): string { return typeof value === 'object' && value !== null && 'message' in value && typeof value.message === 'string' ? value.message : fallback; } + function count(value: number): string { + return `${value.toLocaleString()} ${value === 1 ? 'message' : 'messages'}`; + } + const cancellable = (status: string): boolean => status === 'pending' || status === 'in_progress'; -
- - {#snippet descriptionContent()}Deletions you've staged. Nothing is deleted until you run msgvault delete-staged.{/snippet} +
+ + {#snippet actions()} + {#if cancelledCount > 0} + + {/if} + {/snippet} - {#if error}{/if} + {#if error}{/if} -
- {#if !selection} - - {:else} - -
-
-

Review selection

-
- {#if reviewed} - {@const stageReason = unavailableReason('stage_deletion')} -
- {reviewed.count.toLocaleString()} - {reviewed.count === 1 ? 'item' : 'items'} · {formatBytes(reviewed.estimated_bytes)} - - {reviewed.deletable_count.toLocaleString()} can be staged · {(reviewed.count - reviewed.deletable_count).toLocaleString()} will be skipped.{selectionExclusions()} - - Review expires - {#if reviewed.search_deletion_scope === 'active'} - Semantic search covers active messages only. - {/if} - {#if stageReason} - {preflightReasonLabel('stage_deletion', stageReason)} - {/if} -
-
-
- {/if} - {#if preview} -

{resultSummary(preview)}

- {#if stageCounts(preview).skipped > 0}{/if} - {/if} -
-
- {/if} -
- - {#if loading}

Loading deletion manifests…

- {:else if manifests.length === 0} - + {#if loading} +

Loading…

+ {:else if rows.length === 0} + {:else} -
- - {#snippet header()} - - - - - - - {/snippet} - {#each manifests as manifest (manifest.id)} - {@const chip = manifestStatusChip(manifest.status)} - - - - - - - - - {/each} -
{manifest.id}{manifest.description}{manifest.message_count.toLocaleString()} {manifest.message_count === 1 ? 'item' : 'items'}{chip.label} -
- {#if detail} - {@const detailChip = manifestStatusChip(detail.status)} - - - - {/if} -
+ + {#snippet header()} + + + + + {/snippet} + {#each rows as batch (batch.id)} + {@const chip = batchStatus(batch.status)} + void open(batch)}> + + + + + + {/each} +
+ + {count(batch.message_count)}{accounts[batch.id] ?? ''}{chip.label}
{/if} + +
+ +
-{#if confirmStage && reviewed} - { - confirmStage = undefined; - }} + surface="soft" + label="Cancel batch" + onclick={() => confirmCancel = batches.find((batch) => batch.id === selectedID)} + /> +{/snippet} + +{#if selectedID && detail} + {@const chip = batchStatus(detail.status)} + -

{confirmationDescription()}

- {#snippet footer()} - + +{/snippet} + {/if} + {#snippet controls()}
{#if personScoped}
{/if} - - - + {#if visual?.ready}{/if} {#if hostedVisualSearch}
+{#if selectedSource} + {#key selectedSource.id} + void syncNow(selectedSource)} + onclose={() => (selected = undefined)} + onSaved={refresh} + /> + {/key} +{:else if selectedContact} + void syncContacts(selectedContact)} + onclose={() => (selected = undefined)} + onOpenSettings={() => onOpenSettings('carddav')} + /> +{/if} + +{#if adding} + (adding = false)}> + + +{/if} + {#if pendingDeletionReview} - pendingDeletionReview = undefined} onStaged={() => { selection.clear(); void shellData.refresh(); }} /> + pendingDeletionReview = undefined} onStaged={() => { selection.clear(); void shellData.refreshDeletions(); }} /> {/if} diff --git a/web/src/lib/components/shell/AppShell.test.ts b/web/src/lib/components/shell/AppShell.test.ts index ed251fdf1b..a1266c9afd 100644 --- a/web/src/lib/components/shell/AppShell.test.ts +++ b/web/src/lib/components/shell/AppShell.test.ts @@ -2829,14 +2829,14 @@ describe('AppShell', () => { const rendered = render(AppShell, { client: createAPIClient(fetchFn), state }); const grid = await screen.findByRole('grid', { name: 'Messages results' }); await screen.findByText('Synthetic subject 1'); - expect(screen.queryByRole('button', { name: 'Delete from Gmail…' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Review deletion…' })).toBeNull(); grid.focus(); await fireEvent.keyDown(grid, { key: ' ' }); await waitFor(() => expect(preflights).toBe(1)); - await fireEvent.click(await screen.findByRole('button', { name: 'Delete from Gmail…' })); + await fireEvent.click(await screen.findByRole('button', { name: 'Review deletion…' })); - expect(await screen.findByRole('dialog', { name: 'Delete from Gmail' })).toBeDefined(); + expect(await screen.findByRole('dialog', { name: 'Review deletion' })).toBeDefined(); expect(state.current.workspace).toBe('everything'); await waitFor(() => expect(preflights).toBe(2)); rendered.unmount(); diff --git a/web/src/lib/components/shell/shell-data.svelte.test.ts b/web/src/lib/components/shell/shell-data.svelte.test.ts index 956e933b79..3d0f23dd35 100644 --- a/web/src/lib/components/shell/shell-data.svelte.test.ts +++ b/web/src/lib/components/shell/shell-data.svelte.test.ts @@ -226,3 +226,62 @@ it.each([ expect(data.deletionsStaged).toBe(visible); } finally { data.stop(); vi.restoreAllMocks(); } }); + +it('uses cached completion time for delayed batches and keeps visibility on failed reads', async () => { + vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-10-29T12:00:00Z')); + const requests: string[] = []; + let failing = false; + const data = new ShellData(createAPIClient(async input => { + const path = new URL((input as Request).url).pathname; + requests.push(path); + if (failing) return Response.json({}, { status: 500 }); + if (path === '/api/v1/deletions') return Response.json({ manifests: [{ id: 'delayed', status: 'completed', created_at: '2026-10-01T12:00:00Z' }, { id: 'old', status: 'completed', created_at: '2026-09-01T12:00:00Z' }] }); + return Response.json({ execution: { completed_at: path.endsWith('/delayed') ? '2026-10-28T12:00:00Z' : '2026-09-02T12:00:00Z' } }); + })); + try { + await data.refreshDeletions(); + expect(data.deletionsStaged).toBe(true); + await data.refreshDeletions(); + expect(requests.filter(path => path.endsWith('/delayed'))).toHaveLength(1); + failing = true; + await data.refreshDeletions(); + expect(data.deletionsStaged).toBe(true); + failing = false; + vi.mocked(Date.now).mockReturnValue(Date.parse('2026-11-05T12:00:00Z')); + await data.refreshDeletions(); + expect(data.deletionsStaged).toBe(false); + } finally { data.stop(); vi.restoreAllMocks(); } +}); + +it('refreshes deletions and jobs on the timer without rereading saved views', async () => { + vi.useFakeTimers(); + const { data, requests } = setup(); + try { + await data.refresh(); + data.start(); + await vi.advanceTimersByTimeAsync(30_000); + expect(requests.filter(request => new URL(request.url).pathname === '/api/v1/saved-views')).toHaveLength(1); + expect(requests.filter(request => new URL(request.url).pathname === '/api/v1/deletions')).toHaveLength(2); + } finally { data.stop(); vi.useRealTimers(); } +}); + +it('bounds older completion reads to four while a recent batch keeps navigation visible', async () => { + const pending: Array<(response: Response) => void> = []; + const data = new ShellData(createAPIClient(async input => { + if ((input as Request).url.endsWith('/saved-views')) return Response.json({ saved_views: [view(1, 'Budget')] }); + if ((input as Request).url.endsWith('/deletions')) return Response.json({ manifests: [ + { id: 'recent', status: 'pending', created_at: '2020-01-01T00:00:00Z' }, + ...Array.from({ length: 5 }, (_, i) => ({ id: `old-${i}`, status: 'completed', created_at: '2020-01-01T00:00:00Z' })) + ] }); + return new Promise(resolve => pending.push(resolve)); + })); + const refresh = data.refresh(); + await vi.waitFor(() => expect(pending).toHaveLength(4)); + expect(data.deletionsStaged).toBe(true); + await vi.waitFor(() => expect(data.views.map(view => view.name)).toEqual(['Budget'])); + pending[0](Response.json({})); + await vi.waitFor(() => expect(pending).toHaveLength(5)); + pending.slice(1).forEach(resolve => resolve(Response.json({}))); + await refresh; + data.stop(); +}); diff --git a/web/src/lib/components/shell/shell-data.svelte.ts b/web/src/lib/components/shell/shell-data.svelte.ts index 39a6cdfa70..8ae30e12b3 100644 --- a/web/src/lib/components/shell/shell-data.svelte.ts +++ b/web/src/lib/components/shell/shell-data.svelte.ts @@ -2,6 +2,7 @@ import { deleteSavedView, listSavedViews, listDeletions, + getDeletion, patchSavedView, } from '../../api/generated/api/api'; import type { APIClient } from '../../api/client'; @@ -12,8 +13,8 @@ import type { ExploreURLState } from '../../explore/models'; import { supportsSavedViewSchema, exploreStateFromSavedView, type CanonicalState } from '../../saved-views/canonical'; const REFRESH_MS = 30_000; -export function batchIsRecent(batch: Pick, now: number): boolean { - return batch.status === 'pending' || batch.status === 'in_progress' || now - Date.parse(batch.created_at) <= 7 * 24 * 60 * 60 * 1000; +export function batchIsRecent(batch: Pick, now: number, completedAt?: string | null): boolean { + return batch.status === 'pending' || batch.status === 'in_progress' || now - Date.parse(completedAt || batch.created_at) <= 7 * 24 * 60 * 60 * 1000; } export function viewIncompatibility(view: SavedView): string { if (!supportsSavedViewSchema(view.schema_version)) { @@ -41,6 +42,8 @@ export class ShellData { #client: APIClient; #timer: ReturnType | undefined; #controller: AbortController | undefined; + #deletionsController?: AbortController; + #completed = new Map(); constructor(client: APIClient, operations = new OperationsController(client)) { this.#client = client; @@ -50,7 +53,7 @@ export class ShellData { /** Jobs poll while visible; saved views refresh on entry, focus, and writes. */ start(): void { this.#timer = setInterval(() => { - if (typeof document === 'undefined' || document.visibilityState === 'visible') { void this.refresh(); void this.jobs.refresh(); } + if (typeof document === 'undefined' || document.visibilityState === 'visible') { void this.refreshDeletions(); void this.jobs.refresh(); } }, REFRESH_MS); } @@ -58,6 +61,7 @@ export class ShellData { if (this.#timer !== undefined) clearInterval(this.#timer); this.#timer = undefined; this.#controller?.abort(); + this.#deletionsController?.abort(); this.jobs.dispose(); } @@ -65,11 +69,40 @@ export class ShellData { this.#controller?.abort(); const controller = (this.#controller = new AbortController()); const client = { ...this.#client, signal: controller.signal }; - const [views, staged] = await Promise.all([this.#loadViews(client), listDeletions(undefined, client).catch(() => undefined)]); + const deletions = this.refreshDeletions(); + const views = await this.#loadViews(client); if (controller.signal.aborted) return; - if (staged?.data) this.deletionsStaged = (staged.data.manifests ?? []).some(batch => batchIsRecent(batch, Date.now())); this.error = views ? undefined : "Couldn't load saved views."; if (views) this.#ordered = views; + await deletions; + } + + async refreshDeletions(): Promise { + this.#deletionsController?.abort(); + const controller = this.#deletionsController = new AbortController(); + const client = { ...this.#client, signal: controller.signal }; + try { + const { data } = await listDeletions(undefined, client); + if (!data || controller.signal.aborted) return; + const batches = data.manifests ?? []; + const now = Date.now(); + if (batches.some(batch => batchIsRecent(batch, now, this.#completed.get(batch.id)))) this.deletionsStaged = true; + const older = batches.filter(batch => !batchIsRecent(batch, now) && !this.#completed.has(batch.id)); + let index = 0, failed = false; + await Promise.all(Array.from({ length: Math.min(4, older.length) }, async () => { + while (index < older.length && !controller.signal.aborted) { + const batch = older[index++]; + try { + const { data } = await getDeletion({ id: batch.id }, client); + if (!data) failed = true; + else if (!controller.signal.aborted) this.#completed.set(batch.id, data.execution?.completed_at ?? null); + } catch { failed = true; } + } + })); + if (controller.signal.aborted) return; + const visible = batches.some(batch => batchIsRecent(batch, now, this.#completed.get(batch.id))); + if (visible || !failed) this.deletionsStaged = visible; + } catch { /* Keep the last known sidebar state on failed refresh. */ } } async #loadViews(client: APIClient): Promise { diff --git a/web/src/lib/components/sources/SourcePanel.svelte b/web/src/lib/components/sources/SourcePanel.svelte index e4774d09ca..96eb0ad0e9 100644 --- a/web/src/lib/components/sources/SourcePanel.svelte +++ b/web/src/lib/components/sources/SourcePanel.svelte @@ -19,7 +19,7 @@ - -{entry.label} diff --git a/web/src/lib/components/common/run-state.ts b/web/src/lib/components/common/run-state.ts deleted file mode 100644 index d3eb8fbd97..0000000000 --- a/web/src/lib/components/common/run-state.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ChipTone } from '@kenn-io/kit-ui'; - -export type RunState = 'running' | 'succeeded' | 'partial' | 'failed' | 'queued' | 'cancelled'; - -/** The one run-state vocabulary: label and tone for every job, sync, and run. */ -export const RUN_STATES: Record = { - queued: { label: 'Queued', tone: 'info' }, - cancelled: { label: 'Cancelled', tone: 'muted' }, - running: { label: 'Running', tone: 'info' }, - succeeded: { label: 'Succeeded', tone: 'success' }, - partial: { label: 'Partial', tone: 'warning' }, - failed: { label: 'Failed', tone: 'danger' }, -}; diff --git a/web/src/lib/components/deletions/DeletionsWorkspace.svelte b/web/src/lib/components/deletions/DeletionsWorkspace.svelte index 439bbde5af..ceb07fe4f7 100644 --- a/web/src/lib/components/deletions/DeletionsWorkspace.svelte +++ b/web/src/lib/components/deletions/DeletionsWorkspace.svelte @@ -18,16 +18,10 @@ - -{#snippet footer()} -