clawkeep 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/ui/app.js ADDED
@@ -0,0 +1,1191 @@
1
+ /* ClawKeep — Backup Dashboard */
2
+ 'use strict';
3
+
4
+ const TOKEN = new URLSearchParams(location.search).get('token') || '';
5
+ const Q = (x) => '?token=' + TOKEN + (x ? '&' + x : '');
6
+ const api = async (p, q) => (await fetch('/api/' + p + Q(q))).json();
7
+ const $ = (s) => document.querySelector(s);
8
+ const $$ = (s) => document.querySelectorAll(s);
9
+ const esc = (s) => !s ? '' : s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
10
+
11
+ function timeAgo(d) {
12
+ const ms = Date.now() - new Date(d).getTime();
13
+ const m = Math.floor(ms / 60e3), h = Math.floor(ms / 36e5), dy = Math.floor(ms / 864e5);
14
+ if (m < 1) return 'now';
15
+ if (m < 60) return m + (m === 1 ? ' minute' : ' minutes') + ' ago';
16
+ if (h < 24) return h + (h === 1 ? ' hour' : ' hours') + ' ago';
17
+ if (dy < 30) return dy + (dy === 1 ? ' day' : ' days') + ' ago';
18
+ return new Date(d).toLocaleDateString();
19
+ }
20
+
21
+ function fmtSize(b) {
22
+ if (!b && b !== 0) return '';
23
+ if (b < 1024) return b + ' B';
24
+ if (b < 1048576) return (b / 1024).toFixed(1) + ' KB';
25
+ return (b / 1048576).toFixed(1) + ' MB';
26
+ }
27
+
28
+ function toast(msg, ok) {
29
+ const el = document.createElement('div');
30
+ el.className = 'toast' + (ok ? ' toast-ok' : '');
31
+ el.innerHTML = msg;
32
+ $('#toast-wrap').appendChild(el);
33
+ setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 200); }, 3000);
34
+ }
35
+
36
+ function renderMarkdown(text) {
37
+ let h = esc(text);
38
+ h = h.replace(/^### (.+)$/gm, '<h3>$1</h3>');
39
+ h = h.replace(/^## (.+)$/gm, '<h2>$1</h2>');
40
+ h = h.replace(/^# (.+)$/gm, '<h1>$1</h1>');
41
+ h = h.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
42
+ h = h.replace(/\*(.+?)\*/g, '<em>$1</em>');
43
+ h = h.replace(/```(\w*)\n([\s\S]*?)```/g, '<pre><code>$2</code></pre>');
44
+ h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
45
+ h = h.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
46
+ h = h.replace(/^- (.+)$/gm, '<li>$1</li>');
47
+ h = h.replace(/(<li>.*<\/li>\n?)+/g, '<ul>$&</ul>');
48
+ h = h.replace(/^&gt; (.+)$/gm, '<blockquote>$1</blockquote>');
49
+ h = h.replace(/^---$/gm, '<hr>');
50
+ h = h.replace(/^\|(.+)\|$/gm, (m, c) => {
51
+ const cells = c.split('|').map(x => x.trim());
52
+ if (cells.every(x => /^[-:]+$/.test(x))) return '';
53
+ return '<tr>' + cells.map(x => '<td>' + x + '</td>').join('') + '</tr>';
54
+ });
55
+ h = h.replace(/(<tr>.*<\/tr>\n?)+/g, '<table>$&</table>');
56
+ return h;
57
+ }
58
+
59
+ /* ═══ SYNTAX HIGHLIGHTING ═══ */
60
+ const LANG_MAP = {
61
+ js: 'js', mjs: 'js', cjs: 'js', jsx: 'js',
62
+ ts: 'js', tsx: 'js',
63
+ py: 'py', python: 'py',
64
+ json: 'json',
65
+ css: 'css',
66
+ html: 'html', htm: 'html', xml: 'html', svg: 'html',
67
+ yml: 'yaml', yaml: 'yaml',
68
+ sh: 'sh', bash: 'sh', zsh: 'sh',
69
+ go: 'go',
70
+ rs: 'rust', rust: 'rust',
71
+ java: 'java',
72
+ md: 'md',
73
+ };
74
+
75
+ const KW = {
76
+ js: 'abstract|async|await|break|case|catch|class|const|continue|debugger|default|delete|do|else|enum|export|extends|finally|for|from|function|if|implements|import|in|instanceof|interface|let|new|of|package|private|protected|public|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield',
77
+ py: 'and|as|assert|async|await|break|class|continue|def|del|elif|else|except|finally|for|from|global|if|import|in|is|lambda|nonlocal|not|or|pass|raise|return|try|while|with|yield',
78
+ go: 'break|case|chan|const|continue|default|defer|else|fallthrough|for|func|go|goto|if|import|interface|map|package|range|return|select|struct|switch|type|var',
79
+ rust: 'as|async|await|break|const|continue|crate|dyn|else|enum|extern|fn|for|if|impl|in|let|loop|match|mod|move|mut|pub|ref|return|self|static|struct|super|trait|type|unsafe|use|where|while',
80
+ java: 'abstract|assert|boolean|break|byte|case|catch|char|class|const|continue|default|do|double|else|enum|extends|final|finally|float|for|if|implements|import|instanceof|int|interface|long|native|new|package|private|protected|public|return|short|static|strictfp|super|switch|synchronized|this|throw|throws|transient|try|void|volatile|while',
81
+ css: '@media|@keyframes|@import|@font-face|@supports|@charset',
82
+ };
83
+
84
+ const CONSTS = {
85
+ js: 'true|false|null|undefined|NaN|Infinity|arguments|console|window|document|module|exports|require|process|globalThis|Promise|Array|Object|String|Number|Boolean|Symbol|Map|Set|WeakMap|WeakSet|Error|RegExp|Math|JSON|Date|parseInt|parseFloat',
86
+ py: 'True|False|None|self|cls|print|len|range|str|int|float|list|dict|set|tuple|type|super|property|staticmethod|classmethod|isinstance|issubclass|hasattr|getattr|setattr|enumerate|zip|map|filter|sorted|reversed|open|input|Exception',
87
+ go: 'true|false|nil|iota|append|cap|close|complex|copy|delete|imag|len|make|new|panic|print|println|real|recover|error|string|bool|int|int8|int16|int32|int64|uint|uint8|uint16|uint32|uint64|float32|float64|byte|rune',
88
+ rust: 'true|false|self|Self|Some|None|Ok|Err|Box|Vec|String|Option|Result|impl|println|eprintln|format|todo|unimplemented|unreachable|assert|panic|cfg|derive|allow|deny|warn|test|macro_rules',
89
+ java: 'true|false|null|this|super|System|String|Integer|Boolean|Double|Float|Long|Short|Byte|Character|Object|Class|Exception|RuntimeException|Thread|Runnable|Override|Deprecated|SuppressWarnings',
90
+ };
91
+
92
+ function getLang(filename) {
93
+ const ext = (filename || '').split('.').pop().toLowerCase();
94
+ return LANG_MAP[ext] || null;
95
+ }
96
+
97
+ function highlight(code, lang) {
98
+ if (!lang || lang === 'md') return esc(code);
99
+
100
+ if (lang === 'json') return highlightJSON(code);
101
+ if (lang === 'yaml') return highlightYAML(code);
102
+ if (lang === 'html') return highlightHTML(code);
103
+ if (lang === 'css') return highlightCSS(code);
104
+
105
+ const kw = KW[lang] || KW.js;
106
+ const cn = CONSTS[lang] || CONSTS.js;
107
+ const commentLine = (lang === 'py' || lang === 'sh') ? '#' : '//';
108
+ const hasBlockComment = (lang !== 'py' && lang !== 'sh');
109
+
110
+ const out = [];
111
+ let i = 0;
112
+ while (i < code.length) {
113
+ if (hasBlockComment && code[i] === '/' && code[i + 1] === '*') {
114
+ const end = code.indexOf('*/', i + 2);
115
+ const slice = end === -1 ? code.substring(i) : code.substring(i, end + 2);
116
+ out.push('<span class="hl-cm">' + esc(slice) + '</span>');
117
+ i += slice.length;
118
+ continue;
119
+ }
120
+ if (code.substring(i, i + commentLine.length) === commentLine) {
121
+ const nl = code.indexOf('\n', i);
122
+ const slice = nl === -1 ? code.substring(i) : code.substring(i, nl);
123
+ out.push('<span class="hl-cm">' + esc(slice) + '</span>');
124
+ i += slice.length;
125
+ continue;
126
+ }
127
+ if ((lang === 'py' || lang === 'sh') && code[i] === '#') {
128
+ const nl = code.indexOf('\n', i);
129
+ const slice = nl === -1 ? code.substring(i) : code.substring(i, nl);
130
+ out.push('<span class="hl-cm">' + esc(slice) + '</span>');
131
+ i += slice.length;
132
+ continue;
133
+ }
134
+ if (code[i] === '"' || code[i] === "'" || code[i] === '`') {
135
+ const q = code[i];
136
+ let j = i + 1;
137
+ while (j < code.length) {
138
+ if (code[j] === '\\') { j += 2; continue; }
139
+ if (code[j] === q) { j++; break; }
140
+ if (q !== '`' && code[j] === '\n') break;
141
+ j++;
142
+ }
143
+ out.push('<span class="hl-str">' + esc(code.substring(i, j)) + '</span>');
144
+ i = j;
145
+ continue;
146
+ }
147
+ if (/[0-9]/.test(code[i]) && (i === 0 || /[^a-zA-Z_$]/.test(code[i - 1]))) {
148
+ let j = i;
149
+ while (j < code.length && /[0-9a-fA-FxXoObBeE._]/.test(code[j])) j++;
150
+ out.push('<span class="hl-num">' + esc(code.substring(i, j)) + '</span>');
151
+ i = j;
152
+ continue;
153
+ }
154
+ if (/[a-zA-Z_$@]/.test(code[i])) {
155
+ let j = i;
156
+ while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
157
+ const word = code.substring(i, j);
158
+ let la = j;
159
+ while (la < code.length && code[la] === ' ') la++;
160
+ if (new RegExp('^(' + kw + ')$').test(word)) {
161
+ out.push('<span class="hl-kw">' + esc(word) + '</span>');
162
+ } else if (new RegExp('^(' + cn + ')$').test(word)) {
163
+ out.push('<span class="hl-const">' + esc(word) + '</span>');
164
+ } else if (code[la] === '(') {
165
+ out.push('<span class="hl-fn">' + esc(word) + '</span>');
166
+ } else {
167
+ out.push(esc(word));
168
+ }
169
+ i = j;
170
+ continue;
171
+ }
172
+ out.push(esc(code[i]));
173
+ i++;
174
+ }
175
+ return out.join('');
176
+ }
177
+
178
+ function highlightJSON(code) {
179
+ return code.split('\n').map(line => {
180
+ let h = esc(line);
181
+ h = h.replace(/^(\s*)(&quot;[^&]*?&quot;)(\s*:)/g, '$1<span class="hl-prop">$2</span>$3');
182
+ h = h.replace(/:\s*(&quot;[^&]*?&quot;)/g, ': <span class="hl-str">$1</span>');
183
+ h = h.replace(/:\s*(-?[0-9][0-9.eE]*)/g, ': <span class="hl-num">$1</span>');
184
+ h = h.replace(/:\s*(true|false|null)\b/g, ': <span class="hl-const">$1</span>');
185
+ return h;
186
+ }).join('\n');
187
+ }
188
+
189
+ function highlightYAML(code) {
190
+ return code.split('\n').map(line => {
191
+ let h = esc(line);
192
+ if (/^\s*#/.test(line)) return '<span class="hl-cm">' + h + '</span>';
193
+ h = h.replace(/^(\s*)([\w][\w.-]*)(\s*:)/g, '$1<span class="hl-prop">$2</span>$3');
194
+ h = h.replace(/:\s*(&quot;[^&]*?&quot;|&#x27;[^&]*?&#x27;)/g, ': <span class="hl-str">$1</span>');
195
+ h = h.replace(/:\s*(true|false|null|yes|no)\s*$/gi, ': <span class="hl-const">$1</span>');
196
+ h = h.replace(/:\s*(-?[0-9][0-9.]*)\s*$/g, ': <span class="hl-num">$1</span>');
197
+ return h;
198
+ }).join('\n');
199
+ }
200
+
201
+ function highlightHTML(code) {
202
+ const out = [];
203
+ let i = 0;
204
+ while (i < code.length) {
205
+ if (code[i] === '<' && code[i + 1] === '!') {
206
+ const end = code.indexOf('-->', i);
207
+ const slice = end === -1 ? code.substring(i) : code.substring(i, end + 3);
208
+ out.push('<span class="hl-cm">' + esc(slice) + '</span>');
209
+ i += slice.length;
210
+ } else if (code[i] === '<') {
211
+ const end = code.indexOf('>', i);
212
+ const tag = end === -1 ? code.substring(i) : code.substring(i, end + 1);
213
+ let h = esc(tag);
214
+ h = h.replace(/^(&lt;\/?)([\w-]+)/, '$1<span class="hl-kw">$2</span>');
215
+ h = h.replace(/([\w-]+)(=)/g, '<span class="hl-prop">$1</span>$2');
216
+ h = h.replace(/(&quot;[^&]*?&quot;)/g, '<span class="hl-str">$1</span>');
217
+ out.push(h);
218
+ i += tag.length;
219
+ } else {
220
+ const next = code.indexOf('<', i);
221
+ const slice = next === -1 ? code.substring(i) : code.substring(i, next);
222
+ out.push(esc(slice));
223
+ i += slice.length;
224
+ }
225
+ }
226
+ return out.join('');
227
+ }
228
+
229
+ function highlightCSS(code) {
230
+ const out = [];
231
+ let i = 0;
232
+ while (i < code.length) {
233
+ if (code[i] === '/' && code[i + 1] === '*') {
234
+ const end = code.indexOf('*/', i + 2);
235
+ const slice = end === -1 ? code.substring(i) : code.substring(i, end + 2);
236
+ out.push('<span class="hl-cm">' + esc(slice) + '</span>');
237
+ i += slice.length;
238
+ } else if (code[i] === '"' || code[i] === "'") {
239
+ const q = code[i]; let j = i + 1;
240
+ while (j < code.length && code[j] !== q && code[j] !== '\n') { if (code[j] === '\\') j++; j++; }
241
+ if (j < code.length && code[j] === q) j++;
242
+ out.push('<span class="hl-str">' + esc(code.substring(i, j)) + '</span>');
243
+ i = j;
244
+ } else if (code[i] === '{' || code[i] === '}' || code[i] === ';') {
245
+ out.push(esc(code[i]));
246
+ i++;
247
+ } else if (code[i] === ':' && i > 0) {
248
+ out.push(':');
249
+ i++;
250
+ } else if (code[i] === '@') {
251
+ let j = i; while (j < code.length && /[a-zA-Z-]/.test(code[j + 1] || '')) j++;
252
+ j++;
253
+ out.push('<span class="hl-kw">' + esc(code.substring(i, j)) + '</span>');
254
+ i = j;
255
+ } else if (/[#.]/.test(code[i]) && (i === 0 || /[\s{};,]/.test(code[i - 1]))) {
256
+ let j = i; while (j < code.length && /[a-zA-Z0-9_-]/.test(code[j + 1] || '')) j++;
257
+ j++;
258
+ out.push('<span class="hl-fn">' + esc(code.substring(i, j)) + '</span>');
259
+ i = j;
260
+ } else {
261
+ out.push(esc(code[i]));
262
+ i++;
263
+ }
264
+ }
265
+ return out.join('');
266
+ }
267
+
268
+
269
+ /* ═══ DIFF PARSER ═══ */
270
+ function parseDiffSections(rawDiff) {
271
+ if (!rawDiff || !rawDiff.trim()) return [];
272
+
273
+ const chunks = rawDiff.split(/^(?=diff --git )/m);
274
+ return chunks.filter(c => c.trim()).map(chunk => {
275
+ const lines = chunk.split('\n');
276
+ const header = lines[0] || '';
277
+ const match = header.match(/^diff --git a\/(.+?) b\/(.+)/);
278
+ const filename = match ? match[2] : 'unknown';
279
+
280
+ let additions = 0, deletions = 0;
281
+ const body = [];
282
+ let oldLine = 0, newLine = 0;
283
+
284
+ for (let i = 1; i < lines.length; i++) {
285
+ const l = lines[i];
286
+ if (l.startsWith('index ') || l.startsWith('--- ') || l.startsWith('+++ ')) continue;
287
+ if (l.startsWith('@@')) {
288
+ const hm = l.match(/@@ -(\d+)/);
289
+ const hm2 = l.match(/\+(\d+)/);
290
+ oldLine = hm ? parseInt(hm[1]) : 0;
291
+ newLine = hm2 ? parseInt(hm2[1]) : 0;
292
+ body.push({ type: 'hunk', text: l });
293
+ continue;
294
+ }
295
+ if (l.startsWith('+')) {
296
+ additions++;
297
+ body.push({ type: 'add', text: l.substring(1), newLine: newLine++ });
298
+ } else if (l.startsWith('-')) {
299
+ deletions++;
300
+ body.push({ type: 'del', text: l.substring(1), oldLine: oldLine++ });
301
+ } else if (l.startsWith('\\')) {
302
+ body.push({ type: 'info', text: l });
303
+ } else {
304
+ body.push({ type: 'ctx', text: l.substring(1) || '', oldLine: oldLine++, newLine: newLine++ });
305
+ }
306
+ }
307
+
308
+ return { filename, additions, deletions, body };
309
+ });
310
+ }
311
+
312
+ function renderDiffSections(rawDiff) {
313
+ const sections = parseDiffSections(rawDiff);
314
+ if (!sections.length) return '<div class="empty">No changes</div>';
315
+
316
+ return sections.map((s, idx) => {
317
+ const id = 'diff-section-' + idx;
318
+ const bodyHtml = s.body.map(l => {
319
+ const e = esc(l.text);
320
+ if (l.type === 'hunk') return `<tr class="diff-hunk"><td class="diff-ln"></td><td class="diff-ln"></td><td class="diff-ln-code"><span class="d-hunk">${esc(l.text)}</span></td></tr>`;
321
+ if (l.type === 'add') return `<tr class="diff-line-add"><td class="diff-ln"></td><td class="diff-ln">${l.newLine}</td><td class="diff-ln-code"><span class="d-add">+${e}</span></td></tr>`;
322
+ if (l.type === 'del') return `<tr class="diff-ln-del"><td class="diff-ln">${l.oldLine}</td><td class="diff-ln"></td><td class="diff-ln-code"><span class="d-del">-${e}</span></td></tr>`;
323
+ if (l.type === 'info') return `<tr><td class="diff-ln"></td><td class="diff-ln"></td><td class="diff-ln-code" style="color:var(--t4)">${esc(l.text)}</td></tr>`;
324
+ return `<tr><td class="diff-ln">${l.oldLine || ''}</td><td class="diff-ln">${l.newLine || ''}</td><td class="diff-ln-code">${e}</td></tr>`;
325
+ }).join('');
326
+
327
+ return `<div class="diff-section">
328
+ <div class="diff-file-header" onclick="toggleDiffSection('${id}')">
329
+ <span class="diff-chevron" id="chev-${id}">&#9660;</span>
330
+ <span class="file-icon">${fIcon(s.filename)}</span>
331
+ <span class="diff-file-name">${esc(s.filename)}</span>
332
+ <span class="diff-stat">
333
+ ${s.additions ? '<span class="diff-stat-add">+' + s.additions + '</span>' : ''}
334
+ ${s.deletions ? '<span class="diff-stat-del">-' + s.deletions + '</span>' : ''}
335
+ </span>
336
+ </div>
337
+ <div class="diff-file-body" id="${id}">
338
+ <table class="diff-table"><tbody>${bodyHtml}</tbody></table>
339
+ </div>
340
+ </div>`;
341
+ }).join('');
342
+ }
343
+
344
+ function toggleDiffSection(id) {
345
+ const el = document.getElementById(id);
346
+ const chev = document.getElementById('chev-' + id);
347
+ if (!el) return;
348
+ el.classList.toggle('collapsed');
349
+ if (chev) chev.innerHTML = el.classList.contains('collapsed') ? '&#9654;' : '&#9660;';
350
+ }
351
+
352
+
353
+ /* ═══ TABS ═══ */
354
+ function switchTab(name) {
355
+ $$('.nav-item').forEach(b => {
356
+ b.classList.toggle('active', b.dataset.tab === name);
357
+ });
358
+ $('#page-title').textContent = { dashboard: 'Dashboard', history: 'History', backup: 'Backup', browse: 'Browse' }[name];
359
+ ['dashboard', 'history', 'backup', 'browse'].forEach(id => {
360
+ const el = $('#tab-' + id);
361
+ if (el) el.classList.toggle('hidden', id !== name);
362
+ });
363
+ if (name === 'dashboard') loadDashboard();
364
+ if (name === 'history') loadHistory();
365
+ if (name === 'backup') loadBackup();
366
+ if (name === 'browse') { timeTravelHash = null; loadFiles('.'); }
367
+ }
368
+
369
+ $$('.nav-item').forEach(btn => {
370
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab));
371
+ });
372
+
373
+
374
+ /* ═══ DASHBOARD ═══ */
375
+ async function loadDashboard() {
376
+ const [data, backupStatus, watchStatus, repoSize] = await Promise.all([
377
+ api('status'),
378
+ api('backup/status').catch(() => ({})),
379
+ api('backup/watch-status').catch(() => ({})),
380
+ api('backup/repo-size').catch(() => ({})),
381
+ ]);
382
+
383
+ const s = data.stats, gs = data.gitStatus;
384
+ const lastBackup = s.lastSnap ? timeAgo(s.lastSnap) : 'never';
385
+ const sizeStr = repoSize.size ? fmtSize(repoSize.size) : '--';
386
+ const isWatching = watchStatus.running || false;
387
+ const statusLabel = isWatching ? 'Active' : 'Idle';
388
+
389
+ $('#sidebar-info').innerHTML = `<strong>${s.totalSnaps} backups</strong>${s.trackedFiles} files`;
390
+
391
+ // Stats grid
392
+ $('#stats-grid').innerHTML = `
393
+ <div class="stat-card"><div class="stat-label">Status</div><div class="stat-value">${statusLabel}</div></div>
394
+ <div class="stat-card"><div class="stat-label">Backups</div><div class="stat-value">${s.totalSnaps.toLocaleString()}</div></div>
395
+ <div class="stat-card"><div class="stat-label">Files</div><div class="stat-value">${s.trackedFiles.toLocaleString()}</div></div>
396
+ <div class="stat-card"><div class="stat-label">Size</div><div class="stat-value">${sizeStr}</div></div>
397
+ `;
398
+
399
+ // Protection status
400
+ const checks = [];
401
+ if (isWatching) {
402
+ checks.push('<div class="prot-item prot-ok"><span class="prot-icon">&#10003;</span> Watch daemon running</div>');
403
+ } else {
404
+ checks.push('<div class="prot-item prot-warn"><span class="prot-icon">&#9888;</span> Watch daemon not running</div>');
405
+ }
406
+ if (s.lastSnap) {
407
+ checks.push(`<div class="prot-item prot-ok"><span class="prot-icon">&#10003;</span> Last backup: ${lastBackup}</div>`);
408
+ } else {
409
+ checks.push('<div class="prot-item prot-warn"><span class="prot-icon">&#9888;</span> No backups yet</div>');
410
+ }
411
+ if (backupStatus.target) {
412
+ const syncLabel = backupStatus.lastSync ? `synced ${timeAgo(backupStatus.lastSync)}` : 'not synced yet';
413
+ checks.push(`<div class="prot-item prot-ok"><span class="prot-icon">&#10003;</span> Backup target: ${esc(backupStatus.targetLabel || backupStatus.target)} (${syncLabel})</div>`);
414
+ if (backupStatus.target === 'local' && backupStatus.passwordSet) {
415
+ const chunkInfo = backupStatus.chunkCount ? ` (${backupStatus.chunkCount} chunk${backupStatus.chunkCount !== 1 ? 's' : ''})` : '';
416
+ checks.push(`<div class="prot-item prot-ok"><span class="prot-icon">&#10003;</span> Encrypted backup${chunkInfo}</div>`);
417
+ } else if (backupStatus.target === 'local' && !backupStatus.passwordSet) {
418
+ checks.push('<div class="prot-item prot-warn"><span class="prot-icon">&#9888;</span> Encryption password not set</div>');
419
+ }
420
+ } else {
421
+ checks.push('<div class="prot-item prot-warn"><span class="prot-icon">&#9888;</span> No backup target configured</div>');
422
+ }
423
+ $('#protection-status').innerHTML = `<div class="box prot-box"><div class="box-header">Protection status</div><div class="box-body-pad">${checks.join('')}</div></div>`;
424
+
425
+ // Pending changes
426
+ const banner = $('#pending-banner');
427
+ if (!gs.clean) {
428
+ const fileList = (gs.files || []).slice(0, 5).map(f => {
429
+ let cls = 'cb-m', label = 'M';
430
+ if (f.working_dir === '?' || f.index === '?') { cls = 'cb-a'; label = 'A'; }
431
+ else if (f.working_dir === 'D' || f.index === 'D') { cls = 'cb-d'; label = 'D'; }
432
+ return `<span class="change-badge ${cls}">${label}</span> <span class="pending-path">${esc(f.path)}</span>`;
433
+ }).join('<span class="pending-sep">&middot;</span>');
434
+ const more = gs.total > 5 ? `<span class="pending-more">+${gs.total - 5} more</span>` : '';
435
+ banner.innerHTML = `<div class="pending-banner">
436
+ <div class="pending-left"><span class="pending-dot"></span><strong>${gs.total} unsaved change${gs.total !== 1 ? 's' : ''}</strong></div>
437
+ <div class="pending-files">${fileList}${more}</div>
438
+ <button class="btn btn-primary btn-sm" onclick="takeSnap()">Backup now</button>
439
+ </div>`;
440
+ } else {
441
+ banner.innerHTML = '';
442
+ }
443
+
444
+ // Recent changes (last 5)
445
+ const entries = await api('log', 'limit=5');
446
+ $('#recent-count').textContent = entries.length;
447
+ if (!entries.length) {
448
+ $('#recent-body').innerHTML = '<div class="empty">No backups yet. Make some changes and back up.</div>';
449
+ } else {
450
+ const viewAll = `<div class="view-all"><a href="#" onclick="switchTab('history');return false">View all &rarr;</a></div>`;
451
+ $('#recent-body').innerHTML = entries.map((e, i) => commitRow(e, i === 0, false)).join('') + viewAll;
452
+ }
453
+
454
+ $('#last-updated').textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
455
+ }
456
+
457
+
458
+ /* ═══ HISTORY ═══ */
459
+ let historyDetailHash = null;
460
+
461
+ async function loadHistory() {
462
+ historyDetailHash = null;
463
+ if (compareMode) toggleCompareMode();
464
+ const detail = $('#history-detail-view');
465
+ if (detail) detail.classList.add('hidden');
466
+ const list = $('#history-list');
467
+ if (list) list.classList.remove('hidden');
468
+ const result = $('#compare-result');
469
+ if (result) result.classList.add('hidden');
470
+
471
+ const entries = await api('log', 'limit=100');
472
+ $('#history-count').textContent = entries.length;
473
+ if (!entries.length) { $('#history-body').innerHTML = '<div class="empty">No backups yet</div>'; return; }
474
+ $('#history-body').innerHTML = entries.map((e, i) => commitRow(e, i === 0, true)).join('');
475
+ }
476
+
477
+ function commitRow(e, isLatest, clickable) {
478
+ const short = e.hash.substring(0, 7);
479
+ const onclick = clickable ? ` onclick="onCompareClick('${e.hash}', this)"` : '';
480
+ const date = new Date(e.date);
481
+ const fullDate = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
482
+ return `
483
+ <div class="commit-row"${onclick}>
484
+ <div class="commit-timeline"><div class="commit-dot ${isLatest ? 'latest' : 'old'}"></div><div class="commit-line"></div></div>
485
+ <div class="commit-body">
486
+ <div class="commit-msg">${esc(e.message)}</div>
487
+ <div class="commit-meta">
488
+ <span class="commit-hash">${short}</span>
489
+ <span>${timeAgo(e.date)}</span>
490
+ <span class="commit-fulldate">${fullDate}</span>
491
+ </div>
492
+ </div>
493
+ </div>`;
494
+ }
495
+
496
+ async function showBackupDetail(hash) {
497
+ historyDetailHash = hash;
498
+
499
+ // Switch to history tab if not already there
500
+ const activeTab = $('.nav-item.active');
501
+ if (activeTab && activeTab.dataset.tab !== 'history') {
502
+ switchTab('history');
503
+ }
504
+
505
+ // Hide list, show detail
506
+ const list = $('#history-list');
507
+ if (list) list.classList.add('hidden');
508
+ let detail = $('#history-detail-view');
509
+ if (!detail) {
510
+ const d = document.createElement('div');
511
+ d.id = 'history-detail-view';
512
+ $('#tab-history').appendChild(d);
513
+ detail = d;
514
+ }
515
+ detail.classList.remove('hidden');
516
+ detail.innerHTML = '<div class="empty" style="padding:60px">Loading backup...</div>';
517
+
518
+ const [meta, diffData] = await Promise.all([
519
+ api('commit', 'hash=' + hash),
520
+ api('commit/diff', 'hash=' + hash),
521
+ ]);
522
+
523
+ const short = hash.substring(0, 7);
524
+
525
+ detail.innerHTML = `
526
+ <div class="commit-detail-page">
527
+ <button class="btn btn-ghost commit-back" onclick="loadHistory()">&#8592; Back to history</button>
528
+ <div class="cdp-header">
529
+ <h2 class="cdp-message">${esc(meta.message || 'Backup ' + short)}</h2>
530
+ <div class="cdp-meta">
531
+ <span class="commit-hash">${short}</span>
532
+ <span>${meta.date ? timeAgo(meta.date) : ''}</span>
533
+ </div>
534
+ <div class="cdp-summary">
535
+ ${meta.files ? meta.files.length + ' file' + (meta.files.length !== 1 ? 's' : '') + ' changed' : ''}
536
+ ${meta.summary ? ' &middot; ' + esc(meta.summary) : ''}
537
+ </div>
538
+ <div class="cdp-actions">
539
+ <button class="btn btn-ghost" onclick="browseAtCommit('${hash}')">Browse files at this point</button>
540
+ <button class="btn btn-danger" onclick="confirmRestore('${hash}')">Restore to this backup</button>
541
+ </div>
542
+ </div>
543
+ <div class="cdp-diff">
544
+ ${renderDiffSections(diffData.diff || '')}
545
+ </div>
546
+ </div>`;
547
+ }
548
+
549
+
550
+ /* ═══ BACKUP TAB ═══ */
551
+ async function loadBackup() {
552
+ const [backupStatus, repoSize, passwordStatus] = await Promise.all([
553
+ api('backup/status').catch(() => ({})),
554
+ api('backup/repo-size').catch(() => ({})),
555
+ api('backup/has-password').catch(() => ({ set: false })),
556
+ ]);
557
+
558
+ const container = $('#backup-content');
559
+ let html = '';
560
+
561
+ // Password setup card (for local targets or when no target yet)
562
+ if (!backupStatus.target || backupStatus.target === 'local') {
563
+ const pwSet = passwordStatus.set || backupStatus.passwordSet;
564
+ html += `<div class="box">
565
+ <div class="box-header">Encryption</div>
566
+ <div class="box-body-pad">
567
+ ${pwSet
568
+ ? '<div class="prot-item prot-ok"><span class="prot-icon">&#10003;</span> Encryption password set &mdash; backups are encrypted with AES-256-GCM</div>'
569
+ : `<p class="target-prompt">Set an encryption password to protect your backups. Your password is never stored &mdash; only a hash for verification.</p>
570
+ <div class="export-form">
571
+ <input class="modal-input" id="backup-password" type="password" placeholder="Encryption password">
572
+ <button class="btn btn-primary" onclick="doSetBackupPassword()">Set password</button>
573
+ </div>`
574
+ }
575
+ </div>
576
+ </div>`;
577
+ }
578
+
579
+ if (backupStatus.target) {
580
+ // Configured: show status card
581
+ const syncLabel = backupStatus.lastSync ? timeAgo(backupStatus.lastSync) : 'never';
582
+ const autoSync = backupStatus.autoSync ? 'On' : 'Off';
583
+
584
+ html += `<div class="box" style="margin-top:16px">
585
+ <div class="box-header">Backup target</div>
586
+ <div class="box-body-pad">
587
+ <div class="target-status">
588
+ <div class="target-info">
589
+ <div class="target-type">${esc(backupStatus.target.charAt(0).toUpperCase() + backupStatus.target.slice(1))} &mdash; ${esc(backupStatus.targetLabel || '')}</div>
590
+ <div class="target-detail">Last sync: ${syncLabel} &middot; Auto-sync: ${autoSync}</div>
591
+ ${backupStatus.chunkCount ? `<div class="target-detail">Chunks: ${backupStatus.chunkCount}${backupStatus.workspaceId ? ' &middot; Workspace: ' + esc(backupStatus.workspaceId) : ''}</div>` : ''}
592
+ </div>
593
+ <div class="target-actions">
594
+ <button class="btn btn-primary btn-sm" onclick="doSync()">Sync now</button>
595
+ <button class="btn btn-ghost btn-sm" onclick="doTest()">Test connection</button>
596
+ <button class="btn btn-ghost btn-sm" onclick="showSetTarget()">Change target</button>
597
+ ${backupStatus.chunkCount > 10 ? '<button class="btn btn-ghost btn-sm" onclick="doCompact()">Compact</button>' : ''}
598
+ </div>
599
+ </div>
600
+ </div>
601
+ </div>`;
602
+ } else {
603
+ // Not configured: show target selection
604
+ html += `<div class="box" style="margin-top:16px">
605
+ <div class="box-header">Backup target</div>
606
+ <div class="box-body-pad">
607
+ <p class="target-prompt">Choose where to back up your data.</p>
608
+ <div class="target-cards">
609
+ <div class="target-card" onclick="showSetTarget('local')">
610
+ <div class="target-card-icon">&#128193;</div>
611
+ <div class="target-card-title">Local path</div>
612
+ <div class="target-card-desc">Encrypted backup to a local folder, NAS, or external drive</div>
613
+ </div>
614
+ <div class="target-card" onclick="showSetTarget('git')">
615
+ <div class="target-card-icon">&#128268;</div>
616
+ <div class="target-card-title">Git remote</div>
617
+ <div class="target-card-desc">Push to a remote git repository</div>
618
+ </div>
619
+ <div class="target-card disabled">
620
+ <div class="target-card-icon">&#9729;</div>
621
+ <div class="target-card-title">Cloud</div>
622
+ <div class="target-card-desc">Coming soon</div>
623
+ </div>
624
+ <div class="target-card disabled">
625
+ <div class="target-card-icon">&#9741;</div>
626
+ <div class="target-card-title">S3</div>
627
+ <div class="target-card-desc">Coming soon</div>
628
+ </div>
629
+ </div>
630
+ </div>
631
+ </div>`;
632
+ }
633
+
634
+ container.innerHTML = html;
635
+ }
636
+
637
+ function showSetTarget(type) {
638
+ if (type === 'local') {
639
+ showModal(`
640
+ <h3>Set local backup target</h3>
641
+ <p>Enter the path where encrypted backups will be stored. All data is AES-256-GCM encrypted before writing.</p>
642
+ <input class="modal-input" id="target-path" type="text" placeholder="/mnt/nas/backups/my-project" autofocus>
643
+ <div class="modal-actions">
644
+ <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
645
+ <button class="btn btn-primary" onclick="doSetTarget('local')">Set target</button>
646
+ </div>
647
+ `);
648
+ setTimeout(() => { const el = $('#target-path'); if (el) el.focus(); }, 50);
649
+ } else if (type === 'git') {
650
+ showModal(`
651
+ <h3>Set git remote target</h3>
652
+ <p>Enter the URL of the remote repository.</p>
653
+ <input class="modal-input" id="target-url" type="text" placeholder="git@github.com:user/repo.git" autofocus>
654
+ <div class="modal-actions">
655
+ <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
656
+ <button class="btn btn-primary" onclick="doSetTarget('git')">Set target</button>
657
+ </div>
658
+ `);
659
+ setTimeout(() => { const el = $('#target-url'); if (el) el.focus(); }, 50);
660
+ } else {
661
+ // Show choice modal (when changing target)
662
+ showModal(`
663
+ <h3>Change backup target</h3>
664
+ <p>Select a backup target type.</p>
665
+ <div class="modal-actions" style="flex-direction:column;gap:8px;align-items:stretch">
666
+ <button class="btn btn-ghost" onclick="closeModal();showSetTarget('local')">Local path</button>
667
+ <button class="btn btn-ghost" onclick="closeModal();showSetTarget('git')">Git remote</button>
668
+ </div>
669
+ <div class="modal-actions" style="margin-top:12px">
670
+ <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
671
+ </div>
672
+ `);
673
+ }
674
+ }
675
+
676
+ async function doSetTarget(type) {
677
+ let options = {};
678
+ if (type === 'local') {
679
+ const path = $('#target-path').value.trim();
680
+ if (!path) { toast('Path is required'); return; }
681
+ options = { path };
682
+ } else if (type === 'git') {
683
+ const url = $('#target-url').value.trim();
684
+ if (!url) { toast('URL is required'); return; }
685
+ options = { url };
686
+ }
687
+ closeModal();
688
+ toast('Setting backup target...');
689
+ try {
690
+ const r = await api('backup/set-target', 'type=' + encodeURIComponent(type) + '&options=' + encodeURIComponent(JSON.stringify(options)));
691
+ if (r.error) { toast('Error: ' + esc(r.error)); return; }
692
+ toast('Backup target set', true);
693
+ loadBackup();
694
+ } catch (e) { toast('Error: ' + esc(e.message)); }
695
+ }
696
+
697
+ async function doSync() {
698
+ // Check if local target — need password
699
+ const status = await api('backup/status').catch(() => ({}));
700
+ if (status.target === 'local') {
701
+ showModal(`
702
+ <h3>Sync backup</h3>
703
+ <p>Enter your encryption password to sync.</p>
704
+ <input class="modal-input" id="sync-password" type="password" placeholder="Encryption password" autofocus>
705
+ <div class="modal-actions">
706
+ <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
707
+ <button class="btn btn-primary" onclick="runSync()">Sync</button>
708
+ </div>
709
+ `);
710
+ setTimeout(() => { const el = $('#sync-password'); if (el) el.focus(); }, 50);
711
+ return;
712
+ }
713
+ runSyncDirect();
714
+ }
715
+
716
+ async function runSync() {
717
+ const password = $('#sync-password').value.trim();
718
+ if (!password) { toast('Password required'); return; }
719
+ closeModal();
720
+ toast('Syncing...');
721
+ try {
722
+ const r = await api('backup/sync', 'password=' + encodeURIComponent(password));
723
+ if (r.error) { toast('Sync failed: ' + esc(r.error)); return; }
724
+ if (r.synced === false) { toast(r.message || 'Already up to date', true); return; }
725
+ toast('Synced successfully' + (r.chunkCount ? ' (' + r.chunkCount + ' chunks)' : ''), true);
726
+ loadBackup();
727
+ } catch (e) { toast('Sync failed: ' + esc(e.message)); }
728
+ }
729
+
730
+ async function runSyncDirect() {
731
+ toast('Syncing...');
732
+ try {
733
+ const r = await api('backup/sync');
734
+ if (r.error) { toast('Sync failed: ' + esc(r.error)); return; }
735
+ toast('Synced successfully', true);
736
+ loadBackup();
737
+ } catch (e) { toast('Sync failed: ' + esc(e.message)); }
738
+ }
739
+
740
+ async function doTest() {
741
+ toast('Testing connection...');
742
+ try {
743
+ const r = await api('backup/test');
744
+ if (r.error) { toast('Test failed: ' + esc(r.error)); return; }
745
+ toast('Connection OK (' + (r.latency || 0) + 'ms)', true);
746
+ } catch (e) { toast('Test failed: ' + esc(e.message)); }
747
+ }
748
+
749
+ async function downloadExport() {
750
+ const password = $('#export-password').value;
751
+ if (!password) { toast('Password required'); return; }
752
+ const btn = $('#export-btn');
753
+ btn.disabled = true;
754
+ btn.textContent = 'Preparing...';
755
+ try {
756
+ const res = await fetch('/api/backup/export' + Q('password=' + encodeURIComponent(password)));
757
+ if (!res.ok) {
758
+ let msg = 'Export failed';
759
+ try { const d = await res.json(); msg = d.error || msg; } catch {}
760
+ toast(msg);
761
+ return;
762
+ }
763
+ const blob = await res.blob();
764
+ const url = URL.createObjectURL(blob);
765
+ const a = document.createElement('a');
766
+ a.href = url;
767
+ a.download = 'backup.clawkeep.enc';
768
+ document.body.appendChild(a);
769
+ a.click();
770
+ a.remove();
771
+ URL.revokeObjectURL(url);
772
+ toast('Export downloaded', true);
773
+ } catch (e) { toast('Export failed: ' + esc(e.message)); }
774
+ finally {
775
+ btn.disabled = false;
776
+ btn.textContent = 'Download encrypted backup';
777
+ }
778
+ }
779
+
780
+ async function doSetBackupPassword() {
781
+ const el = $('#backup-password');
782
+ const password = el ? el.value.trim() : '';
783
+ if (!password) { toast('Password is required'); return; }
784
+ toast('Setting password...');
785
+ try {
786
+ const r = await api('backup/set-password', 'password=' + encodeURIComponent(password));
787
+ if (r.error) { toast('Error: ' + esc(r.error)); return; }
788
+ toast('Encryption password set', true);
789
+ loadBackup();
790
+ } catch (e) { toast('Error: ' + esc(e.message)); }
791
+ }
792
+
793
+ async function doCompact() {
794
+ showModal(`
795
+ <h3>Compact backup</h3>
796
+ <p>This will merge all incremental chunks into a single full backup. Enter your encryption password to continue.</p>
797
+ <input class="modal-input" id="compact-password" type="password" placeholder="Encryption password" autofocus>
798
+ <div class="modal-actions">
799
+ <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
800
+ <button class="btn btn-primary" onclick="runCompact()">Compact</button>
801
+ </div>
802
+ `);
803
+ setTimeout(() => { const el = $('#compact-password'); if (el) el.focus(); }, 50);
804
+ }
805
+
806
+ async function runCompact() {
807
+ const password = $('#compact-password').value.trim();
808
+ if (!password) { toast('Password required'); return; }
809
+ closeModal();
810
+ toast('Compacting...');
811
+ try {
812
+ const r = await api('backup/compact', 'password=' + encodeURIComponent(password));
813
+ if (r.error) { toast('Compact failed: ' + esc(r.error)); return; }
814
+ if (r.compacted === false) { toast(r.message || 'Nothing to compact'); return; }
815
+ toast('Compacted ' + r.oldChunks + ' chunks into 1', true);
816
+ loadBackup();
817
+ } catch (e) { toast('Compact failed: ' + esc(e.message)); }
818
+ }
819
+
820
+
821
+ /* ═══ BROWSE ═══ */
822
+ let currentPath = '.';
823
+
824
+ async function loadFiles(p) {
825
+ currentPath = p;
826
+ const files = await api('files', 'path=' + encodeURIComponent(p));
827
+ if (files.error) { $('#fb-body').innerHTML = `<div class="empty">${esc(files.error)}</div>`; return; }
828
+
829
+ // Breadcrumb
830
+ const parts = [{ name: 'root', path: '.' }];
831
+ if (p !== '.') {
832
+ const segs = p.split('/').filter(Boolean);
833
+ let acc = '';
834
+ segs.forEach((s) => { acc = acc ? acc + '/' + s : s; parts.push({ name: s, path: acc }); });
835
+ }
836
+ const last = parts.length - 1;
837
+ $('#fb-breadcrumb').innerHTML = parts.map((pt, i) =>
838
+ i === last && i > 0
839
+ ? `<span class="bc-current">${esc(pt.name)}</span>`
840
+ : `<span class="bc-seg" onclick="loadFiles('${pt.path.replace(/'/g, "\\'")}')">${esc(pt.name)}</span><span class="bc-sep">/</span>`
841
+ ).join('');
842
+
843
+ // Rows
844
+ const rows = [];
845
+ if (p !== '.') {
846
+ const parent = p.split('/').slice(0, -1).join('/') || '.';
847
+ rows.push(`<div class="file-row" onclick="loadFiles('${parent.replace(/'/g, "\\'")}')">
848
+ <span class="file-icon">&#128193;</span><span class="file-name is-dir">..</span>
849
+ <span class="file-msg"></span><span class="file-time"></span></div>`);
850
+ }
851
+ files.forEach(f => {
852
+ const safePath = f.path.replace(/'/g, "\\'");
853
+ if (f.type === 'dir') {
854
+ rows.push(`<div class="file-row" data-filepath="${esc(f.path)}" onclick="loadFiles('${safePath}')">
855
+ <span class="file-icon">&#128193;</span><span class="file-name is-dir">${esc(f.name)}</span>
856
+ <span class="file-msg"></span><span class="file-time"></span></div>`);
857
+ } else {
858
+ rows.push(`<div class="file-row" data-filepath="${esc(f.path)}" onclick="viewFile('${safePath}')">
859
+ <span class="file-icon">${fIcon(f.name)}</span>
860
+ <span class="file-name">${esc(f.name)}</span>
861
+ <span class="file-msg"></span>
862
+ <span class="file-time">${fmtSize(f.size)}</span></div>`);
863
+ }
864
+ });
865
+ $('#fb-body').innerHTML = rows.join('');
866
+ $('#file-viewer').innerHTML = '';
867
+
868
+ loadFileHistory(p);
869
+
870
+ const readme = files.find(f => /^readme\.md$/i.test(f.name) && f.type === 'file');
871
+ const readmeContainer = $('#readme-render');
872
+ if (readme) {
873
+ if (readmeContainer) readmeContainer.innerHTML = '<div class="empty" style="padding:24px">Loading README...</div>';
874
+ const data = await api('file', 'path=' + encodeURIComponent(readme.path));
875
+ if (readmeContainer && data.content) {
876
+ readmeContainer.innerHTML = `<div class="box readme-box">
877
+ <div class="box-header"><span class="file-icon">&#128221;</span> ${esc(readme.name)}</div>
878
+ <div class="md-render">${renderMarkdown(data.content)}</div>
879
+ </div>`;
880
+ }
881
+ } else if (readmeContainer) {
882
+ readmeContainer.innerHTML = '';
883
+ }
884
+ }
885
+
886
+ async function loadFileHistory(p) {
887
+ try {
888
+ const history = await api('file-history', 'path=' + encodeURIComponent(p === '.' ? '' : p));
889
+ if (!history || history.error) return;
890
+
891
+ $$('#fb-body .file-row[data-filepath]').forEach(row => {
892
+ const fp = row.getAttribute('data-filepath');
893
+ const h = history[fp] || history[p === '.' ? fp : p + '/' + fp.split('/').pop()];
894
+ if (h) {
895
+ const msgEl = row.querySelector('.file-msg');
896
+ const timeEl = row.querySelector('.file-time');
897
+ if (msgEl) msgEl.textContent = h.message || '';
898
+ if (timeEl) timeEl.textContent = timeAgo(h.date);
899
+ }
900
+ });
901
+ } catch {}
902
+ }
903
+
904
+ /* ═══ BROWSE AT BACKUP (TIME TRAVEL) ═══ */
905
+ let timeTravelHash = null;
906
+
907
+ function browseAtCommit(hash) {
908
+ timeTravelHash = hash;
909
+ switchTab('browse');
910
+ loadFilesAtCommit(hash, '');
911
+ }
912
+
913
+ function exitTimeTravel() {
914
+ timeTravelHash = null;
915
+ loadFiles('.');
916
+ }
917
+
918
+ async function loadFilesAtCommit(hash, dir) {
919
+ const short = hash.substring(0, 7);
920
+ const files = await api('files-at', 'hash=' + encodeURIComponent(hash) + '&path=' + encodeURIComponent(dir));
921
+ if (files.error) { $('#fb-body').innerHTML = `<div class="empty">${esc(files.error)}</div>`; return; }
922
+
923
+ const ttBar = `<div class="time-travel-bar">
924
+ Browsing at backup <span class="tt-hash">${short}</span>
925
+ <button class="btn btn-ghost" onclick="exitTimeTravel()">&#10005; Exit</button>
926
+ </div>`;
927
+
928
+ // Breadcrumb
929
+ const parts = [{ name: 'root', path: '' }];
930
+ if (dir) {
931
+ const segs = dir.split('/').filter(Boolean);
932
+ let acc = '';
933
+ segs.forEach((s) => { acc = acc ? acc + '/' + s : s; parts.push({ name: s, path: acc }); });
934
+ }
935
+ const last = parts.length - 1;
936
+ $('#fb-breadcrumb').innerHTML = parts.map((pt, i) =>
937
+ i === last && i > 0
938
+ ? `<span class="bc-current">${esc(pt.name)}</span>`
939
+ : `<span class="bc-seg" onclick="loadFilesAtCommit('${hash}','${pt.path.replace(/'/g, "\\'")}')">${esc(pt.name)}</span><span class="bc-sep">/</span>`
940
+ ).join('');
941
+
942
+ // Rows
943
+ const rows = [];
944
+ if (dir) {
945
+ const parent = dir.split('/').slice(0, -1).join('/');
946
+ rows.push(`<div class="file-row" onclick="loadFilesAtCommit('${hash}','${parent.replace(/'/g, "\\'")}')">
947
+ <span class="file-icon">&#128193;</span><span class="file-name is-dir">..</span>
948
+ <span class="file-msg"></span><span class="file-time"></span></div>`);
949
+ }
950
+ files.forEach(f => {
951
+ const safePath = f.path.replace(/'/g, "\\'");
952
+ if (f.type === 'dir') {
953
+ rows.push(`<div class="file-row" onclick="loadFilesAtCommit('${hash}','${safePath}')">
954
+ <span class="file-icon">&#128193;</span><span class="file-name is-dir">${esc(f.name)}</span>
955
+ <span class="file-msg"></span><span class="file-time"></span></div>`);
956
+ } else {
957
+ rows.push(`<div class="file-row" onclick="viewFileAtCommit('${hash}','${safePath}')">
958
+ <span class="file-icon">${fIcon(f.name)}</span>
959
+ <span class="file-name">${esc(f.name)}</span>
960
+ <span class="file-msg"></span><span class="file-time"></span></div>`);
961
+ }
962
+ });
963
+ $('#fb-body').innerHTML = ttBar + rows.join('');
964
+ $('#file-viewer').innerHTML = '';
965
+ $('#readme-render').innerHTML = '';
966
+ }
967
+
968
+ async function viewFileAtCommit(hash, p) {
969
+ const data = await api('file-at', 'hash=' + encodeURIComponent(hash) + '&path=' + encodeURIComponent(p));
970
+ if (!data || data.error) { $('#file-viewer').innerHTML = `<div class="box"><div class="box-body-pad empty">${esc((data && data.error) || 'Not found')}</div></div>`; return; }
971
+ if (data.binary) { $('#file-viewer').innerHTML = `<div class="box"><div class="fv-header"><span class="fv-path">${esc(p)}</span></div><div class="box-body-pad empty">Binary file</div></div>`; return; }
972
+
973
+ const short = hash.substring(0, 7);
974
+ const close = `<button class="fv-close" onclick="$('#file-viewer').innerHTML=''" title="Close">&#10005;</button>`;
975
+ const isMd = p.endsWith('.md');
976
+ const lang = getLang(p);
977
+ const lines = (data.content || '').split('\n');
978
+ const lineCount = lines.length;
979
+
980
+ let body;
981
+ if (isMd) {
982
+ body = `<div class="md-render">${renderMarkdown(data.content)}</div>`;
983
+ } else {
984
+ const tableRows = lines.map((line, i) => {
985
+ const num = i + 1;
986
+ const highlighted = highlight(line, lang);
987
+ return `<tr id="L${num}" class="code-row"><td class="ln">${num}</td><td class="code-line">${highlighted || ' '}</td></tr>`;
988
+ }).join('');
989
+ body = `<div class="code-scroll"><table class="code-table"><tbody>${tableRows}</tbody></table></div>`;
990
+ }
991
+
992
+ $('#file-viewer').innerHTML = `<div class="box">
993
+ <div class="fv-header">
994
+ <span class="fv-path">${esc(p)} <span style="color:var(--t4)">@ ${short}</span></span>
995
+ <div class="fv-meta">
996
+ <span>${lineCount} lines</span>
997
+ ${close}
998
+ </div>
999
+ </div>
1000
+ ${body}
1001
+ </div>`;
1002
+ $('#file-viewer').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1003
+ }
1004
+
1005
+
1006
+ function fIcon(n) {
1007
+ const e = n.split('.').pop().toLowerCase();
1008
+ return { md:'&#128221;', json:'&#128203;', js:'&#128220;', ts:'&#128220;', py:'&#128013;', yml:'&#9881;', yaml:'&#9881;', env:'&#128274;', sh:'&#9881;', css:'&#127912;', html:'&#127760;' }[e] || '&#128196;';
1009
+ }
1010
+
1011
+ async function viewFile(p) {
1012
+ const data = await api('file', 'path=' + encodeURIComponent(p));
1013
+ if (data.error) { $('#file-viewer').innerHTML = `<div class="box"><div class="box-body-pad empty">${esc(data.error)}</div></div>`; return; }
1014
+ if (data.binary) { $('#file-viewer').innerHTML = `<div class="box"><div class="fv-header"><span class="fv-path">${esc(p)}</span></div><div class="box-body-pad empty">Binary file &middot; ${fmtSize(data.size)}</div></div>`; return; }
1015
+
1016
+ const close = `<button class="fv-close" onclick="$('#file-viewer').innerHTML=''" title="Close">&#10005;</button>`;
1017
+ const isMd = p.endsWith('.md');
1018
+ const lang = getLang(p);
1019
+ const lines = (data.content || '').split('\n');
1020
+ const lineCount = lines.length;
1021
+
1022
+ let body;
1023
+ if (isMd) {
1024
+ body = `<div class="md-render">${renderMarkdown(data.content)}</div>`;
1025
+ } else {
1026
+ const tableRows = lines.map((line, i) => {
1027
+ const num = i + 1;
1028
+ const highlighted = highlight(line, lang);
1029
+ return `<tr id="L${num}" class="code-row"><td class="ln" onclick="highlightLine(${num})">${num}</td><td class="code-line">${highlighted || ' '}</td></tr>`;
1030
+ }).join('');
1031
+ body = `<div class="code-scroll"><table class="code-table"><tbody>${tableRows}</tbody></table></div>`;
1032
+ }
1033
+
1034
+ $('#file-viewer').innerHTML = `<div class="box">
1035
+ <div class="fv-header">
1036
+ <span class="fv-path">${esc(p)}</span>
1037
+ <div class="fv-meta">
1038
+ <span>${lineCount} lines</span>
1039
+ <span>${fmtSize(data.size)}</span>
1040
+ ${close}
1041
+ </div>
1042
+ </div>
1043
+ ${body}
1044
+ </div>`;
1045
+ $('#file-viewer').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1046
+ }
1047
+
1048
+ function highlightLine(num) {
1049
+ $$('.code-row.highlighted').forEach(r => r.classList.remove('highlighted'));
1050
+ const row = document.getElementById('L' + num);
1051
+ if (row) row.classList.add('highlighted');
1052
+ }
1053
+
1054
+
1055
+ /* ═══ MODAL ═══ */
1056
+ function showModal(html) {
1057
+ $('#modal').innerHTML = html;
1058
+ $('#modal-overlay').classList.remove('hidden');
1059
+ $('#modal').classList.remove('hidden');
1060
+ }
1061
+
1062
+ function closeModal() {
1063
+ $('#modal-overlay').classList.add('hidden');
1064
+ $('#modal').classList.add('hidden');
1065
+ }
1066
+
1067
+ /* ═══ RESTORE ═══ */
1068
+ function confirmRestore(hash) {
1069
+ const short = hash.substring(0, 7);
1070
+ showModal(`
1071
+ <h3>Restore to backup</h3>
1072
+ <p>This will revert all files to backup <span class="modal-hash">${short}</span> and create a new backup. Your current state will still be in history.</p>
1073
+ <div class="modal-actions">
1074
+ <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
1075
+ <button class="btn btn-danger" onclick="doRestore('${hash}')">Restore</button>
1076
+ </div>
1077
+ `);
1078
+ }
1079
+
1080
+ async function doRestore(hash) {
1081
+ closeModal();
1082
+ const short = hash.substring(0, 7);
1083
+ toast('Restoring to ' + short + '...');
1084
+ try {
1085
+ const r = await api('restore', 'hash=' + hash);
1086
+ if (r.error) { toast('Error: ' + r.error); return; }
1087
+ await loadDashboard();
1088
+ toast('Restored to ' + short + '. New backup created.', true);
1089
+ loadHistory();
1090
+ } catch (e) { toast('Restore failed: ' + e.message); }
1091
+ }
1092
+
1093
+ /* ═══ COMPARE ═══ */
1094
+ let compareMode = false;
1095
+ let compareFrom = null;
1096
+ let compareTo = null;
1097
+
1098
+ function toggleCompareMode() {
1099
+ compareMode = !compareMode;
1100
+ compareFrom = null;
1101
+ compareTo = null;
1102
+ const toggle = $('#compare-toggle');
1103
+ const bar = $('#compare-bar');
1104
+ const result = $('#compare-result');
1105
+
1106
+ if (compareMode) {
1107
+ toggle.textContent = 'Cancel';
1108
+ toggle.classList.add('btn-danger');
1109
+ toggle.classList.remove('btn-ghost');
1110
+ bar.classList.remove('hidden');
1111
+ bar.innerHTML = 'Select the <strong>base</strong> backup...';
1112
+ result.classList.add('hidden');
1113
+ $('#history-body').classList.add('compare-mode');
1114
+ } else {
1115
+ toggle.textContent = 'Compare';
1116
+ toggle.classList.remove('btn-danger');
1117
+ toggle.classList.add('btn-ghost');
1118
+ bar.classList.add('hidden');
1119
+ result.classList.add('hidden');
1120
+ $('#history-body').classList.remove('compare-mode');
1121
+ $$('#history-body .commit-row.selected').forEach(r => r.classList.remove('selected'));
1122
+ }
1123
+ }
1124
+
1125
+ function onCompareClick(hash, el) {
1126
+ if (!compareMode) { showBackupDetail(hash); return; }
1127
+
1128
+ if (!compareFrom) {
1129
+ compareFrom = hash;
1130
+ el.classList.add('selected');
1131
+ $('#compare-bar').innerHTML = `Base: <span class="commit-hash">${hash.substring(0, 7)}</span> &mdash; now select the <strong>target</strong> backup`;
1132
+ } else if (!compareTo && hash !== compareFrom) {
1133
+ compareTo = hash;
1134
+ el.classList.add('selected');
1135
+ runCompare();
1136
+ }
1137
+ }
1138
+
1139
+ async function runCompare() {
1140
+ const bar = $('#compare-bar');
1141
+ const fromShort = compareFrom.substring(0, 7);
1142
+ const toShort = compareTo.substring(0, 7);
1143
+ bar.innerHTML = `Comparing <span class="commit-hash">${fromShort}</span> &rarr; <span class="commit-hash">${toShort}</span> <button class="btn btn-ghost btn-sm" onclick="toggleCompareMode()" style="margin-left:auto">Clear</button>`;
1144
+
1145
+ const result = $('#compare-result');
1146
+ result.classList.remove('hidden');
1147
+ result.innerHTML = '<div class="empty" style="padding:40px">Loading diff...</div>';
1148
+
1149
+ try {
1150
+ const data = await api('compare', 'from=' + compareFrom + '&to=' + compareTo);
1151
+ if (data.error) { result.innerHTML = `<div class="empty">${esc(data.error)}</div>`; return; }
1152
+ result.innerHTML = `<div class="box"><div class="box-header">Changes between ${fromShort} and ${toShort}</div><div class="box-body">${renderDiffSections(data.diff || '')}</div></div>`;
1153
+ } catch (e) {
1154
+ result.innerHTML = `<div class="empty">Compare failed: ${esc(e.message)}</div>`;
1155
+ }
1156
+ }
1157
+
1158
+ /* ═══ ACTIONS ═══ */
1159
+ async function refresh() { await loadDashboard(); toast('Refreshed', true); }
1160
+
1161
+ function takeSnap() {
1162
+ showModal(`
1163
+ <h3>Create backup</h3>
1164
+ <p>Give this backup a name, or leave empty for auto-generated.</p>
1165
+ <input class="modal-input" id="snap-msg" type="text" placeholder="e.g. before risky deploy" autofocus>
1166
+ <div class="modal-actions">
1167
+ <button class="btn btn-ghost" onclick="closeModal()">Cancel</button>
1168
+ <button class="btn btn-primary" onclick="doSnap()">Backup</button>
1169
+ </div>
1170
+ `);
1171
+ setTimeout(() => { const el = $('#snap-msg'); if (el) el.focus(); }, 50);
1172
+ }
1173
+
1174
+ async function doSnap() {
1175
+ const msgEl = $('#snap-msg');
1176
+ const msg = msgEl ? msgEl.value.trim() : '';
1177
+ closeModal();
1178
+ const btn = document.querySelector('.topbar .btn-primary');
1179
+ if (btn) { btn.innerHTML = '&#8987; Backing up...'; btn.disabled = true; }
1180
+ try {
1181
+ const q = msg ? 'message=' + encodeURIComponent(msg) : '';
1182
+ const r = await api('snap', q);
1183
+ await loadDashboard();
1184
+ toast(r.hash ? `Backup ${r.hash.substring(0, 7)} created` : 'Nothing to back up', true);
1185
+ } catch (e) { toast('Error: ' + e.message); }
1186
+ if (btn) { btn.innerHTML = '&#10010; Backup now'; btn.disabled = false; }
1187
+ }
1188
+
1189
+ /* Init */
1190
+ loadDashboard();
1191
+ setInterval(loadDashboard, 30000);