Full document, spreadsheet, slideshow, and diagram tooling
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">×</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">→</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">−${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}