diff --git a/extension/src/popup/components/__tests__/trash.test.ts b/extension/src/popup/components/__tests__/trash.test.ts new file mode 100644 index 0000000..b602a74 --- /dev/null +++ b/extension/src/popup/components/__tests__/trash.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { renderTrash } from '../trash'; + +// Mock popup module +vi.mock('../../popup', () => ({ + getState: vi.fn(() => ({ + vaultSettings: { trash_retention: { kind: 'days', value: 30 } }, + })), + setState: vi.fn(), + sendMessage: vi.fn(), + navigate: vi.fn(), + escapeHtml: (s: string) => s, +})); + +import { sendMessage, navigate } from '../../popup'; + +describe('trash view', () => { + let app: HTMLElement; + + beforeEach(() => { + document.body.innerHTML = '
'; + app = document.getElementById('app')!; + vi.clearAllMocks(); + }); + + it('renders empty state when no trashed items', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { items: [] }, + }); + + await renderTrash(app); + + expect(app.innerHTML).toContain('Trash is empty'); + expect(app.querySelector('#empty-trash-btn')).toBeNull(); + }); + + it('renders trashed items with restore buttons', async () => { + const now = Math.floor(Date.now() / 1000); + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { + items: [ + ['id1', { id: 'id1', type: 'login', title: 'Test Login', trashed_at: now - 3600, tags: [], favorite: false, modified: now, attachment_summaries: [] }], + ], + }, + }); + + await renderTrash(app); + + expect(app.innerHTML).toContain('Test Login'); + expect(app.innerHTML).toContain('restore'); + expect(app.querySelector('#empty-trash-btn')).not.toBeNull(); + }); + + it('back button navigates to list', async () => { + (sendMessage as ReturnType).mockResolvedValueOnce({ + ok: true, + data: { items: [] }, + }); + + await renderTrash(app); + app.querySelector('#back-btn')?.click(); + + expect(navigate).toHaveBeenCalledWith('list'); + }); +}); diff --git a/extension/src/popup/components/trash.ts b/extension/src/popup/components/trash.ts new file mode 100644 index 0000000..c1a60a5 --- /dev/null +++ b/extension/src/popup/components/trash.ts @@ -0,0 +1,117 @@ +/// Trash view โ€” lists soft-deleted items with restore/purge actions. + +import { getState, setState, sendMessage, navigate, escapeHtml } from '../popup'; +import type { ItemId, ManifestEntry, VaultSettings } from '../../shared/types'; + +const TYPE_ICONS: Record = { + login: '๐Ÿ”‘', secure_note: '๐Ÿ“', identity: '๐Ÿ‘ค', card: '๐Ÿ’ณ', + key: '๐Ÿ”', document: '๐Ÿ“„', totp: 'โฑ๏ธ', +}; + +function relativeTime(unixSec: number): string { + const now = Math.floor(Date.now() / 1000); + const diff = now - unixSec; + if (diff < 60) return 'just now'; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + return `${Math.floor(diff / 86400)}d ago`; +} + +function daysUntilPurge(trashedAt: number, retention: VaultSettings['trash_retention']): number | null { + if (retention.kind === 'forever') return null; + const trashedDaysAgo = Math.floor((Date.now() / 1000 - trashedAt) / 86400); + return Math.max(0, retention.value - trashedDaysAgo); +} + +export function teardown(): void { + // No cleanup needed +} + +export async function renderTrash(app: HTMLElement): Promise { + const state = getState(); + + // Fetch trashed items + const resp = await sendMessage({ type: 'list_trashed' }); + if (!resp.ok) { + app.innerHTML = `

Failed to load trash

`; + return; + } + + const items = (resp.data as { items: Array<[ItemId, ManifestEntry]> }).items; + const retention = state.vaultSettings?.trash_retention ?? { kind: 'days', value: 30 }; + + // Calculate days until oldest auto-purges + let oldestPurgeDays: number | null = null; + if (items.length > 0 && retention.kind === 'days') { + const oldest = items[items.length - 1][1]; + oldestPurgeDays = daysUntilPurge(oldest.trashed_at ?? 0, retention); + } + + const headerInfo = items.length === 0 + ? '' + : oldestPurgeDays !== null + ? `${items.length} item${items.length === 1 ? '' : 's'} ยท oldest auto-purges in ${oldestPurgeDays}d` + : `${items.length} item${items.length === 1 ? '' : 's'}`; + + app.innerHTML = ` +
+
+ +

trash

+
+ ${headerInfo ? `

${escapeHtml(headerInfo)}

` : ''} + ${items.length === 0 + ? `

Trash is empty

` + : items.map(([id, entry]) => ` +
+ ${TYPE_ICONS[entry.type] ?? '๐Ÿ“ฆ'} +
+ ${escapeHtml(entry.title)} + trashed ${relativeTime(entry.trashed_at ?? 0)} +
+ +
+ `).join('')} + ${items.length > 0 ? ` +
+ +
+ ` : ''} +
+ `; + + // Wire handlers + document.getElementById('back-btn')?.addEventListener('click', () => navigate('list')); + + document.querySelectorAll('[data-restore]').forEach((btn) => { + btn.addEventListener('click', async () => { + const id = btn.dataset.restore; + if (!id) return; + btn.disabled = true; + btn.textContent = '...'; + const result = await sendMessage({ type: 'restore_item', id }); + if (result.ok) { + await sendMessage({ type: 'sync' }); + renderTrash(app); + } else { + setState({ error: result.error }); + } + }); + }); + + document.getElementById('empty-trash-btn')?.addEventListener('click', async () => { + if (!confirm(`Permanently delete ${items.length} item${items.length === 1 ? '' : 's'}? This cannot be undone.`)) { + return; + } + const btn = document.getElementById('empty-trash-btn') as HTMLButtonElement; + btn.disabled = true; + btn.textContent = 'deleting...'; + const result = await sendMessage({ type: 'purge_all_trash' }); + if (result.ok) { + await sendMessage({ type: 'sync' }); + renderTrash(app); + } else { + setState({ error: result.error }); + } + }); +} diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 189cb9e..6ca31da 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -998,3 +998,65 @@ textarea { padding: 0 6px; cursor: pointer; } + +/* --- Trash view --- */ + +.trash-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 8px; +} + +.trash-row { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 4px; + background: #161b22; + margin-bottom: 6px; +} + +.trash-row__icon { + font-size: 16px; + flex-shrink: 0; +} + +.trash-row__info { + flex: 1; + min-width: 0; +} + +.trash-row__title { + display: block; + font-size: 13px; + color: #c9d1d9; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.trash-row__meta { + font-size: 11px; + color: #8b949e; +} + +.trash-row__restore { + font-size: 11px; + padding: 4px 8px; + background: #238636; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.trash-row__restore:hover { + background: #2ea043; +} + +.trash-row__restore:disabled { + opacity: 0.5; + cursor: default; +}