git-watchtower 1.10.4 → 1.10.6

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