git-watchtower 1.10.1 → 1.10.2

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.
@@ -0,0 +1,1446 @@
1
+ /**
2
+ * Client-side JavaScript for the Git Watchtower web dashboard.
3
+ * Contains all interactive behavior: SSE connection, rendering, keyboard
4
+ * navigation, modals, notifications, and preferences.
5
+ * @module server/web-ui/js
6
+ */
7
+
8
+ /**
9
+ * Get the dashboard client-side JavaScript.
10
+ * @returns {string} JavaScript content (without script tags)
11
+ */
12
+ function getDashboardJs() {
13
+ return `
14
+ (function() {
15
+ 'use strict';
16
+
17
+ // ── State ──────────────────────────────────────────────────────
18
+ var state = null;
19
+ var prevBranches = null; // for notification diffing
20
+ var selectedIndex = 0;
21
+ var searchMode = false;
22
+ var searchQuery = '';
23
+ var confirmMode = false;
24
+ var confirmCallback = null;
25
+ var connected = false;
26
+ var flashTimer = null;
27
+ var activeTabId = null;
28
+ var logViewerMode = false;
29
+ var logViewerTab = 'server';
30
+ var branchActionMode = false;
31
+ var infoMode = false;
32
+ var cleanupMode = false;
33
+ var updateMode = false;
34
+ var stashMode = false;
35
+ var pendingStashBranch = null;
36
+ var updateNotificationShown = false;
37
+
38
+ // ── Persistent Preferences (localStorage) ─────────────────────
39
+ var PREFS_KEY = 'git-watchtower-prefs';
40
+ function loadPrefs() {
41
+ try {
42
+ return JSON.parse(localStorage.getItem(PREFS_KEY)) || {};
43
+ } catch (e) { return {}; }
44
+ }
45
+ function savePrefs(updates) {
46
+ var prefs = loadPrefs();
47
+ for (var k in updates) { if (updates.hasOwnProperty(k)) prefs[k] = updates[k]; }
48
+ try { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); } catch (e) { /* ignore */ }
49
+ return prefs;
50
+ }
51
+ var prefs = loadPrefs();
52
+ var sidebarCollapsed = prefs.sidebarCollapsed || false;
53
+ var sortOrder = prefs.sortOrder || 'default';
54
+ var pinnedBranches = prefs.pinnedBranches || [];
55
+
56
+ // Apply initial sidebar state
57
+ (function() {
58
+ var layout = document.querySelector('.layout');
59
+ if (sidebarCollapsed) layout.classList.add('sidebar-collapsed');
60
+ })();
61
+
62
+ // ── Browser Notifications ─────────────────────────────────────
63
+ var notifPermission = typeof Notification !== 'undefined' ? Notification.permission : 'denied';
64
+
65
+ function updateNotifButton() {
66
+ var btn = document.getElementById('notif-btn');
67
+ if (notifPermission === 'granted') {
68
+ btn.className = 'notif-btn granted';
69
+ btn.textContent = 'notifs on';
70
+ } else if (notifPermission === 'denied') {
71
+ btn.className = 'notif-btn denied';
72
+ btn.textContent = 'notifs blocked';
73
+ } else {
74
+ btn.className = 'notif-btn';
75
+ btn.textContent = 'notifications';
76
+ }
77
+ }
78
+ updateNotifButton();
79
+
80
+ document.getElementById('notif-btn').addEventListener('click', function() {
81
+ if (notifPermission === 'granted' || notifPermission === 'denied') return;
82
+ if (typeof Notification === 'undefined') {
83
+ showToast('Notifications not supported in this browser', 'warning');
84
+ return;
85
+ }
86
+ Notification.requestPermission().then(function(perm) {
87
+ notifPermission = perm;
88
+ updateNotifButton();
89
+ if (perm === 'granted') {
90
+ showToast('Desktop notifications enabled', 'success');
91
+ }
92
+ });
93
+ });
94
+
95
+ function sendNotification(title, body, tag) {
96
+ if (notifPermission !== 'granted') return;
97
+ try {
98
+ var n = new Notification(title, { body: body, tag: tag || 'git-watchtower', icon: '', silent: false });
99
+ setTimeout(function() { n.close(); }, 8000);
100
+ } catch (e) { /* ignore */ }
101
+ }
102
+
103
+ function diffBranchesForNotifications(oldBranches, newBranches) {
104
+ if (!oldBranches || !newBranches) return;
105
+ var oldMap = {};
106
+ for (var i = 0; i < oldBranches.length; i++) {
107
+ oldMap[oldBranches[i].name] = oldBranches[i];
108
+ }
109
+ for (var j = 0; j < newBranches.length; j++) {
110
+ var nb = newBranches[j];
111
+ var ob = oldMap[nb.name];
112
+ if (!ob && nb.isNew) {
113
+ sendNotification('New Branch', nb.name + ' was created', 'new-' + nb.name);
114
+ } else if (ob && !ob.justUpdated && nb.justUpdated) {
115
+ sendNotification('Branch Updated', nb.name + ' has new commits', 'update-' + nb.name);
116
+ }
117
+ }
118
+ // Check PR state changes
119
+ if (state && state.branchPrStatusMap) {
120
+ for (var bn in state.branchPrStatusMap) {
121
+ if (!state.branchPrStatusMap.hasOwnProperty(bn)) continue;
122
+ var pr = state.branchPrStatusMap[bn];
123
+ if (pr && pr.state === 'MERGED') {
124
+ // Only notify once - check if it was not merged before
125
+ var oldBranch = oldMap[bn];
126
+ if (oldBranch) {
127
+ sendNotification('PR Merged', 'PR #' + pr.number + ' for ' + bn + ' was merged', 'merged-' + bn);
128
+ }
129
+ }
130
+ }
131
+ }
132
+ }
133
+
134
+ // ── Clipboard Helper ──────────────────────────────────────────
135
+ function copyToClipboard(text, btnEl) {
136
+ navigator.clipboard.writeText(text).then(function() {
137
+ if (btnEl) {
138
+ btnEl.classList.add('copied');
139
+ btnEl.innerHTML = '&#x2713;';
140
+ setTimeout(function() {
141
+ btnEl.classList.remove('copied');
142
+ btnEl.innerHTML = '&#x1f4cb;';
143
+ }, 1500);
144
+ }
145
+ showToast('Copied: ' + text, 'success');
146
+ }).catch(function() {
147
+ showToast('Failed to copy', 'error');
148
+ });
149
+ }
150
+
151
+ // ── URL Building Helpers ──────────────────────────────────────
152
+ function getRepoUrl() {
153
+ return (state && state.repoWebUrl) ? state.repoWebUrl.replace(/\\/tree\\/.*$/, '') : null;
154
+ }
155
+ function getBranchUrl(branchName) {
156
+ var base = getRepoUrl();
157
+ if (!base) return null;
158
+ return base + '/tree/' + encodeURIComponent(branchName);
159
+ }
160
+ function getCommitUrl(hash) {
161
+ var base = getRepoUrl();
162
+ if (!base || !hash) return null;
163
+ return base + '/commit/' + hash;
164
+ }
165
+ function getPrUrl(prNumber) {
166
+ var base = getRepoUrl();
167
+ if (!base || !prNumber) return null;
168
+ // Detect GitLab by URL pattern
169
+ if (base.indexOf('gitlab') !== -1) {
170
+ return base + '/-/merge_requests/' + prNumber;
171
+ }
172
+ return base + '/pull/' + prNumber;
173
+ }
174
+
175
+ // ── SSE Connection ─────────────────────────────────────────────
176
+ var evtSource = null;
177
+
178
+ function connect() {
179
+ if (evtSource) { evtSource.close(); }
180
+ evtSource = new EventSource('/api/events');
181
+
182
+ evtSource.onopen = function() {
183
+ connected = true;
184
+ updateConnectionStatus();
185
+ };
186
+
187
+ evtSource.addEventListener('state', function(e) {
188
+ try {
189
+ var newState = JSON.parse(e.data);
190
+ // Diff branches for desktop notifications
191
+ if (state && state.branches) {
192
+ diffBranchesForNotifications(state.branches, newState.branches || []);
193
+ }
194
+ prevBranches = state ? state.branches : null;
195
+ state = newState;
196
+ if (!activeTabId && state.activeProjectId) {
197
+ activeTabId = state.activeProjectId;
198
+ }
199
+ renderTabs();
200
+ render();
201
+ } catch (err) { /* ignore parse errors */ }
202
+ });
203
+
204
+ evtSource.addEventListener('flash', function(e) {
205
+ try {
206
+ var data = JSON.parse(e.data);
207
+ showFlash(data.text, data.type);
208
+ } catch (err) { /* ignore */ }
209
+ });
210
+
211
+ evtSource.addEventListener('actionResult', function(e) {
212
+ try {
213
+ var data = JSON.parse(e.data);
214
+ if (!data.success && data.message && data.message.indexOf('uncommitted') !== -1) {
215
+ pendingStashBranch = data.branch || null;
216
+ showErrorToastWithHint(data.message, 'Press S to stash');
217
+ } else {
218
+ showToast(data.message, data.success ? 'success' : 'error');
219
+ }
220
+ } catch (err) { /* ignore */ }
221
+ });
222
+
223
+ evtSource.onerror = function() {
224
+ connected = false;
225
+ updateConnectionStatus();
226
+ };
227
+ }
228
+
229
+ function updateConnectionStatus() {
230
+ var dot = document.getElementById('connection-dot');
231
+ var badge = document.getElementById('status-badge');
232
+ if (connected) {
233
+ dot.className = 'connection-dot connected';
234
+ badge.className = 'badge badge-online';
235
+ badge.textContent = 'live';
236
+ } else {
237
+ dot.className = 'connection-dot disconnected';
238
+ badge.className = 'badge badge-offline';
239
+ badge.textContent = 'reconnecting';
240
+ }
241
+ }
242
+
243
+ // ── Actions ────────────────────────────────────────────────────
244
+ function sendAction(action, payload) {
245
+ var xhr = new XMLHttpRequest();
246
+ xhr.open('POST', '/api/action');
247
+ xhr.setRequestHeader('Content-Type', 'application/json');
248
+ var data = { action: action, payload: payload || {} };
249
+ if (activeTabId) data.projectId = activeTabId;
250
+ xhr.send(JSON.stringify(data));
251
+ }
252
+
253
+ // ── Flash Messages ─────────────────────────────────────────────
254
+ function showFlash(text, type) {
255
+ var el = document.getElementById('flash');
256
+ el.textContent = text;
257
+ el.className = 'flash visible ' + (type || 'info');
258
+ clearTimeout(flashTimer);
259
+ flashTimer = setTimeout(function() {
260
+ el.className = 'flash';
261
+ }, 3000);
262
+ }
263
+
264
+ // ── Toast Notifications ────────────────────────────────────────
265
+ function showToast(text, type) {
266
+ var container = document.getElementById('toast-container');
267
+ var toast = document.createElement('div');
268
+ var icons = { success: '\\u2713', error: '\\u2717', info: '\\u2139', warning: '\\u26a0' };
269
+ toast.className = 'toast ' + (type || 'info');
270
+ toast.innerHTML = '<span class="toast-icon">' + (icons[type] || icons.info) + '</span>' + escHtml(text);
271
+ container.appendChild(toast);
272
+ requestAnimationFrame(function() {
273
+ requestAnimationFrame(function() { toast.classList.add('visible'); });
274
+ });
275
+ setTimeout(function() {
276
+ toast.classList.remove('visible');
277
+ setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
278
+ }, 4000);
279
+ }
280
+
281
+ // ── Confirm Dialog ─────────────────────────────────────────────
282
+ function showConfirm(title, message, onConfirm, opts) {
283
+ opts = opts || {};
284
+ confirmMode = true;
285
+ confirmCallback = onConfirm;
286
+ var box = document.getElementById('confirm-box');
287
+ box.innerHTML =
288
+ '<div class="confirm-title">' + escHtml(title) + '</div>' +
289
+ '<div class="confirm-message">' + escHtml(message) + '</div>' +
290
+ '<div class="confirm-actions">' +
291
+ '<button class="confirm-btn" id="confirm-cancel">Cancel</button>' +
292
+ '<button class="confirm-btn ' + (opts.danger ? 'danger' : 'primary') + '" id="confirm-ok">' +
293
+ escHtml(opts.label || 'Confirm') +
294
+ '</button>' +
295
+ '</div>';
296
+ document.getElementById('confirm-overlay').className = 'confirm-overlay active';
297
+ document.getElementById('confirm-cancel').onclick = hideConfirm;
298
+ document.getElementById('confirm-ok').onclick = function() {
299
+ hideConfirm();
300
+ if (confirmCallback) confirmCallback();
301
+ };
302
+ }
303
+
304
+ function hideConfirm() {
305
+ confirmMode = false;
306
+ confirmCallback = null;
307
+ document.getElementById('confirm-overlay').className = 'confirm-overlay';
308
+ }
309
+
310
+ // ── Tabs ───────────────────────────────────────────────────────
311
+ function renderTabs() {
312
+ var tabBar = document.getElementById('tab-bar');
313
+ var projects = (state && state.projects) || [];
314
+ if (projects.length <= 1) {
315
+ tabBar.className = 'tab-bar';
316
+ return;
317
+ }
318
+ tabBar.className = 'tab-bar visible';
319
+ // Adjust layout height for tab bar
320
+ document.querySelector('.layout').style.height = 'calc(100vh - 49px - 40px)';
321
+ var html = '';
322
+ for (var i = 0; i < projects.length; i++) {
323
+ var p = projects[i];
324
+ var isActive = p.id === activeTabId;
325
+ html += '<div class="tab' + (isActive ? ' active' : '') + '" data-project-id="' + escHtml(p.id) + '">';
326
+ html += '<span class="tab-dot"></span>';
327
+ html += escHtml(p.name);
328
+ if (i < 9) html += '<span class="tab-number">' + (i + 1) + '</span>';
329
+ html += '</div>';
330
+ }
331
+ tabBar.innerHTML = html;
332
+ }
333
+
334
+ function switchTab(projectId) {
335
+ if (projectId === activeTabId) return;
336
+ activeTabId = projectId;
337
+ selectedIndex = 0;
338
+ searchQuery = '';
339
+ searchMode = false;
340
+ document.getElementById('search-bar').className = 'search-bar';
341
+ document.getElementById('search-input').value = '';
342
+ renderTabs();
343
+ // Fetch the project's state
344
+ var xhr = new XMLHttpRequest();
345
+ xhr.open('GET', '/api/projects/' + projectId + '/state');
346
+ xhr.onload = function() {
347
+ if (xhr.status === 200) {
348
+ try {
349
+ var pState = JSON.parse(xhr.responseText);
350
+ // Merge into current state for rendering
351
+ state.branches = pState.branches || [];
352
+ state.currentBranch = pState.currentBranch;
353
+ state.activityLog = pState.activityLog || [];
354
+ state.switchHistory = pState.switchHistory || [];
355
+ state.sparklineCache = pState.sparklineCache || {};
356
+ state.branchPrStatusMap = pState.branchPrStatusMap || {};
357
+ state.aheadBehindCache = pState.aheadBehindCache || {};
358
+ state.projectName = pState.projectName || '';
359
+ state.pollingStatus = pState.pollingStatus || 'idle';
360
+ state.isOffline = pState.isOffline || false;
361
+ state.serverMode = pState.serverMode || 'none';
362
+ render();
363
+ } catch (err) { /* ignore */ }
364
+ }
365
+ };
366
+ xhr.send();
367
+ }
368
+
369
+ // ── Time Formatting ────────────────────────────────────────────
370
+ function timeAgo(dateStr) {
371
+ if (!dateStr) return '';
372
+ var ts = new Date(dateStr).getTime();
373
+ if (isNaN(ts)) return '';
374
+ var diff = Date.now() - ts;
375
+ if (diff < 0) return 'now';
376
+ var s = Math.floor(diff / 1000);
377
+ if (s < 60) return s + 's ago';
378
+ var m = Math.floor(s / 60);
379
+ if (m < 60) return m + 'm ago';
380
+ var h = Math.floor(m / 60);
381
+ if (h < 24) return h + 'h ago';
382
+ var d = Math.floor(h / 24);
383
+ return d + 'd ago';
384
+ }
385
+
386
+ // ── Sparkline Rendering ────────────────────────────────────────
387
+ function renderSparklineBars(sparkStr) {
388
+ if (!sparkStr) return '';
389
+ var chars = '\\u2581\\u2582\\u2583\\u2584\\u2585\\u2586\\u2587\\u2588';
390
+ var html = '<div class="sparkline-bar">';
391
+ for (var i = 0; i < sparkStr.length; i++) {
392
+ var ch = sparkStr[i];
393
+ var idx = chars.indexOf(ch);
394
+ if (idx < 0) {
395
+ html += '<div class="spark-bar" style="height:1px"></div>';
396
+ } else {
397
+ var pct = Math.round(((idx + 1) / 8) * 100);
398
+ html += '<div class="spark-bar" style="height:' + pct + '%"></div>';
399
+ }
400
+ }
401
+ html += '</div>';
402
+ return html;
403
+ }
404
+
405
+ // ── Compact number ─────────────────────────────────────────────
406
+ function fmtCompact(n) {
407
+ if (n < 1000) return String(n);
408
+ if (n < 10000) return (n / 1000).toFixed(1) + 'k';
409
+ if (n < 1000000) return Math.round(n / 1000) + 'k';
410
+ return (n / 1000000).toFixed(1) + 'm';
411
+ }
412
+
413
+ // ── Get Display Branches ───────────────────────────────────────
414
+ function getDisplayBranches() {
415
+ if (!state || !state.branches) return [];
416
+ var branches = state.branches.slice();
417
+ if (searchQuery) {
418
+ var q = searchQuery.toLowerCase();
419
+ branches = branches.filter(function(b) {
420
+ return b.name.toLowerCase().indexOf(q) !== -1;
421
+ });
422
+ }
423
+ // Pin branches to top
424
+ if (pinnedBranches.length > 0) {
425
+ var pinSet = {};
426
+ for (var i = 0; i < pinnedBranches.length; i++) pinSet[pinnedBranches[i]] = true;
427
+ branches.sort(function(a, b) {
428
+ var aPin = pinSet[a.name] ? 1 : 0;
429
+ var bPin = pinSet[b.name] ? 1 : 0;
430
+ return bPin - aPin; // pinned first
431
+ });
432
+ }
433
+ // Sort
434
+ if (sortOrder === 'alpha') {
435
+ var pinSet2 = {};
436
+ for (var j = 0; j < pinnedBranches.length; j++) pinSet2[pinnedBranches[j]] = true;
437
+ branches.sort(function(a, b) {
438
+ // Pinned branches always first
439
+ var aPin = pinSet2[a.name] ? 1 : 0;
440
+ var bPin = pinSet2[b.name] ? 1 : 0;
441
+ if (aPin !== bPin) return bPin - aPin;
442
+ return a.name.localeCompare(b.name);
443
+ });
444
+ } else if (sortOrder === 'recent') {
445
+ var pinSet3 = {};
446
+ for (var k = 0; k < pinnedBranches.length; k++) pinSet3[pinnedBranches[k]] = true;
447
+ branches.sort(function(a, b) {
448
+ var aPin = pinSet3[a.name] ? 1 : 0;
449
+ var bPin = pinSet3[b.name] ? 1 : 0;
450
+ if (aPin !== bPin) return bPin - aPin;
451
+ var aDate = a.date ? new Date(a.date).getTime() : 0;
452
+ var bDate = b.date ? new Date(b.date).getTime() : 0;
453
+ return bDate - aDate;
454
+ });
455
+ }
456
+ return branches;
457
+ }
458
+
459
+ // ── Render ─────────────────────────────────────────────────────
460
+ function render() {
461
+ if (!state) return;
462
+
463
+ // Header — hide project name pill when tabs are showing it
464
+ var projectEl = document.getElementById('project-name');
465
+ var hasTabs = state.projects && state.projects.length > 1;
466
+ if (hasTabs) {
467
+ projectEl.style.display = 'none';
468
+ } else {
469
+ projectEl.style.display = '';
470
+ projectEl.textContent = state.projectName || '-';
471
+ }
472
+ var versionEl = document.getElementById('version');
473
+ if (state.version) versionEl.textContent = 'v' + state.version;
474
+
475
+ // Status badge
476
+ if (connected) {
477
+ var badge = document.getElementById('status-badge');
478
+ if (state.isOffline) {
479
+ badge.className = 'badge badge-offline';
480
+ badge.textContent = 'offline';
481
+ } else if (state.pollingStatus === 'fetching') {
482
+ badge.className = 'badge badge-fetching';
483
+ badge.textContent = 'fetching';
484
+ } else {
485
+ badge.className = 'badge badge-online';
486
+ badge.textContent = 'live';
487
+ }
488
+ }
489
+
490
+ renderBranches();
491
+ renderActivityLog();
492
+ renderSessionStats();
493
+ renderPrefsBar();
494
+
495
+ // Auto-show update notification (once per session)
496
+ if (state.updateAvailable && !updateNotificationShown && !anyModalOpen()) {
497
+ updateNotificationShown = true;
498
+ showUpdateModal();
499
+ }
500
+
501
+ // Update log viewer if open
502
+ if (logViewerMode) renderLogViewer();
503
+ }
504
+
505
+ function renderBranches() {
506
+ var container = document.getElementById('branch-list');
507
+ var branches = getDisplayBranches();
508
+ var countEl = document.getElementById('branch-count');
509
+ countEl.textContent = branches.length;
510
+
511
+ if (selectedIndex >= branches.length) {
512
+ selectedIndex = Math.max(0, branches.length - 1);
513
+ }
514
+
515
+ if (branches.length === 0) {
516
+ container.innerHTML = '<div class="empty-state">' +
517
+ '<div class="empty-state-icon">&#x1f33f;</div>' +
518
+ (searchQuery ? 'No branches matching "' + escHtml(searchQuery) + '"' : 'No branches found') +
519
+ '</div>';
520
+ return;
521
+ }
522
+
523
+ var html = '';
524
+ for (var i = 0; i < branches.length; i++) {
525
+ var b = branches[i];
526
+ var isSelected = i === selectedIndex;
527
+ var isCurrent = b.name === state.currentBranch;
528
+
529
+ // Sparkline
530
+ var sparkStr = state.sparklineCache ? state.sparklineCache[b.name] : null;
531
+
532
+ // PR status
533
+ var prStatus = state.branchPrStatusMap ? state.branchPrStatusMap[b.name] : null;
534
+ var isMerged = prStatus && prStatus.state === 'MERGED';
535
+
536
+ // Ahead/behind
537
+ var ab = state.aheadBehindCache ? state.aheadBehindCache[b.name] : null;
538
+
539
+ var itemClasses = 'branch-item';
540
+ if (isSelected) itemClasses += ' selected';
541
+ if (isCurrent) itemClasses += ' current';
542
+ if (isMerged) itemClasses += ' merged';
543
+
544
+ html += '<div class="' + itemClasses + '" data-index="' + i + '">';
545
+ if (isCurrent) {
546
+ html += '<span class="branch-current-icon">&#x25cf;</span>';
547
+ }
548
+ html += '<span class="branch-cursor">&#x25b6;</span>';
549
+ html += '<div class="branch-info">';
550
+ html += '<div class="branch-name-row">';
551
+ // Branch name - clickable link to GitHub/GitLab
552
+ var branchUrl = getBranchUrl(b.name);
553
+ var isPinned = pinnedBranches.indexOf(b.name) !== -1;
554
+ html += '<span class="branch-name">';
555
+ if (branchUrl) {
556
+ html += '<a href="' + escHtml(branchUrl) + '" target="_blank" rel="noopener" title="Open on web" onclick="event.stopPropagation()">' + escHtml(b.name) + '</a>';
557
+ } else {
558
+ html += escHtml(b.name);
559
+ }
560
+ html += '</span>';
561
+ // Copy branch name button
562
+ html += '<button class="copy-btn" data-copy="' + escHtml(b.name) + '" title="Copy branch name" onclick="event.stopPropagation()">&#x1f4cb;</button>';
563
+ html += '</div>'; // branch-name-row
564
+
565
+ html += '<div class="branch-meta">';
566
+ // Commit hash - clickable link
567
+ var commitUrl = getCommitUrl(b.commit);
568
+ html += '<span class="branch-commit">';
569
+ if (commitUrl) {
570
+ html += '<a href="' + escHtml(commitUrl) + '" target="_blank" rel="noopener" title="View commit" onclick="event.stopPropagation()">' + escHtml(b.commit || '') + '</a>';
571
+ } else {
572
+ html += escHtml(b.commit || '');
573
+ }
574
+ html += '</span>';
575
+ // Copy commit hash
576
+ if (b.commit) {
577
+ html += '<button class="copy-btn" data-copy="' + escHtml(b.commit) + '" title="Copy commit hash" onclick="event.stopPropagation()">&#x1f4cb;</button>';
578
+ }
579
+ html += '<span class="branch-subject">' + escHtml(b.subject || '') + '</span>';
580
+ html += '</div>'; // branch-meta
581
+ html += '</div>'; // branch-info
582
+
583
+ html += '<div class="branch-right">';
584
+ // Badges
585
+ var badges = '';
586
+ if (isCurrent) badges += '<span class="branch-current-badge">HEAD</span>';
587
+ if (isPinned) badges += '<span class="branch-new-badge" style="color:var(--orange);background:rgba(219,109,40,0.15)">pinned</span>';
588
+ if (b.isNew) badges += '<span class="branch-new-badge">new</span>';
589
+ if (b.isDeleted) badges += '<span class="branch-deleted-badge">deleted</span>';
590
+ if (b.justUpdated) badges += '<span class="branch-updated-badge">updated</span>';
591
+ if (prStatus) {
592
+ var prClass = prStatus.state === 'OPEN' ? 'pr-open' : prStatus.state === 'MERGED' ? 'pr-merged' : 'pr-closed';
593
+ var prUrl = getPrUrl(prStatus.number);
594
+ badges += '<span class="pr-badge ' + prClass + '">';
595
+ if (prUrl) badges += '<a href="' + escHtml(prUrl) + '" target="_blank" rel="noopener" onclick="event.stopPropagation()">';
596
+ badges += (prStatus.state === 'MERGED' ? 'merged' : 'PR #' + prStatus.number);
597
+ if (prUrl) badges += '</a>';
598
+ badges += '</span>';
599
+ if (prUrl) badges += '<button class="copy-btn" data-copy="' + escHtml(prUrl) + '" title="Copy PR URL" onclick="event.stopPropagation()">&#x1f4cb;</button>';
600
+ }
601
+ html += '<div class="branch-time-row">';
602
+ html += '<span class="branch-time">' + timeAgo(b.date) + '</span>';
603
+ if (badges) html += '<div class="branch-badges">' + badges + '</div>';
604
+ html += '</div>';
605
+ if (ab && (ab.ahead || ab.behind)) {
606
+ html += '<div class="branch-diff">';
607
+ html += '<span class="diff-added">+' + fmtCompact(ab.ahead || 0) + '</span>';
608
+ html += '<span class="diff-deleted">-' + fmtCompact(ab.behind || 0) + '</span>';
609
+ html += '<span class="diff-label">commits</span>';
610
+ if (ab.linesAdded || ab.linesDeleted) {
611
+ html += ' <span class="diff-added">+' + fmtCompact(ab.linesAdded || 0) + '</span>';
612
+ html += '<span class="diff-deleted">-' + fmtCompact(ab.linesDeleted || 0) + '</span>';
613
+ html += '<span class="diff-label">lines</span>';
614
+ }
615
+ html += '</div>';
616
+ }
617
+ html += renderSparklineBars(sparkStr);
618
+ html += '</div>';
619
+ html += '</div>'; // branch-item
620
+ }
621
+
622
+ container.innerHTML = html;
623
+
624
+ // Scroll selected into view
625
+ var selected = container.querySelector('.branch-item.selected');
626
+ if (selected) {
627
+ selected.scrollIntoView({ block: 'nearest' });
628
+ }
629
+ }
630
+
631
+ function renderActivityLog() {
632
+ var container = document.getElementById('activity-log');
633
+ var log = (state && state.activityLog) || [];
634
+ if (log.length === 0) {
635
+ container.innerHTML = '<div class="empty-state"><div class="empty-state-icon">&#x1f4cb;</div>No activity yet</div>';
636
+ return;
637
+ }
638
+ var html = '';
639
+ for (var i = 0; i < log.length; i++) {
640
+ var entry = log[i];
641
+ var t = '';
642
+ if (entry.timestamp) {
643
+ var d = new Date(entry.timestamp);
644
+ t = isNaN(d.getTime()) ? '' : d.toLocaleTimeString();
645
+ }
646
+ html += '<div class="log-entry">';
647
+ html += '<span class="log-dot ' + (entry.type || 'info') + '"></span>';
648
+ html += '<span class="log-text">' + escHtml(entry.message) + '</span>';
649
+ html += '<span class="log-time">' + t + '</span>';
650
+ html += '</div>';
651
+ }
652
+ container.innerHTML = html;
653
+ }
654
+
655
+ // ── Log Viewer ─────────────────────────────────────────────────
656
+ function showLogViewer() {
657
+ logViewerMode = true;
658
+ logViewerTab = 'server';
659
+ renderLogViewer();
660
+ document.getElementById('log-viewer-overlay').className = 'modal-overlay active';
661
+ }
662
+
663
+ function hideLogViewer() {
664
+ logViewerMode = false;
665
+ document.getElementById('log-viewer-overlay').className = 'modal-overlay';
666
+ }
667
+
668
+ function renderLogViewer() {
669
+ if (!state) return;
670
+ var container = document.getElementById('log-viewer-content');
671
+ // Update tab active state
672
+ var tabs = document.querySelectorAll('.log-viewer-tab');
673
+ for (var t = 0; t < tabs.length; t++) {
674
+ tabs[t].className = 'log-viewer-tab' + (tabs[t].getAttribute('data-tab') === logViewerTab ? ' active' : '');
675
+ }
676
+
677
+ var html = '';
678
+ if (logViewerTab === 'server') {
679
+ var logs = state.serverLogBuffer || [];
680
+ if (logs.length === 0) {
681
+ html = '<div style="color:var(--text-muted);padding:20px;text-align:center;">No server logs</div>';
682
+ } else {
683
+ for (var i = 0; i < logs.length; i++) {
684
+ var log = logs[i];
685
+ html += '<div class="log-line' + (log.isError ? ' error' : '') + '">';
686
+ html += '<span class="log-ts">' + escHtml(log.timestamp || '') + '</span>';
687
+ html += escHtml(log.line || '');
688
+ html += '</div>';
689
+ }
690
+ }
691
+ } else {
692
+ var alog = (state.activityLog || []);
693
+ if (alog.length === 0) {
694
+ html = '<div style="color:var(--text-muted);padding:20px;text-align:center;">No activity</div>';
695
+ } else {
696
+ for (var j = 0; j < alog.length; j++) {
697
+ var entry = alog[j];
698
+ var ts = entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString() : '';
699
+ html += '<div class="log-line">';
700
+ html += '<span class="log-ts">' + ts + '</span>';
701
+ html += escHtml(entry.message || '');
702
+ html += '</div>';
703
+ }
704
+ }
705
+ }
706
+ container.innerHTML = html;
707
+ container.scrollTop = container.scrollHeight;
708
+ }
709
+
710
+ document.getElementById('log-viewer-tabs').addEventListener('click', function(e) {
711
+ var tab = e.target.closest('.log-viewer-tab');
712
+ if (!tab) return;
713
+ logViewerTab = tab.getAttribute('data-tab');
714
+ renderLogViewer();
715
+ });
716
+
717
+ document.getElementById('log-viewer-close').addEventListener('click', hideLogViewer);
718
+ document.getElementById('log-viewer-overlay').addEventListener('click', function(e) {
719
+ if (e.target === this) hideLogViewer();
720
+ });
721
+
722
+ // ── Branch Action Modal ────────────────────────────────────────
723
+ function showBranchActions() {
724
+ var branches = getDisplayBranches();
725
+ if (!branches.length || selectedIndex >= branches.length) return;
726
+ var branch = branches[selectedIndex];
727
+ branchActionMode = true;
728
+ document.getElementById('branch-action-title').textContent = 'Actions: ' + branch.name;
729
+
730
+ var prStatus = (state.branchPrStatusMap || {})[branch.name];
731
+ var isCurrent = branch.name === state.currentBranch;
732
+
733
+ var actions = [];
734
+
735
+ // Open on web (GitHub/GitLab) — direct link if we have repo URL
736
+ var brUrl = getBranchUrl(branch.name);
737
+ if (brUrl) {
738
+ actions.push({ icon: '\\u{1f310}', label: 'Open branch on web', key: 'openLink', data: { url: brUrl } });
739
+ } else {
740
+ actions.push({ icon: '\\u{1f310}', label: 'Open branch on web', key: 'openBranchWeb', data: { branch: branch.name } });
741
+ }
742
+
743
+ // PR actions
744
+ var prUrl = prStatus ? getPrUrl(prStatus.number) : null;
745
+ if (prStatus && prUrl) {
746
+ actions.push({ icon: '\\u{1f517}', label: 'View PR #' + prStatus.number, key: 'openLink', data: { url: prUrl } });
747
+ } else if (prStatus && prStatus.url) {
748
+ actions.push({ icon: '\\u{1f517}', label: 'View PR #' + prStatus.number, key: 'openPrUrl', data: { url: prStatus.url } });
749
+ }
750
+
751
+ // Copy actions
752
+ actions.push({ icon: '\\u{1f4cb}', label: 'Copy branch name', key: 'copy', data: { text: branch.name } });
753
+ if (branch.commit) {
754
+ actions.push({ icon: '\\u{1f4cb}', label: 'Copy commit hash (' + branch.commit + ')', key: 'copy', data: { text: branch.commit } });
755
+ }
756
+ if (prUrl) {
757
+ actions.push({ icon: '\\u{1f4cb}', label: 'Copy PR URL', key: 'copy', data: { text: prUrl } });
758
+ }
759
+
760
+ // Pin/Unpin
761
+ var isPinnedBranch = pinnedBranches.indexOf(branch.name) !== -1;
762
+ actions.push({ icon: isPinnedBranch ? '\\u{1f4cc}' : '\\u{1f4cc}', label: isPinnedBranch ? 'Unpin branch' : 'Pin branch to top', key: 'pin', data: { branch: branch.name } });
763
+
764
+ // Switch to branch
765
+ if (!isCurrent) {
766
+ actions.push({ icon: '\\u{27a1}', label: 'Switch to this branch', key: 'switchBranch', data: { branch: branch.name } });
767
+ }
768
+
769
+ // Pull
770
+ if (isCurrent) {
771
+ actions.push({ icon: '\\u{2b07}', label: 'Pull latest changes', key: 'pull', data: {} });
772
+ }
773
+
774
+ // Fetch
775
+ actions.push({ icon: '\\u{1f504}', label: 'Fetch all remotes', key: 'fetch', data: {} });
776
+
777
+ var html = '';
778
+ for (var i = 0; i < actions.length; i++) {
779
+ var a = actions[i];
780
+ html += '<button class="action-item" data-action-key="' + escHtml(a.key) + '" data-action-data=\\'' + escHtml(JSON.stringify(a.data)) + '\\'>';
781
+ html += '<span class="action-icon">' + a.icon + '</span>';
782
+ html += '<span class="action-label">' + escHtml(a.label) + '</span>';
783
+ html += '</button>';
784
+ }
785
+ document.getElementById('branch-action-list').innerHTML = html;
786
+ document.getElementById('branch-action-overlay').className = 'modal-overlay active';
787
+ }
788
+
789
+ function hideBranchActions() {
790
+ branchActionMode = false;
791
+ document.getElementById('branch-action-overlay').className = 'modal-overlay';
792
+ }
793
+
794
+ document.getElementById('branch-action-close').addEventListener('click', hideBranchActions);
795
+ document.getElementById('branch-action-overlay').addEventListener('click', function(e) {
796
+ if (e.target === this) hideBranchActions();
797
+ });
798
+
799
+ document.getElementById('branch-action-list').addEventListener('click', function(e) {
800
+ var btn = e.target.closest('.action-item');
801
+ if (!btn) return;
802
+ var key = btn.getAttribute('data-action-key');
803
+ var data = {};
804
+ try { data = JSON.parse(btn.getAttribute('data-action-data') || '{}'); } catch (err) { /* ignore */ }
805
+
806
+ hideBranchActions();
807
+
808
+ if (key === 'openLink') {
809
+ // Direct client-side link opening
810
+ window.open(data.url, '_blank', 'noopener');
811
+ showToast('Opening in browser...', 'info');
812
+ } else if (key === 'copy') {
813
+ copyToClipboard(data.text, null);
814
+ } else if (key === 'pin') {
815
+ var pIdx = pinnedBranches.indexOf(data.branch);
816
+ if (pIdx === -1) {
817
+ pinnedBranches.push(data.branch);
818
+ showToast('Pinned: ' + data.branch, 'success');
819
+ } else {
820
+ pinnedBranches.splice(pIdx, 1);
821
+ showToast('Unpinned: ' + data.branch, 'info');
822
+ }
823
+ savePrefs({ pinnedBranches: pinnedBranches });
824
+ renderBranches();
825
+ } else if (key === 'openBranchWeb' || key === 'openPrUrl') {
826
+ // Fallback: handled by the server sending back a URL
827
+ sendAction('openBrowser', data);
828
+ showToast('Opening in browser...', 'info');
829
+ } else if (key === 'switchBranch') {
830
+ sendAction('switchBranch', data);
831
+ showToast('Switching to ' + data.branch + '...', 'info');
832
+ } else {
833
+ sendAction(key, data);
834
+ showToast(key + '...', 'info');
835
+ }
836
+ });
837
+
838
+ // ── Info Panel ─────────────────────────────────────────────────
839
+ function showInfo() {
840
+ if (!state) return;
841
+ infoMode = true;
842
+ var grid = document.getElementById('info-grid');
843
+ var rows = [
844
+ ['Project', state.projectName || '-'],
845
+ ['Version', 'v' + (state.version || '-')],
846
+ ['Server Mode', state.serverMode || 'none'],
847
+ ['Server Port', state.noServer ? 'N/A' : String(state.port || '-')],
848
+ ['Server Running', state.serverRunning ? 'Yes' : 'No'],
849
+ ['SSE Clients', String(state.clientCount || 0)],
850
+ ['Current Branch', state.currentBranch || '-'],
851
+ ['Polling Status', state.pollingStatus || 'idle'],
852
+ ['Network', state.isOffline ? 'Offline' : 'Online'],
853
+ ['Branches', String((state.branches || []).length)],
854
+ ];
855
+ var html = '';
856
+ for (var i = 0; i < rows.length; i++) {
857
+ html += '<span class="info-label">' + escHtml(rows[i][0]) + '</span>';
858
+ html += '<span class="info-value">' + escHtml(rows[i][1]) + '</span>';
859
+ }
860
+ grid.innerHTML = html;
861
+ document.getElementById('info-overlay').className = 'modal-overlay active';
862
+ }
863
+
864
+ function hideInfo() {
865
+ infoMode = false;
866
+ document.getElementById('info-overlay').className = 'modal-overlay';
867
+ }
868
+
869
+ document.getElementById('info-close').addEventListener('click', hideInfo);
870
+ document.getElementById('info-overlay').addEventListener('click', function(e) {
871
+ if (e.target === this) hideInfo();
872
+ });
873
+
874
+ // ── Stash Management ───────────────────────────────────────────
875
+ function showStashDialog(pendingBranch) {
876
+ stashMode = true;
877
+ pendingStashBranch = pendingBranch || null;
878
+ var msg = pendingBranch
879
+ ? 'You have uncommitted changes. Stash them before switching to <strong>' + escHtml(pendingBranch) + '</strong>?'
880
+ : 'Stash all uncommitted changes in the working directory?';
881
+ var html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:16px;">' + msg + '</div>';
882
+ html += '<div class="confirm-actions">';
883
+ html += '<button class="confirm-btn" id="stash-cancel">Cancel</button>';
884
+ html += '<button class="confirm-btn primary" id="stash-confirm">Stash &amp; Continue</button>';
885
+ html += '</div>';
886
+ document.getElementById('stash-content').innerHTML = html;
887
+ document.getElementById('stash-overlay').className = 'modal-overlay active';
888
+ document.getElementById('stash-cancel').onclick = hideStash;
889
+ document.getElementById('stash-confirm').onclick = function() {
890
+ sendAction('stash', { pendingBranch: pendingStashBranch });
891
+ showToast('Stashing changes...', 'info');
892
+ hideStash();
893
+ };
894
+ }
895
+
896
+ function hideStash() {
897
+ stashMode = false;
898
+ pendingStashBranch = null;
899
+ document.getElementById('stash-overlay').className = 'modal-overlay';
900
+ }
901
+
902
+ document.getElementById('stash-close').addEventListener('click', hideStash);
903
+ document.getElementById('stash-overlay').addEventListener('click', function(e) {
904
+ if (e.target === this) hideStash();
905
+ });
906
+
907
+ // ── Branch Cleanup ─────────────────────────────────────────────
908
+ function showCleanup() {
909
+ cleanupMode = true;
910
+ var html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:12px;">Scanning for branches with deleted remotes...</div>';
911
+ document.getElementById('cleanup-content').innerHTML = html;
912
+ document.getElementById('cleanup-overlay').className = 'modal-overlay active';
913
+
914
+ // Ask the server to find gone branches (we inspect state.branches for gone tracking hints)
915
+ // For now, look at branches that have no remote
916
+ var goneBranches = [];
917
+ if (state && state.branches) {
918
+ for (var i = 0; i < state.branches.length; i++) {
919
+ var b = state.branches[i];
920
+ if (b.isLocal && !b.hasRemote && b.name !== state.currentBranch) {
921
+ goneBranches.push(b.name);
922
+ }
923
+ }
924
+ }
925
+
926
+ if (goneBranches.length === 0) {
927
+ html = '<div style="color:var(--text-dim);font-size:13px;padding:12px 0;">No stale branches found. All branches have active remotes.</div>';
928
+ html += '<div class="confirm-actions"><button class="confirm-btn" id="cleanup-done">OK</button></div>';
929
+ document.getElementById('cleanup-content').innerHTML = html;
930
+ document.getElementById('cleanup-done').onclick = hideCleanup;
931
+ return;
932
+ }
933
+
934
+ html = '<div style="color:var(--text-dim);font-size:13px;margin-bottom:8px;">Found ' + goneBranches.length + ' branch(es) with no remote tracking:</div>';
935
+ html += '<div class="cleanup-branch-list">';
936
+ for (var j = 0; j < goneBranches.length; j++) {
937
+ html += '<div class="cleanup-branch-item"><span class="cleanup-branch-icon">&#x2716;</span>' + escHtml(goneBranches[j]) + '</div>';
938
+ }
939
+ html += '</div>';
940
+ html += '<div class="confirm-actions">';
941
+ html += '<button class="confirm-btn" id="cleanup-cancel">Cancel</button>';
942
+ html += '<button class="confirm-btn danger" id="cleanup-safe">Safe Delete (-d)</button>';
943
+ html += '<button class="confirm-btn danger" id="cleanup-force">Force Delete (-D)</button>';
944
+ html += '</div>';
945
+
946
+ document.getElementById('cleanup-content').innerHTML = html;
947
+ document.getElementById('cleanup-cancel').onclick = hideCleanup;
948
+ document.getElementById('cleanup-safe').onclick = function() {
949
+ sendAction('deleteBranches', { branches: goneBranches, force: false });
950
+ showToast('Deleting ' + goneBranches.length + ' branches (safe)...', 'info');
951
+ hideCleanup();
952
+ };
953
+ document.getElementById('cleanup-force').onclick = function() {
954
+ showConfirm(
955
+ 'Force Delete',
956
+ 'Force delete ' + goneBranches.length + ' branch(es)? This may delete unmerged work.',
957
+ function() {
958
+ sendAction('deleteBranches', { branches: goneBranches, force: true });
959
+ showToast('Force deleting ' + goneBranches.length + ' branches...', 'warning');
960
+ hideCleanup();
961
+ },
962
+ { danger: true, label: 'Force Delete' }
963
+ );
964
+ };
965
+ }
966
+
967
+ function hideCleanup() {
968
+ cleanupMode = false;
969
+ document.getElementById('cleanup-overlay').className = 'modal-overlay';
970
+ }
971
+
972
+ document.getElementById('cleanup-close').addEventListener('click', hideCleanup);
973
+ document.getElementById('cleanup-overlay').addEventListener('click', function(e) {
974
+ if (e.target === this) hideCleanup();
975
+ });
976
+
977
+ // ── Update Notification ────────────────────────────────────────
978
+ function showUpdateModal() {
979
+ if (!state || !state.updateAvailable) return;
980
+ updateMode = true;
981
+ var html = '<div class="update-versions">';
982
+ html += '<span class="old-version">v' + escHtml(state.version || '?') + '</span>';
983
+ html += '<span class="arrow">&#x2192;</span>';
984
+ html += '<span class="new-version">v' + escHtml(state.updateAvailable) + '</span>';
985
+ html += '</div>';
986
+ html += '<div class="update-info">A new version of git-watchtower is available.</div>';
987
+ if (state.updateInProgress) {
988
+ html += '<div class="update-progress">Update in progress...</div>';
989
+ } else {
990
+ html += '<div class="confirm-actions">';
991
+ html += '<button class="confirm-btn" id="update-dismiss">Dismiss</button>';
992
+ html += '<button class="confirm-btn primary" id="update-install">Update Now</button>';
993
+ html += '</div>';
994
+ }
995
+ document.getElementById('update-content').innerHTML = html;
996
+ document.getElementById('update-overlay').className = 'modal-overlay active';
997
+ if (!state.updateInProgress) {
998
+ document.getElementById('update-dismiss').onclick = hideUpdate;
999
+ document.getElementById('update-install').onclick = function() {
1000
+ sendAction('checkUpdate', { install: true });
1001
+ showToast('Installing update...', 'info');
1002
+ hideUpdate();
1003
+ };
1004
+ }
1005
+ }
1006
+
1007
+ function hideUpdate() {
1008
+ updateMode = false;
1009
+ document.getElementById('update-overlay').className = 'modal-overlay';
1010
+ }
1011
+
1012
+ document.getElementById('update-close').addEventListener('click', hideUpdate);
1013
+ document.getElementById('update-overlay').addEventListener('click', function(e) {
1014
+ if (e.target === this) hideUpdate();
1015
+ });
1016
+
1017
+ // ── Session Stats ──────────────────────────────────────────────
1018
+ function renderSessionStats() {
1019
+ if (!state || !state.sessionStats) return;
1020
+ var s = state.sessionStats;
1021
+ var bar = document.getElementById('stats-bar');
1022
+ var activeBranches = 0;
1023
+ var staleBranches = 0;
1024
+ if (state.branches) {
1025
+ for (var i = 0; i < state.branches.length; i++) {
1026
+ var b = state.branches[i];
1027
+ // Consider stale if no updates and not current
1028
+ if (b.justUpdated || b.name === state.currentBranch) {
1029
+ activeBranches++;
1030
+ } else {
1031
+ staleBranches++;
1032
+ }
1033
+ }
1034
+ }
1035
+ var html = '';
1036
+ html += '<span class="stat-item"><span class="stat-label">Session:</span> <span class="stat-value">' + escHtml(s.sessionDuration || '0m') + '</span></span>';
1037
+ html += '<span class="stat-item"><span class="stat-label">Lines:</span> <span class="stat-value">+' + (s.linesAdded || 0) + '/-' + (s.linesDeleted || 0) + '</span></span>';
1038
+ html += '<span class="stat-item"><span class="stat-label">Polls:</span> <span class="stat-value">' + (s.totalPolls || 0) + '</span> <span class="stat-label">(' + (s.hitRate || 0) + '% hit)</span></span>';
1039
+ if (s.lastUpdate) {
1040
+ html += '<span class="stat-item"><span class="stat-label">Last update:</span> <span class="stat-value">' + escHtml(s.lastUpdate) + '</span></span>';
1041
+ }
1042
+ html += '<span class="stat-item"><span class="stat-label">Active:</span> <span class="stat-value">' + activeBranches + '</span> <span class="stat-label">Stale:</span> <span class="stat-value">' + staleBranches + '</span></span>';
1043
+ bar.innerHTML = html;
1044
+ }
1045
+
1046
+ // ── Error Toast with Stash Hint ────────────────────────────────
1047
+ function showErrorToastWithHint(message, hint) {
1048
+ var container = document.getElementById('toast-container');
1049
+ var toast = document.createElement('div');
1050
+ toast.className = 'toast error';
1051
+ var html = '<span class="toast-icon">\\u2717</span>' + escHtml(message);
1052
+ if (hint) {
1053
+ html += '<span class="toast-action" data-hint="' + escHtml(hint) + '">' + escHtml(hint) + '</span>';
1054
+ }
1055
+ toast.innerHTML = html;
1056
+ container.appendChild(toast);
1057
+ requestAnimationFrame(function() {
1058
+ requestAnimationFrame(function() { toast.classList.add('visible'); });
1059
+ });
1060
+
1061
+ // Handle hint click
1062
+ var hintEl = toast.querySelector('.toast-action');
1063
+ if (hintEl) {
1064
+ hintEl.addEventListener('click', function() {
1065
+ var h = this.getAttribute('data-hint');
1066
+ if (h === 'Press S to stash') {
1067
+ showStashDialog(pendingStashBranch);
1068
+ }
1069
+ toast.classList.remove('visible');
1070
+ setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
1071
+ });
1072
+ }
1073
+
1074
+ setTimeout(function() {
1075
+ toast.classList.remove('visible');
1076
+ setTimeout(function() { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
1077
+ }, 6000);
1078
+ }
1079
+
1080
+ // ── Any modal open check ───────────────────────────────────────
1081
+ function anyModalOpen() {
1082
+ return logViewerMode || branchActionMode || infoMode || cleanupMode || updateMode || stashMode || confirmMode;
1083
+ }
1084
+
1085
+ // ── Keyboard ───────────────────────────────────────────────────
1086
+ document.addEventListener('keydown', function(e) {
1087
+ // Ignore when typing in input fields (other than search)
1088
+ if (e.target.tagName === 'INPUT' && e.target.id !== 'search-input') return;
1089
+ if (e.target.tagName === 'BUTTON') return;
1090
+
1091
+ // Any modal — Escape to close
1092
+ if (logViewerMode && e.key === 'Escape') { e.preventDefault(); hideLogViewer(); return; }
1093
+ if (branchActionMode && e.key === 'Escape') { e.preventDefault(); hideBranchActions(); return; }
1094
+ if (infoMode && e.key === 'Escape') { e.preventDefault(); hideInfo(); return; }
1095
+ if (cleanupMode && e.key === 'Escape') { e.preventDefault(); hideCleanup(); return; }
1096
+ if (updateMode && e.key === 'Escape') { e.preventDefault(); hideUpdate(); return; }
1097
+ if (stashMode && e.key === 'Escape') { e.preventDefault(); hideStash(); return; }
1098
+
1099
+ // Log viewer tab switching
1100
+ if (logViewerMode) {
1101
+ if (e.key === 'Tab' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
1102
+ e.preventDefault();
1103
+ logViewerTab = logViewerTab === 'server' ? 'activity' : 'server';
1104
+ renderLogViewer();
1105
+ }
1106
+ return;
1107
+ }
1108
+
1109
+ // Block other keys while modals are open
1110
+ if (branchActionMode || infoMode || cleanupMode || updateMode || stashMode) return;
1111
+
1112
+ // Confirm dialog mode — Escape to cancel, Enter to confirm
1113
+ if (confirmMode) {
1114
+ if (e.key === 'Escape') { e.preventDefault(); hideConfirm(); }
1115
+ if (e.key === 'Enter') {
1116
+ e.preventDefault();
1117
+ var cb = confirmCallback;
1118
+ hideConfirm();
1119
+ if (cb) cb();
1120
+ }
1121
+ return;
1122
+ }
1123
+
1124
+ // Search mode
1125
+ if (searchMode) {
1126
+ if (e.key === 'Escape') {
1127
+ e.preventDefault();
1128
+ searchMode = false;
1129
+ searchQuery = '';
1130
+ document.getElementById('search-bar').className = 'search-bar';
1131
+ document.getElementById('search-input').value = '';
1132
+ selectedIndex = 0;
1133
+ renderBranches();
1134
+ return;
1135
+ }
1136
+ if (e.key === 'Enter') {
1137
+ e.preventDefault();
1138
+ searchMode = false;
1139
+ document.getElementById('search-bar').className = 'search-bar';
1140
+ return;
1141
+ }
1142
+ if (e.key === 'ArrowDown' || (e.key === 'j' && e.ctrlKey)) {
1143
+ e.preventDefault();
1144
+ moveSelection(1);
1145
+ return;
1146
+ }
1147
+ if (e.key === 'ArrowUp' || (e.key === 'k' && e.ctrlKey)) {
1148
+ e.preventDefault();
1149
+ moveSelection(-1);
1150
+ return;
1151
+ }
1152
+ return;
1153
+ }
1154
+
1155
+ // Tab switching with number keys (1-9)
1156
+ var projects = (state && state.projects) || [];
1157
+ if (projects.length > 1 && e.key >= '1' && e.key <= '9') {
1158
+ var tabIdx = parseInt(e.key, 10) - 1;
1159
+ if (tabIdx < projects.length) {
1160
+ e.preventDefault();
1161
+ switchTab(projects[tabIdx].id);
1162
+ return;
1163
+ }
1164
+ }
1165
+
1166
+ // Tab cycling with Tab key
1167
+ if (e.key === 'Tab' && projects.length > 1) {
1168
+ e.preventDefault();
1169
+ var curIdx = projects.findIndex(function(p) { return p.id === activeTabId; });
1170
+ var nextIdx = e.shiftKey
1171
+ ? (curIdx - 1 + projects.length) % projects.length
1172
+ : (curIdx + 1) % projects.length;
1173
+ switchTab(projects[nextIdx].id);
1174
+ return;
1175
+ }
1176
+
1177
+ // Normal mode
1178
+ switch (e.key) {
1179
+ case 'j':
1180
+ case 'ArrowDown':
1181
+ e.preventDefault();
1182
+ moveSelection(1);
1183
+ break;
1184
+ case 'k':
1185
+ case 'ArrowUp':
1186
+ e.preventDefault();
1187
+ moveSelection(-1);
1188
+ break;
1189
+ case 'Enter':
1190
+ e.preventDefault();
1191
+ var branches = getDisplayBranches();
1192
+ if (branches.length > 0 && selectedIndex < branches.length) {
1193
+ var b = branches[selectedIndex];
1194
+ if (b.isDeleted) {
1195
+ showToast('Cannot switch to a deleted branch', 'error');
1196
+ } else if (b.name === state.currentBranch) {
1197
+ showToast('Already on ' + b.name, 'info');
1198
+ } else {
1199
+ sendAction('switchBranch', { branch: b.name });
1200
+ showToast('Switching to ' + b.name + '...', 'info');
1201
+ }
1202
+ }
1203
+ break;
1204
+ case '/':
1205
+ e.preventDefault();
1206
+ searchMode = true;
1207
+ searchQuery = '';
1208
+ selectedIndex = 0;
1209
+ document.getElementById('search-bar').className = 'search-bar active';
1210
+ var input = document.getElementById('search-input');
1211
+ input.value = '';
1212
+ input.focus();
1213
+ break;
1214
+ case 'p':
1215
+ e.preventDefault();
1216
+ sendAction('pull');
1217
+ showToast('Pulling current branch...', 'info');
1218
+ break;
1219
+ case 'f':
1220
+ e.preventDefault();
1221
+ sendAction('fetch');
1222
+ showToast('Fetching all branches...', 'info');
1223
+ break;
1224
+ case 'r':
1225
+ e.preventDefault();
1226
+ if (state && state.serverMode === 'static') {
1227
+ sendAction('reloadBrowsers');
1228
+ showToast('Reloading browsers...', 'info');
1229
+ }
1230
+ break;
1231
+ case 'R':
1232
+ e.preventDefault();
1233
+ if (state && state.serverMode === 'command') {
1234
+ showConfirm(
1235
+ 'Restart Server',
1236
+ 'Restart the dev server process?',
1237
+ function() {
1238
+ sendAction('restartServer');
1239
+ showToast('Restarting server...', 'info');
1240
+ },
1241
+ { label: 'Restart' }
1242
+ );
1243
+ }
1244
+ break;
1245
+ case 'c':
1246
+ e.preventDefault();
1247
+ sendAction('toggleCasino');
1248
+ break;
1249
+ case 'o':
1250
+ e.preventDefault();
1251
+ sendAction('openBrowser');
1252
+ showToast('Opening in browser...', 'info');
1253
+ break;
1254
+ case 'h':
1255
+ e.preventDefault();
1256
+ if (state && state.switchHistory && state.switchHistory.length > 0) {
1257
+ var last = state.switchHistory[0];
1258
+ var histMsg = 'Last: ' + last.from + ' \\u2192 ' + last.to;
1259
+ if (state.switchHistory.length > 1) histMsg += ' (+' + (state.switchHistory.length - 1) + ' more)';
1260
+ showToast(histMsg, 'info');
1261
+ } else {
1262
+ showToast('No switch history yet', 'info');
1263
+ }
1264
+ break;
1265
+ case 'u':
1266
+ e.preventDefault();
1267
+ sendAction('undo');
1268
+ showToast('Undoing last switch...', 'info');
1269
+ break;
1270
+ case 's':
1271
+ e.preventDefault();
1272
+ sendAction('toggleSound');
1273
+ showToast(state && state.soundEnabled ? 'Sound off' : 'Sound on', 'info');
1274
+ break;
1275
+ case 'b':
1276
+ e.preventDefault();
1277
+ showBranchActions();
1278
+ break;
1279
+ case 'i':
1280
+ e.preventDefault();
1281
+ showInfo();
1282
+ break;
1283
+ case 'l':
1284
+ e.preventDefault();
1285
+ showLogViewer();
1286
+ break;
1287
+ case 'S':
1288
+ e.preventDefault();
1289
+ showStashDialog(null);
1290
+ break;
1291
+ case 'd':
1292
+ e.preventDefault();
1293
+ showCleanup();
1294
+ break;
1295
+ case 'Escape':
1296
+ e.preventDefault();
1297
+ break;
1298
+ }
1299
+ });
1300
+
1301
+ // Search input handler
1302
+ document.getElementById('search-input').addEventListener('input', function(e) {
1303
+ searchQuery = e.target.value;
1304
+ selectedIndex = 0;
1305
+ renderBranches();
1306
+ });
1307
+
1308
+ function moveSelection(delta) {
1309
+ var branches = getDisplayBranches();
1310
+ var newIndex = selectedIndex + delta;
1311
+ if (newIndex >= 0 && newIndex < branches.length) {
1312
+ selectedIndex = newIndex;
1313
+ renderBranches();
1314
+ }
1315
+ }
1316
+
1317
+ // ── Click Handlers ─────────────────────────────────────────────
1318
+ document.getElementById('branch-list').addEventListener('click', function(e) {
1319
+ var item = e.target.closest('.branch-item');
1320
+ if (!item) return;
1321
+ var idx = parseInt(item.getAttribute('data-index'), 10);
1322
+ if (isNaN(idx)) return;
1323
+ selectedIndex = idx;
1324
+ renderBranches();
1325
+
1326
+ // Double-click to switch with confirmation
1327
+ if (e.detail === 2) {
1328
+ var branches = getDisplayBranches();
1329
+ var br = branches[idx];
1330
+ if (br && !br.isDeleted && br.name !== state.currentBranch) {
1331
+ sendAction('switchBranch', { branch: br.name });
1332
+ showToast('Switching to ' + br.name + '...', 'info');
1333
+ }
1334
+ }
1335
+ });
1336
+
1337
+ document.getElementById('confirm-overlay').addEventListener('click', function(e) {
1338
+ if (e.target === this) hideConfirm();
1339
+ });
1340
+
1341
+ // Tab clicks
1342
+ document.getElementById('tab-bar').addEventListener('click', function(e) {
1343
+ var tab = e.target.closest('.tab');
1344
+ if (!tab) return;
1345
+ var projectId = tab.getAttribute('data-project-id');
1346
+ if (projectId) switchTab(projectId);
1347
+ });
1348
+
1349
+ // ── Preferences Bar ─────────────────────────────────────────────
1350
+ function renderPrefsBar() {
1351
+ // Insert prefs controls into footer if not already there
1352
+ var footer = document.getElementById('footer');
1353
+ var existing = document.getElementById('prefs-bar');
1354
+ if (!existing) {
1355
+ var div = document.createElement('span');
1356
+ div.id = 'prefs-bar';
1357
+ div.style.cssText = 'display:flex;gap:6px;align-items:center;margin-left:auto;';
1358
+ div.innerHTML =
1359
+ '<button class="pref-btn' + (sortOrder === 'default' ? ' active' : '') + '" data-sort="default" title="Default sort">Default</button>' +
1360
+ '<button class="pref-btn' + (sortOrder === 'alpha' ? ' active' : '') + '" data-sort="alpha" title="Sort alphabetically">A-Z</button>' +
1361
+ '<button class="pref-btn' + (sortOrder === 'recent' ? ' active' : '') + '" data-sort="recent" title="Sort by most recent">Recent</button>' +
1362
+ '<button class="pref-btn" id="pin-selected-btn" title="Pin/unpin selected branch">Pin</button>' +
1363
+ '<button class="pref-btn' + (sidebarCollapsed ? ' active' : '') + '" id="toggle-sidebar-btn" title="Toggle sidebar">Sidebar</button>';
1364
+ footer.appendChild(div);
1365
+ }
1366
+ }
1367
+
1368
+ // Prefs bar click handler
1369
+ document.getElementById('footer').addEventListener('click', function(e) {
1370
+ var sortBtn = e.target.closest('[data-sort]');
1371
+ if (sortBtn) {
1372
+ sortOrder = sortBtn.getAttribute('data-sort');
1373
+ savePrefs({ sortOrder: sortOrder });
1374
+ var sortBtns = document.querySelectorAll('[data-sort]');
1375
+ for (var i = 0; i < sortBtns.length; i++) {
1376
+ sortBtns[i].className = 'pref-btn' + (sortBtns[i].getAttribute('data-sort') === sortOrder ? ' active' : '');
1377
+ }
1378
+ renderBranches();
1379
+ return;
1380
+ }
1381
+ if (e.target.id === 'pin-selected-btn') {
1382
+ var branches = getDisplayBranches();
1383
+ if (branches.length > 0 && selectedIndex < branches.length) {
1384
+ var bn = branches[selectedIndex].name;
1385
+ var idx = pinnedBranches.indexOf(bn);
1386
+ if (idx === -1) {
1387
+ pinnedBranches.push(bn);
1388
+ showToast('Pinned: ' + bn, 'success');
1389
+ } else {
1390
+ pinnedBranches.splice(idx, 1);
1391
+ showToast('Unpinned: ' + bn, 'info');
1392
+ }
1393
+ savePrefs({ pinnedBranches: pinnedBranches });
1394
+ renderBranches();
1395
+ }
1396
+ return;
1397
+ }
1398
+ if (e.target.id === 'toggle-sidebar-btn') {
1399
+ sidebarCollapsed = !sidebarCollapsed;
1400
+ savePrefs({ sidebarCollapsed: sidebarCollapsed });
1401
+ var layout = document.querySelector('.layout');
1402
+ if (sidebarCollapsed) {
1403
+ layout.classList.add('sidebar-collapsed');
1404
+ } else {
1405
+ layout.classList.remove('sidebar-collapsed');
1406
+ }
1407
+ e.target.className = 'pref-btn' + (sidebarCollapsed ? ' active' : '');
1408
+ return;
1409
+ }
1410
+ });
1411
+
1412
+ // ── Sidebar Toggle (header) ───────────────────────────────────
1413
+ document.getElementById('sidebar-toggle').addEventListener('click', function() {
1414
+ sidebarCollapsed = !sidebarCollapsed;
1415
+ savePrefs({ sidebarCollapsed: sidebarCollapsed });
1416
+ var layout = document.querySelector('.layout');
1417
+ layout.classList.toggle('sidebar-collapsed', sidebarCollapsed);
1418
+ var btn = document.getElementById('toggle-sidebar-btn');
1419
+ if (btn) btn.className = 'pref-btn' + (sidebarCollapsed ? ' active' : '');
1420
+ });
1421
+
1422
+ // ── Copy button delegation ────────────────────────────────────
1423
+ document.addEventListener('click', function(e) {
1424
+ var copyBtn = e.target.closest('.copy-btn');
1425
+ if (!copyBtn) return;
1426
+ var text = copyBtn.getAttribute('data-copy');
1427
+ if (text) {
1428
+ e.preventDefault();
1429
+ e.stopPropagation();
1430
+ copyToClipboard(text, copyBtn);
1431
+ }
1432
+ });
1433
+
1434
+ // ── Utility ────────────────────────────────────────────────────
1435
+ function escHtml(s) {
1436
+ if (!s) return '';
1437
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1438
+ }
1439
+
1440
+ // ── Init ───────────────────────────────────────────────────────
1441
+ connect();
1442
+ })();
1443
+ `;
1444
+ }
1445
+
1446
+ module.exports = { getDashboardJs };