feat(ext/popup): trash view — list trashed items with restore/purge

Shows trashed items sorted newest-first with restore buttons.
Empty trash button purges all items + orphan blobs. Header shows
count and days until oldest auto-purges.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-26 19:28:56 -04:00
parent 39a8e12438
commit 9fbf9bb3ee
3 changed files with 246 additions and 0 deletions

View File

@@ -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 = '<div id="app"></div>';
app = document.getElementById('app')!;
vi.clearAllMocks();
});
it('renders empty state when no trashed items', async () => {
(sendMessage as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
data: { items: [] },
});
await renderTrash(app);
app.querySelector<HTMLButtonElement>('#back-btn')?.click();
expect(navigate).toHaveBeenCalledWith('list');
});
});

View File

@@ -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<string, string> = {
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<void> {
const state = getState();
// Fetch trashed items
const resp = await sendMessage({ type: 'list_trashed' });
if (!resp.ok) {
app.innerHTML = `<div class="pad"><p class="error">Failed to load trash</p></div>`;
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 = `
<div class="pad">
<div class="trash-header">
<button class="btn" id="back-btn">← back</button>
<h3 style="margin:0;">trash</h3>
</div>
${headerInfo ? `<p class="muted" style="margin:8px 0;">${escapeHtml(headerInfo)}</p>` : ''}
${items.length === 0
? `<p class="muted" style="text-align:center;margin-top:32px;">Trash is empty</p>`
: items.map(([id, entry]) => `
<div class="trash-row" data-id="${escapeHtml(id)}">
<span class="trash-row__icon">${TYPE_ICONS[entry.type] ?? '📦'}</span>
<div class="trash-row__info">
<span class="trash-row__title">${escapeHtml(entry.title)}</span>
<span class="trash-row__meta">trashed ${relativeTime(entry.trashed_at ?? 0)}</span>
</div>
<button class="trash-row__restore" data-restore="${escapeHtml(id)}">restore</button>
</div>
`).join('')}
${items.length > 0 ? `
<div style="margin-top:16px;text-align:center;">
<button class="btn danger" id="empty-trash-btn">empty trash</button>
</div>
` : ''}
</div>
`;
// Wire handlers
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.querySelectorAll<HTMLButtonElement>('[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 });
}
});
}

View File

@@ -998,3 +998,65 @@ textarea {
padding: 0 6px; padding: 0 6px;
cursor: pointer; 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;
}