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 = ``;
+ 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 = `
+
+
+ ${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;
+}