git-watchtower 1.10.3 → 1.10.5

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