feat(ext/popup): attachments-disclosure shared component

Compact disclosure rendering attachment rows with an action column
(× in edit, ↓ in view). Image-mime rows lazily decrypt + show a 16×16
thumb via object URLs; teardown revokes them on disclosure close. Edit
mode adds a "+ attach file" button wired to a hidden file input that
checks vault caps client-side before sending upload_attachment to SW.
6 new tests; total ~143.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-25 16:16:57 -04:00
parent b9c495cdea
commit c5f0449843
3 changed files with 363 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../popup', async () => {
const sendMessage = vi.fn();
const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
return { sendMessage, escapeHtml };
});
import { renderAttachmentsDisclosure, wireAttachmentsDisclosure } from '../attachments-disclosure';
import { sendMessage } from '../../popup';
import type { AttachmentRef } from '../../../shared/types';
const REF1: AttachmentRef = { id: 'a1', filename: 'doc.pdf', mime_type: 'application/pdf', size: 12345, created: 1700000000 };
const REF2: AttachmentRef = { id: 'a2', filename: 'photo.png', mime_type: 'image/png', size: 240000, created: 1700000001 };
describe('attachments-disclosure render', () => {
it('renders empty state with no rows in edit mode', () => {
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
expect(html).toContain('attachments');
expect(html).toContain('+ attach file');
expect(html).not.toContain('attachment-row');
});
it('renders rows + remove buttons in edit mode', () => {
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange: vi.fn() });
expect(html).toContain('doc.pdf');
expect(html).toContain('photo.png');
expect(html).toContain('×');
expect(html).toContain('attachment-row__thumb'); // image-mime row gets thumb hook
});
it('renders rows + download buttons in view mode (no add btn)', () => {
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' });
expect(html).toContain('↓');
expect(html).not.toContain('+ attach file');
});
});
describe('attachments-disclosure wiring', () => {
beforeEach(() => {
vi.mocked(sendMessage).mockReset();
});
it('clicking + attach triggers file input click', () => {
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
const fileInput = document.querySelector('.attachments-disclosure__file-input') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click');
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
(document.querySelector('.attachment-add-btn') as HTMLButtonElement).click();
expect(clickSpy).toHaveBeenCalled();
});
it('clicking × calls onChange with the attachment removed', () => {
const onChange = vi.fn();
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange });
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange });
(document.querySelectorAll('.attachment-row__remove')[0] as HTMLElement).click();
expect(onChange).toHaveBeenCalledWith([REF2]);
});
it('clicking ↓ in view mode sends download_attachment', async () => {
vi.mocked(sendMessage).mockResolvedValueOnce({ ok: true, data: { bytes: new ArrayBuffer(10), filename: 'doc.pdf', mimeType: 'application/pdf' } });
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' });
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1], mode: 'view' });
(document.querySelector('.attachment-row__download') as HTMLElement).click();
await new Promise((r) => setTimeout(r, 50));
expect(vi.mocked(sendMessage)).toHaveBeenCalledWith(expect.objectContaining({
type: 'download_attachment',
itemId: 'i1',
attachmentId: 'a1',
}));
});
});

View File

@@ -0,0 +1,198 @@
/// Compact disclosure pattern for attachments — shared between all
/// item type forms (edit + view modes). Edit mode supports + attach
/// (uploads via SW) and × remove (defers blob delete until form save).
/// View mode supports ↓ download (decrypts via SW + browser download).
/// Image-mime rows lazy-load 16×16 thumbnails via object URLs;
/// teardownAttachmentsDisclosure() revokes them on view exit.
import { sendMessage, escapeHtml } from '../popup';
import type { AttachmentRef, VaultSettings } from '../../shared/types';
export type DisclosureMode = 'edit' | 'view';
export interface AttachmentsDisclosureOpts {
itemId: string;
attachments: AttachmentRef[];
mode: DisclosureMode;
onChange?: (next: AttachmentRef[]) => void; // edit mode only
}
const formatBytes = (n: number): string => {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${Math.round(n / 1024)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
};
const isImage = (mime: string): boolean => mime.startsWith('image/');
const objectUrlRegistry = new Map<string, string>(); // attachmentId → object URL
function teardownObjectUrls(): void {
for (const url of objectUrlRegistry.values()) {
URL.revokeObjectURL(url);
}
objectUrlRegistry.clear();
}
async function fetchThumbUrl(itemId: string, attachmentId: string, mime: string): Promise<string | null> {
if (objectUrlRegistry.has(attachmentId)) return objectUrlRegistry.get(attachmentId)!;
const resp = await sendMessage({ type: 'download_attachment', itemId, attachmentId });
if (!resp.ok) return null;
const data = resp.data as { bytes: ArrayBuffer };
const blob = new Blob([data.bytes], { type: mime });
const url = URL.createObjectURL(blob);
objectUrlRegistry.set(attachmentId, url);
return url;
}
export function renderAttachmentsDisclosure(opts: AttachmentsDisclosureOpts): string {
const count = opts.attachments.length;
const headerLabel = count === 0 ? 'attachments' : `attachments (${count})`;
const expanded = count > 0;
const rowsHtml = opts.attachments.map((a) => {
const action = opts.mode === 'edit' ? '×' : '↓';
const actionClass = opts.mode === 'edit' ? 'attachment-row__remove' : 'attachment-row__download';
const iconHtml = isImage(a.mime_type)
? `<span class="attachment-row__thumb" data-att-id="${escapeHtml(a.id)}" data-mime="${escapeHtml(a.mime_type)}">📄</span>`
: `<span class="attachment-row__icon">📄</span>`;
return `
<div class="attachment-row" data-att-id="${escapeHtml(a.id)}">
${iconHtml}
<span class="attachment-row__name">${escapeHtml(a.filename)}</span>
<span class="attachment-row__meta">${formatBytes(a.size)}</span>
<span class="${actionClass}" data-att-id="${escapeHtml(a.id)}">${action}</span>
</div>
`;
}).join('');
const addBtn = opts.mode === 'edit'
? `<button class="attachment-add-btn" type="button">+ attach file</button>`
: '';
const fileInput = opts.mode === 'edit'
? `<input type="file" class="attachments-disclosure__file-input" hidden />`
: '';
return `
<details class="attachments-disclosure" ${expanded ? 'open' : ''}>
<summary>${expanded ? '▾' : '▸'} ${headerLabel}</summary>
<div class="attachments-disclosure__body">
${rowsHtml}
${addBtn}
</div>
${fileInput}
</details>
`;
}
export function wireAttachmentsDisclosure(
root: HTMLElement,
opts: AttachmentsDisclosureOpts,
): void {
const disc = root.querySelector('.attachments-disclosure') as HTMLDetailsElement | null;
if (!disc) return;
// Lazy-load image thumbs whenever disclosure opens.
const loadThumbs = async (): Promise<void> => {
const thumbs = disc.querySelectorAll<HTMLElement>('.attachment-row__thumb');
for (const thumb of thumbs) {
const attId = thumb.dataset.attId;
const mime = thumb.dataset.mime;
if (!attId || !mime) continue;
const url = await fetchThumbUrl(opts.itemId, attId, mime);
if (url) {
thumb.innerHTML = `<img src="${url}" alt="" />`;
}
}
};
if (disc.open) loadThumbs().catch(() => { /* swallow: thumb failures are non-fatal */ });
disc.addEventListener('toggle', () => {
if (disc.open) loadThumbs().catch(() => { /* swallow: thumb failures are non-fatal */ });
else teardownObjectUrls();
});
// Edit mode: + attach file
if (opts.mode === 'edit') {
const fileInput = disc.querySelector('.attachments-disclosure__file-input') as HTMLInputElement | null;
const addBtn = disc.querySelector('.attachment-add-btn') as HTMLButtonElement | null;
addBtn?.addEventListener('click', () => fileInput?.click());
fileInput?.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
// Cap enforcement (popup-side, before sending to SW).
const settingsResp = await sendMessage({ type: 'get_vault_settings' });
if (settingsResp.ok) {
const settings = (settingsResp.data as { settings: VaultSettings }).settings;
const caps = settings.attachment_caps;
if (caps?.per_attachment_max_bytes && file.size > caps.per_attachment_max_bytes) {
alert(`file too large (${formatBytes(file.size)} / cap ${formatBytes(caps.per_attachment_max_bytes)})`);
fileInput.value = '';
return;
}
if (caps?.per_item_max_count && opts.attachments.length + 1 > caps.per_item_max_count) {
alert(`item attachment count would exceed cap (${opts.attachments.length + 1} / ${caps.per_item_max_count})`);
fileInput.value = '';
return;
}
}
const bytes = await file.arrayBuffer();
const resp = await sendMessage({
type: 'upload_attachment',
itemId: opts.itemId,
filename: file.name,
mimeType: file.type || 'application/octet-stream',
bytes,
});
if (resp.ok) {
const data = resp.data as { attachment: AttachmentRef };
opts.onChange?.([...opts.attachments, data.attachment]);
} else {
alert(`upload failed: ${resp.error}`);
}
fileInput.value = ''; // allow re-pick of same file later
});
// Remove (×) buttons — defer the actual blob delete until form save
disc.querySelectorAll<HTMLElement>('.attachment-row__remove').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const attId = btn.dataset.attId;
if (!attId) return;
opts.onChange?.(opts.attachments.filter((a) => a.id !== attId));
});
});
}
// View mode: ↓ download
if (opts.mode === 'view') {
disc.querySelectorAll<HTMLElement>('.attachment-row__download').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
const attId = btn.dataset.attId;
if (!attId) return;
const att = opts.attachments.find((a) => a.id === attId);
if (!att) return;
const resp = await sendMessage({ type: 'download_attachment', itemId: opts.itemId, attachmentId: attId });
if (!resp.ok) {
alert(`download failed: ${resp.error}`);
return;
}
const data = resp.data as { bytes: ArrayBuffer; filename: string; mimeType: string };
const blob = new Blob([data.bytes], { type: data.mimeType });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = data.filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
});
});
}
}
/// Call from the parent component's teardown to release any image thumbs.
export function teardownAttachmentsDisclosure(): void {
teardownObjectUrls();
}

View File

@@ -794,3 +794,95 @@ textarea {
display: flex; justify-content: flex-end; gap: 6px;
margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d;
}
/* --- attachments disclosure (γ₁) --- */
.attachments-disclosure {
margin: 8px 0;
border: 1px solid #30363d;
border-radius: 4px;
padding: 6px 8px;
font-size: 11px;
color: #8b949e;
}
.attachments-disclosure[open] {
border-color: #aa812a;
}
.attachments-disclosure summary {
cursor: pointer;
list-style: none;
outline: none;
user-select: none;
padding: 2px 0;
}
.attachments-disclosure summary::-webkit-details-marker { display: none; }
.attachments-disclosure summary:hover { color: #c9d1d9; }
.attachments-disclosure__body {
margin-top: 6px;
}
.attachment-row {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
font-size: 10px;
border-bottom: 1px solid #21262d;
}
.attachment-row:last-of-type {
border-bottom: 0;
}
.attachment-row__icon,
.attachment-row__thumb {
width: 16px;
height: 16px;
background: #21262d;
border-radius: 2px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
flex-shrink: 0;
overflow: hidden;
}
.attachment-row__thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.attachment-row__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #c9d1d9;
}
.attachment-row__meta {
color: #6e7681;
font-size: 9px;
font-family: ui-monospace, monospace;
flex-shrink: 0;
}
.attachment-row__remove,
.attachment-row__download {
color: #d2ab43;
cursor: pointer;
padding: 0 6px;
flex-shrink: 0;
}
.attachment-row__remove { color: #ab2b20; }
.attachment-add-btn {
background: transparent;
border: 1px dashed #30363d;
color: #8b949e;
padding: 5px 8px;
font-size: 10px;
cursor: pointer;
border-radius: 3px;
width: 100%;
margin-top: 6px;
text-align: center;
}
.attachment-add-btn:hover {
border-color: #aa812a;
color: #c9d1d9;
}