Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 322 lines 11 kB view raw
1/** 2 * Diff Panel — slide-in panel for comparing two document versions. 3 * 4 * Follows the same pattern as version-panel.ts: 5 * - Fixed position right panel with CSS transition 6 * - Toggle/open/close methods 7 * - z-index 900 (--z-panel) 8 * 9 * Fetches version list from the API, lets the user pick two versions, 10 * decrypts both snapshots, extracts TipTap JSON, and renders an inline diff. 11 */ 12 13import { formatRelativeTime } from '../version-panel.js'; 14import { diffDocuments, renderDiffHtml } from './doc-diff.js'; 15import { listVersions, getVersion } from '../lib/local-store.js'; 16 17// --- Types --- 18 19export interface DiffPanelConfig { 20 docId: string; 21 cryptoKey: CryptoKey; 22 container?: HTMLElement; 23} 24 25export interface DiffPanel { 26 toggle: () => void; 27 open: () => void; 28 close: () => void; 29 isOpen: () => boolean; 30 destroy: () => void; 31} 32 33interface VersionData { 34 id: string; 35 document_id: string; 36 created_at: string; 37 metadata: Record<string, unknown> | null; 38} 39 40/** 41 * Build a dropdown label for a version. 42 * 43 * Forge-authored versions show their `label` metadata (e.g. "Initial plan", 44 * "Step 2 complete") prefixed with a [forge] tag so the timeline stays 45 * readable when diffing machine-generated revisions. Human versions keep 46 * the legacy `name (time)` or `time — author` format. 47 */ 48export function formatDiffOption(meta: Record<string, unknown>, createdAt: string): string { 49 const timeStr = formatRelativeTime(createdAt); 50 const author = typeof meta['author'] === 'string' ? meta['author'] : 'Unknown'; 51 const name = typeof meta['name'] === 'string' ? meta['name'] : null; 52 const label = typeof meta['label'] === 'string' ? meta['label'] : null; 53 54 if (author === 'forge') { 55 const forgeLabel = name || label || 'revision'; 56 return `[forge] ${forgeLabel} (${timeStr})`; 57 } 58 return name ? `${name} (${timeStr})` : `${timeStr}${author}`; 59} 60 61/** Returns true if a version was authored by Forge. */ 62export function isForgeAuthored(meta: Record<string, unknown>): boolean { 63 return meta['author'] === 'forge'; 64} 65 66// --- Factory --- 67 68export function createDiffPanel(config: DiffPanelConfig): DiffPanel { 69 const { 70 docId, 71 container = document.body, 72 } = config; 73 74 // Build DOM 75 const panel = document.createElement('div'); 76 panel.className = 'diff-panel'; 77 panel.setAttribute('role', 'dialog'); 78 panel.setAttribute('aria-label', 'Compare versions'); 79 80 panel.innerHTML = ` 81 <div class="diff-panel-header"> 82 <h3>Compare Versions</h3> 83 <button class="btn-icon diff-panel-close" title="Close (Esc)" aria-label="Close">&times;</button> 84 </div> 85 <div class="diff-panel-controls"> 86 <div class="diff-panel-select-group"> 87 <label class="diff-panel-label" for="diff-version-a">Base</label> 88 <select id="diff-version-a" class="diff-panel-select" aria-label="Base version"></select> 89 </div> 90 <span class="diff-panel-arrow" aria-hidden="true">&rarr;</span> 91 <div class="diff-panel-select-group"> 92 <label class="diff-panel-label" for="diff-version-b">Compare</label> 93 <select id="diff-version-b" class="diff-panel-select" aria-label="Compare version"></select> 94 </div> 95 <button class="btn-primary btn-sm diff-panel-run">Compare</button> 96 </div> 97 <div class="diff-panel-stats" style="display:none"></div> 98 <div class="diff-panel-body"> 99 <div class="diff-panel-empty">Select two versions to compare</div> 100 </div> 101 `; 102 103 container.appendChild(panel); 104 105 const closeBtn = panel.querySelector('.diff-panel-close') as HTMLButtonElement; 106 const selectA = panel.querySelector('#diff-version-a') as HTMLSelectElement; 107 const selectB = panel.querySelector('#diff-version-b') as HTMLSelectElement; 108 const compareBtn = panel.querySelector('.diff-panel-run') as HTMLButtonElement; 109 const statsEl = panel.querySelector('.diff-panel-stats') as HTMLDivElement; 110 const bodyEl = panel.querySelector('.diff-panel-body') as HTMLDivElement; 111 112 let isOpen_ = false; 113 let versions: VersionData[] = []; 114 115 // --- Event handlers --- 116 closeBtn.addEventListener('click', close); 117 compareBtn.addEventListener('click', runDiff); 118 119 function handleKeydown(e: KeyboardEvent): void { 120 if (e.key === 'Escape' && isOpen_) { 121 e.preventDefault(); 122 close(); 123 } 124 } 125 document.addEventListener('keydown', handleKeydown); 126 127 // --- Methods --- 128 function toggle(): void { 129 if (isOpen_) close(); 130 else open(); 131 } 132 133 function open(): void { 134 isOpen_ = true; 135 panel.classList.add('open'); 136 loadVersions(); 137 } 138 139 function close(): void { 140 isOpen_ = false; 141 panel.classList.remove('open'); 142 } 143 144 async function loadVersions(): Promise<void> { 145 selectA.innerHTML = '<option value="">Loading...</option>'; 146 selectB.innerHTML = '<option value="">Loading...</option>'; 147 bodyEl.innerHTML = '<div class="diff-panel-empty">Loading versions...</div>'; 148 statsEl.style.display = 'none'; 149 150 try { 151 const entries = await listVersions(docId); 152 // Parse metadata JSON strings into objects 153 versions = entries.map(e => ({ 154 id: e.id, 155 document_id: e.document_id, 156 created_at: e.created_at, 157 metadata: ((): Record<string, unknown> | null => { 158 try { return JSON.parse(e.metadata || '{}'); } catch { return null; } 159 })(), 160 })); 161 162 if (versions.length < 2) { 163 bodyEl.innerHTML = '<div class="diff-panel-empty">Need at least two versions to compare</div>'; 164 selectA.innerHTML = '<option value="">Not enough versions</option>'; 165 selectB.innerHTML = '<option value="">Not enough versions</option>'; 166 return; 167 } 168 169 // Populate dropdowns — versions are newest-first from listVersions 170 selectA.innerHTML = ''; 171 selectB.innerHTML = ''; 172 173 for (const v of versions) { 174 const meta = (v.metadata && typeof v.metadata === 'object') ? v.metadata : {}; 175 const label = formatDiffOption(meta, v.created_at); 176 const forgeClass = isForgeAuthored(meta) ? 'diff-option--forge' : ''; 177 178 const optA = document.createElement('option'); 179 optA.value = v.id; 180 optA.textContent = label; 181 if (forgeClass) optA.className = forgeClass; 182 selectA.appendChild(optA); 183 184 const optB = document.createElement('option'); 185 optB.value = v.id; 186 optB.textContent = label; 187 if (forgeClass) optB.className = forgeClass; 188 selectB.appendChild(optB); 189 } 190 191 // Default: compare second-newest (base) vs newest (compare) 192 if (versions.length >= 2) { 193 selectA.value = versions[1]!.id; 194 selectB.value = versions[0]!.id; 195 } 196 197 bodyEl.innerHTML = '<div class="diff-panel-empty">Select two versions and click Compare</div>'; 198 } catch { 199 bodyEl.innerHTML = '<div class="diff-panel-empty">Failed to load versions</div>'; 200 } 201 } 202 203 async function fetchAndDecryptVersion(versionId: string): Promise<Uint8Array> { 204 const entry = await getVersion(docId, versionId); 205 if (!entry) throw new Error('Version not found'); 206 const encrypted = new Uint8Array(entry.snapshot); 207 208 const { decrypt } = await import('../lib/crypto.js'); 209 return decrypt(encrypted, config.cryptoKey); 210 } 211 212 async function yDocToJson(data: Uint8Array): Promise<Record<string, unknown> | null> { 213 const Y = await import('yjs'); 214 const tempDoc = new Y.Doc(); 215 Y.applyUpdate(tempDoc, data); 216 217 // Extract text from the Yjs XML fragment 218 const fragment = tempDoc.getXmlFragment('default'); 219 const xmlStr = fragment.toString(); 220 tempDoc.destroy(); 221 222 if (!xmlStr || xmlStr === '<UNDEFINED></UNDEFINED>') return null; 223 224 // Strip HTML tags and wrap as a simple TipTap-like doc for diffing 225 const textContent = xmlStr.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); 226 if (!textContent) return null; 227 228 return { 229 type: 'doc', 230 content: [{ 231 type: 'paragraph', 232 content: [{ type: 'text', text: textContent }], 233 }], 234 }; 235 } 236 237 async function runDiff(): Promise<void> { 238 const idA = selectA.value; 239 const idB = selectB.value; 240 241 if (!idA || !idB) { 242 bodyEl.innerHTML = '<div class="diff-panel-empty">Please select two versions</div>'; 243 return; 244 } 245 246 if (idA === idB) { 247 bodyEl.innerHTML = '<div class="diff-panel-empty">Select two different versions to compare</div>'; 248 return; 249 } 250 251 bodyEl.innerHTML = '<div class="diff-panel-empty">Computing diff...</div>'; 252 statsEl.style.display = 'none'; 253 compareBtn.disabled = true; 254 255 try { 256 const [dataA, dataB] = await Promise.all([ 257 fetchAndDecryptVersion(idA), 258 fetchAndDecryptVersion(idB), 259 ]); 260 261 const [jsonA, jsonB] = await Promise.all([ 262 yDocToJson(dataA), 263 yDocToJson(dataB), 264 ]); 265 266 const blocks = diffDocuments(jsonA, jsonB); 267 268 // Compute stats 269 let inserts = 0; 270 let deletes = 0; 271 for (const b of blocks) { 272 if (b.type === 'insert') inserts += b.content.split(/\s+/).length; 273 if (b.type === 'delete') deletes += b.content.split(/\s+/).length; 274 } 275 276 if (inserts === 0 && deletes === 0) { 277 statsEl.style.display = 'none'; 278 bodyEl.innerHTML = '<div class="diff-panel-empty">No differences found</div>'; 279 } else { 280 statsEl.style.display = ''; 281 const metaA = (versions.find(v => v.id === idA)?.metadata ?? {}) as Record<string, unknown>; 282 const metaB = (versions.find(v => v.id === idB)?.metadata ?? {}) as Record<string, unknown>; 283 const forgeBadge = isForgeAuthored(metaA) && isForgeAuthored(metaB) 284 ? '<span class="diff-stat-forge" title="Both versions authored by Forge">forge</span>' 285 : ''; 286 statsEl.innerHTML = ` 287 <span class="diff-stat-add">+${inserts} word${inserts !== 1 ? 's' : ''}</span> 288 <span class="diff-stat-del">&minus;${deletes} word${deletes !== 1 ? 's' : ''}</span> 289 ${forgeBadge} 290 `; 291 292 const diffHtml = renderDiffHtml(blocks); 293 bodyEl.innerHTML = `<div class="diff-panel-content">${diffHtml}</div>`; 294 } 295 } catch (err) { 296 const msg = err instanceof Error ? err.message : 'Unknown error'; 297 bodyEl.innerHTML = `<div class="diff-panel-empty">Failed to compute diff: ${escapeHtml(msg)}</div>`; 298 statsEl.style.display = 'none'; 299 } finally { 300 compareBtn.disabled = false; 301 } 302 } 303 304 function escapeHtml(str: string): string { 305 const div = document.createElement('div'); 306 div.textContent = str; 307 return div.innerHTML; 308 } 309 310 function destroy(): void { 311 document.removeEventListener('keydown', handleKeydown); 312 panel.remove(); 313 } 314 315 return { 316 toggle, 317 open, 318 close, 319 isOpen: () => isOpen_, 320 destroy, 321 }; 322}