/** * Diff Panel — slide-in panel for comparing two document versions. * * Follows the same pattern as version-panel.ts: * - Fixed position right panel with CSS transition * - Toggle/open/close methods * - z-index 900 (--z-panel) * * Fetches version list from the API, lets the user pick two versions, * decrypts both snapshots, extracts TipTap JSON, and renders an inline diff. */ import { formatRelativeTime } from '../version-panel.js'; import { diffDocuments, renderDiffHtml } from './doc-diff.js'; import { listVersions, getVersion } from '../lib/local-store.js'; // --- Types --- export interface DiffPanelConfig { docId: string; cryptoKey: CryptoKey; container?: HTMLElement; } export interface DiffPanel { toggle: () => void; open: () => void; close: () => void; isOpen: () => boolean; destroy: () => void; } interface VersionData { id: string; document_id: string; created_at: string; metadata: Record | null; } /** * Build a dropdown label for a version. * * Forge-authored versions show their `label` metadata (e.g. "Initial plan", * "Step 2 complete") prefixed with a [forge] tag so the timeline stays * readable when diffing machine-generated revisions. Human versions keep * the legacy `name (time)` or `time — author` format. */ export function formatDiffOption(meta: Record, createdAt: string): string { const timeStr = formatRelativeTime(createdAt); const author = typeof meta['author'] === 'string' ? meta['author'] : 'Unknown'; const name = typeof meta['name'] === 'string' ? meta['name'] : null; const label = typeof meta['label'] === 'string' ? meta['label'] : null; if (author === 'forge') { const forgeLabel = name || label || 'revision'; return `[forge] ${forgeLabel} (${timeStr})`; } return name ? `${name} (${timeStr})` : `${timeStr} — ${author}`; } /** Returns true if a version was authored by Forge. */ export function isForgeAuthored(meta: Record): boolean { return meta['author'] === 'forge'; } // --- Factory --- export function createDiffPanel(config: DiffPanelConfig): DiffPanel { const { docId, container = document.body, } = config; // Build DOM const panel = document.createElement('div'); panel.className = 'diff-panel'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Compare versions'); panel.innerHTML = `

Compare Versions

Select two versions to compare
`; container.appendChild(panel); const closeBtn = panel.querySelector('.diff-panel-close') as HTMLButtonElement; const selectA = panel.querySelector('#diff-version-a') as HTMLSelectElement; const selectB = panel.querySelector('#diff-version-b') as HTMLSelectElement; const compareBtn = panel.querySelector('.diff-panel-run') as HTMLButtonElement; const statsEl = panel.querySelector('.diff-panel-stats') as HTMLDivElement; const bodyEl = panel.querySelector('.diff-panel-body') as HTMLDivElement; let isOpen_ = false; let versions: VersionData[] = []; // --- Event handlers --- closeBtn.addEventListener('click', close); compareBtn.addEventListener('click', runDiff); function handleKeydown(e: KeyboardEvent): void { if (e.key === 'Escape' && isOpen_) { e.preventDefault(); close(); } } document.addEventListener('keydown', handleKeydown); // --- Methods --- function toggle(): void { if (isOpen_) close(); else open(); } function open(): void { isOpen_ = true; panel.classList.add('open'); loadVersions(); } function close(): void { isOpen_ = false; panel.classList.remove('open'); } async function loadVersions(): Promise { selectA.innerHTML = ''; selectB.innerHTML = ''; bodyEl.innerHTML = '
Loading versions...
'; statsEl.style.display = 'none'; try { const entries = await listVersions(docId); // Parse metadata JSON strings into objects versions = entries.map(e => ({ id: e.id, document_id: e.document_id, created_at: e.created_at, metadata: ((): Record | null => { try { return JSON.parse(e.metadata || '{}'); } catch { return null; } })(), })); if (versions.length < 2) { bodyEl.innerHTML = '
Need at least two versions to compare
'; selectA.innerHTML = ''; selectB.innerHTML = ''; return; } // Populate dropdowns — versions are newest-first from listVersions selectA.innerHTML = ''; selectB.innerHTML = ''; for (const v of versions) { const meta = (v.metadata && typeof v.metadata === 'object') ? v.metadata : {}; const label = formatDiffOption(meta, v.created_at); const forgeClass = isForgeAuthored(meta) ? 'diff-option--forge' : ''; const optA = document.createElement('option'); optA.value = v.id; optA.textContent = label; if (forgeClass) optA.className = forgeClass; selectA.appendChild(optA); const optB = document.createElement('option'); optB.value = v.id; optB.textContent = label; if (forgeClass) optB.className = forgeClass; selectB.appendChild(optB); } // Default: compare second-newest (base) vs newest (compare) if (versions.length >= 2) { selectA.value = versions[1]!.id; selectB.value = versions[0]!.id; } bodyEl.innerHTML = '
Select two versions and click Compare
'; } catch { bodyEl.innerHTML = '
Failed to load versions
'; } } async function fetchAndDecryptVersion(versionId: string): Promise { const entry = await getVersion(docId, versionId); if (!entry) throw new Error('Version not found'); const encrypted = new Uint8Array(entry.snapshot); const { decrypt } = await import('../lib/crypto.js'); return decrypt(encrypted, config.cryptoKey); } async function yDocToJson(data: Uint8Array): Promise | null> { const Y = await import('yjs'); const tempDoc = new Y.Doc(); Y.applyUpdate(tempDoc, data); // Extract text from the Yjs XML fragment const fragment = tempDoc.getXmlFragment('default'); const xmlStr = fragment.toString(); tempDoc.destroy(); if (!xmlStr || xmlStr === '') return null; // Strip HTML tags and wrap as a simple TipTap-like doc for diffing const textContent = xmlStr.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); if (!textContent) return null; return { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text: textContent }], }], }; } async function runDiff(): Promise { const idA = selectA.value; const idB = selectB.value; if (!idA || !idB) { bodyEl.innerHTML = '
Please select two versions
'; return; } if (idA === idB) { bodyEl.innerHTML = '
Select two different versions to compare
'; return; } bodyEl.innerHTML = '
Computing diff...
'; statsEl.style.display = 'none'; compareBtn.disabled = true; try { const [dataA, dataB] = await Promise.all([ fetchAndDecryptVersion(idA), fetchAndDecryptVersion(idB), ]); const [jsonA, jsonB] = await Promise.all([ yDocToJson(dataA), yDocToJson(dataB), ]); const blocks = diffDocuments(jsonA, jsonB); // Compute stats let inserts = 0; let deletes = 0; for (const b of blocks) { if (b.type === 'insert') inserts += b.content.split(/\s+/).length; if (b.type === 'delete') deletes += b.content.split(/\s+/).length; } if (inserts === 0 && deletes === 0) { statsEl.style.display = 'none'; bodyEl.innerHTML = '
No differences found
'; } else { statsEl.style.display = ''; const metaA = (versions.find(v => v.id === idA)?.metadata ?? {}) as Record; const metaB = (versions.find(v => v.id === idB)?.metadata ?? {}) as Record; const forgeBadge = isForgeAuthored(metaA) && isForgeAuthored(metaB) ? 'forge' : ''; statsEl.innerHTML = ` +${inserts} word${inserts !== 1 ? 's' : ''} −${deletes} word${deletes !== 1 ? 's' : ''} ${forgeBadge} `; const diffHtml = renderDiffHtml(blocks); bodyEl.innerHTML = `
${diffHtml}
`; } } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; bodyEl.innerHTML = `
Failed to compute diff: ${escapeHtml(msg)}
`; statsEl.style.display = 'none'; } finally { compareBtn.disabled = false; } } function escapeHtml(str: string): string { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } function destroy(): void { document.removeEventListener('keydown', handleKeydown); panel.remove(); } return { toggle, open, close, isOpen: () => isOpen_, destroy, }; }