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:
67
extension/src/popup/components/__tests__/trash.test.ts
Normal file
67
extension/src/popup/components/__tests__/trash.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
117
extension/src/popup/components/trash.ts
Normal file
117
extension/src/popup/components/trash.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user