From c5f0449843722cec7d7e0838d149c30f41b11b23 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 25 Apr 2026 16:16:57 -0400 Subject: [PATCH] feat(ext/popup): attachments-disclosure shared component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../__tests__/attachments-disclosure.test.ts | 73 +++++++ .../components/attachments-disclosure.ts | 198 ++++++++++++++++++ extension/src/popup/styles.css | 92 ++++++++ 3 files changed, 363 insertions(+) create mode 100644 extension/src/popup/components/__tests__/attachments-disclosure.test.ts create mode 100644 extension/src/popup/components/attachments-disclosure.ts diff --git a/extension/src/popup/components/__tests__/attachments-disclosure.test.ts b/extension/src/popup/components/__tests__/attachments-disclosure.test.ts new file mode 100644 index 0000000..27c5390 --- /dev/null +++ b/extension/src/popup/components/__tests__/attachments-disclosure.test.ts @@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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', + })); + }); +}); diff --git a/extension/src/popup/components/attachments-disclosure.ts b/extension/src/popup/components/attachments-disclosure.ts new file mode 100644 index 0000000..670fb2d --- /dev/null +++ b/extension/src/popup/components/attachments-disclosure.ts @@ -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(); // 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 { + 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) + ? `📄` + : `📄`; + return ` +
+ ${iconHtml} + ${escapeHtml(a.filename)} + ${formatBytes(a.size)} + ${action} +
+ `; + }).join(''); + const addBtn = opts.mode === 'edit' + ? `` + : ''; + const fileInput = opts.mode === 'edit' + ? `` + : ''; + return ` +
+ ${expanded ? '▾' : '▸'} ${headerLabel} +
+ ${rowsHtml} + ${addBtn} +
+ ${fileInput} +
+ `; +} + +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 => { + const thumbs = disc.querySelectorAll('.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 = ``; + } + } + }; + 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('.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('.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(); +} diff --git a/extension/src/popup/styles.css b/extension/src/popup/styles.css index 29e2597..aeb4107 100644 --- a/extension/src/popup/styles.css +++ b/extension/src/popup/styles.css @@ -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; +}