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:
@@ -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',
|
||||
}));
|
||||
});
|
||||
});
|
||||
198
extension/src/popup/components/attachments-disclosure.ts
Normal file
198
extension/src/popup/components/attachments-disclosure.ts
Normal 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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user