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