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;
|
display: flex; justify-content: flex-end; gap: 6px;
|
||||||
margin-top: 20px; padding-top: 12px; border-top: 1px solid #21262d;
|
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