git-watchtower 1.10.5 → 1.10.7

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.5",
3
+ "version": "1.10.7",
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,26 +33,32 @@ function getDashboardJs() {
33
33
  'use strict';
34
34
 
35
35
  // ── State ──────────────────────────────────────────────────────
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;
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
64
  const PREFS_KEY = 'git-watchtower-prefs';
@@ -191,27 +197,27 @@ function getDashboardJs() {
191
197
  evtSource = new EventSource('/api/events');
192
198
 
193
199
  evtSource.onopen = () => {
194
- connected = true;
200
+ ui.connected = true;
195
201
  updateConnectionStatus();
196
202
  };
197
203
 
198
204
  evtSource.addEventListener('state', (e) => {
199
205
  try {
200
206
  const newState = JSON.parse(e.data);
201
- if (!activeTabId && newState.activeProjectId) {
202
- activeTabId = newState.activeProjectId;
207
+ if (!ui.activeTabId && newState.activeProjectId) {
208
+ ui.activeTabId = newState.activeProjectId;
203
209
  }
204
210
  // SSE always pushes the local project's state. When the user
205
211
  // is viewing a different tab we must NOT overwrite the per-project
206
212
  // data (branches, PRs, activity, etc.) — only update global
207
213
  // metadata so the tab bar, connection status, and version info
208
214
  // stay current.
209
- const viewingLocalProject = !activeTabId || activeTabId === newState.activeProjectId;
215
+ const viewingLocalProject = !ui.activeTabId || ui.activeTabId === newState.activeProjectId;
210
216
  if (viewingLocalProject) {
211
217
  if (state && state.branches) {
212
218
  diffBranchesForNotifications(state.branches, newState.branches || []);
213
219
  }
214
- prevBranches = state ? state.branches : null;
220
+ ui.prevBranches = state ? state.branches : null;
215
221
  state = newState;
216
222
  } else {
217
223
  if (state) {
@@ -240,7 +246,7 @@ function getDashboardJs() {
240
246
  try {
241
247
  const data = JSON.parse(e.data);
242
248
  if (!data.success && data.message && data.message.indexOf('uncommitted') !== -1) {
243
- pendingStashBranch = data.branch || null;
249
+ ui.pendingStashBranch = data.branch || null;
244
250
  showErrorToastWithHint(data.message, 'Press S to stash');
245
251
  } else {
246
252
  showToast(data.message, data.success ? 'success' : 'error');
@@ -249,7 +255,7 @@ function getDashboardJs() {
249
255
  });
250
256
 
251
257
  evtSource.onerror = () => {
252
- connected = false;
258
+ ui.connected = false;
253
259
  updateConnectionStatus();
254
260
  };
255
261
  }
@@ -257,8 +263,8 @@ function getDashboardJs() {
257
263
  function updateConnectionStatus() {
258
264
  const dot = document.getElementById('connection-dot');
259
265
  const badge = document.getElementById('status-badge');
260
- if (connected) {
261
- dot.className = 'connection-dot connected';
266
+ if (ui.connected) {
267
+ dot.className = 'connection-dot ui.connected';
262
268
  badge.className = 'badge badge-online';
263
269
  badge.textContent = 'live';
264
270
  } else {
@@ -274,7 +280,7 @@ function getDashboardJs() {
274
280
  xhr.open('POST', '/api/action');
275
281
  xhr.setRequestHeader('Content-Type', 'application/json');
276
282
  const data = { action, payload: payload || {} };
277
- if (activeTabId) data.projectId = activeTabId;
283
+ if (ui.activeTabId) data.projectId = ui.activeTabId;
278
284
  xhr.send(JSON.stringify(data));
279
285
  }
280
286
 
@@ -283,8 +289,8 @@ function getDashboardJs() {
283
289
  const el = document.getElementById('flash');
284
290
  el.textContent = text;
285
291
  el.className = 'flash visible ' + (type || 'info');
286
- clearTimeout(flashTimer);
287
- flashTimer = setTimeout(() => { el.className = 'flash'; }, 3000);
292
+ clearTimeout(ui.flashTimer);
293
+ ui.flashTimer = setTimeout(() => { el.className = 'flash'; }, 3000);
288
294
  }
289
295
 
290
296
  // ── Toast Notifications ────────────────────────────────────────
@@ -337,7 +343,7 @@ function getDashboardJs() {
337
343
  };
338
344
 
339
345
  function anyModalOpen() {
340
- return _openModals.length > 0 || confirmMode;
346
+ return _openModals.length > 0 || ui.confirmMode;
341
347
  }
342
348
 
343
349
  // Create modal instances
@@ -349,18 +355,18 @@ function getDashboardJs() {
349
355
  const updateModal = new Modal('update-overlay', 'update-close');
350
356
 
351
357
  // 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
+ 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; };
358
364
 
359
365
  // ── Confirm Dialog ─────────────────────────────────────────────
360
366
  function showConfirm(title, message, onConfirm, opts) {
361
367
  opts = opts || {};
362
- confirmMode = true;
363
- confirmCallback = onConfirm;
368
+ ui.confirmMode = true;
369
+ ui.confirmCallback = onConfirm;
364
370
  const box = document.getElementById('confirm-box');
365
371
  box.innerHTML =
366
372
  '<div class="confirm-title">' + escHtml(title) + '</div>' +
@@ -375,13 +381,13 @@ function getDashboardJs() {
375
381
  document.getElementById('confirm-cancel').onclick = hideConfirm;
376
382
  document.getElementById('confirm-ok').onclick = () => {
377
383
  hideConfirm();
378
- if (confirmCallback) confirmCallback();
384
+ if (ui.confirmCallback) ui.confirmCallback();
379
385
  };
380
386
  }
381
387
 
382
388
  function hideConfirm() {
383
- confirmMode = false;
384
- confirmCallback = null;
389
+ ui.confirmMode = false;
390
+ ui.confirmCallback = null;
385
391
  document.getElementById('confirm-overlay').className = 'confirm-overlay';
386
392
  }
387
393
 
@@ -398,7 +404,7 @@ function getDashboardJs() {
398
404
  let html = '';
399
405
  for (let i = 0; i < projects.length; i++) {
400
406
  const p = projects[i];
401
- const isActive = p.id === activeTabId;
407
+ const isActive = p.id === ui.activeTabId;
402
408
  html += '<div class="tab' + (isActive ? ' active' : '') + '" data-project-id="' + escHtml(p.id) + '">';
403
409
  html += '<span class="tab-dot"></span>';
404
410
  html += escHtml(p.name);
@@ -412,7 +418,7 @@ function getDashboardJs() {
412
418
  const xhr = new XMLHttpRequest();
413
419
  xhr.open('GET', '/api/projects/' + projectId + '/state');
414
420
  xhr.onload = () => {
415
- if (xhr.status === 200 && activeTabId === projectId) {
421
+ if (xhr.status === 200 && ui.activeTabId === projectId) {
416
422
  try {
417
423
  const pState = JSON.parse(xhr.responseText);
418
424
  state.branches = pState.branches || [];
@@ -435,20 +441,20 @@ function getDashboardJs() {
435
441
  }
436
442
 
437
443
  function switchTab(projectId) {
438
- if (projectId === activeTabId) return;
439
- activeTabId = projectId;
440
- selectedIndex = 0;
441
- searchQuery = '';
442
- searchMode = false;
444
+ if (projectId === ui.activeTabId) return;
445
+ ui.activeTabId = projectId;
446
+ ui.selectedIndex = 0;
447
+ ui.searchQuery = '';
448
+ ui.searchMode = false;
443
449
  document.getElementById('search-bar').className = 'search-bar';
444
450
  document.getElementById('search-input').value = '';
445
451
  renderTabs();
446
452
  fetchAndApplyProjectState(projectId);
447
453
 
448
- clearInterval(remoteTabPollTimer);
449
- remoteTabPollTimer = null;
454
+ clearInterval(ui.remoteTabPollTimer);
455
+ ui.remoteTabPollTimer = null;
450
456
  if (state && projectId !== state.activeProjectId) {
451
- remoteTabPollTimer = setInterval(() => {
457
+ ui.remoteTabPollTimer = setInterval(() => {
452
458
  fetchAndApplyProjectState(projectId);
453
459
  }, 2000);
454
460
  }
@@ -464,7 +470,7 @@ ${pureFnBlock}
464
470
  getDisplayBranches = function() {
465
471
  if (!state || !state.branches) return [];
466
472
  return _pureGetDisplayBranches(state.branches, {
467
- searchQuery: searchQuery,
473
+ searchQuery: ui.searchQuery,
468
474
  pinnedBranches: pinnedBranches,
469
475
  sortOrder: sortOrder,
470
476
  });
@@ -487,7 +493,7 @@ ${pureFnBlock}
487
493
  if (state.version) versionEl.textContent = 'v' + state.version;
488
494
 
489
495
  // Status badge
490
- if (connected) {
496
+ if (ui.connected) {
491
497
  const badge = document.getElementById('status-badge');
492
498
  if (state.isOffline) {
493
499
  badge.className = 'badge badge-offline';
@@ -507,13 +513,13 @@ ${pureFnBlock}
507
513
  renderPrefsBar();
508
514
 
509
515
  // Auto-show update notification (once per session)
510
- if (state.updateAvailable && !updateNotificationShown && !anyModalOpen()) {
511
- updateNotificationShown = true;
516
+ if (state.updateAvailable && !ui.updateNotificationShown && !anyModalOpen()) {
517
+ ui.updateNotificationShown = true;
512
518
  showUpdateModal();
513
519
  }
514
520
 
515
521
  // Update log viewer if open
516
- if (logViewerMode) renderLogViewer();
522
+ if (ui.logViewerMode) renderLogViewer();
517
523
  }
518
524
 
519
525
  function renderBranches() {
@@ -522,14 +528,14 @@ ${pureFnBlock}
522
528
  const countEl = document.getElementById('branch-count');
523
529
  countEl.textContent = branches.length;
524
530
 
525
- if (selectedIndex >= branches.length) {
526
- selectedIndex = Math.max(0, branches.length - 1);
531
+ if (ui.selectedIndex >= branches.length) {
532
+ ui.selectedIndex = Math.max(0, branches.length - 1);
527
533
  }
528
534
 
529
535
  if (branches.length === 0) {
530
536
  container.innerHTML = '<div class="empty-state">' +
531
537
  '<div class="empty-state-icon">&#x1f33f;</div>' +
532
- (searchQuery ? 'No branches matching "' + escHtml(searchQuery) + '"' : 'No branches found') +
538
+ (ui.searchQuery ? 'No branches matching "' + escHtml(ui.searchQuery) + '"' : 'No branches found') +
533
539
  '</div>';
534
540
  return;
535
541
  }
@@ -537,7 +543,7 @@ ${pureFnBlock}
537
543
  let html = '';
538
544
  for (let i = 0; i < branches.length; i++) {
539
545
  const b = branches[i];
540
- const isSelected = i === selectedIndex;
546
+ const isSelected = i === ui.selectedIndex;
541
547
  const isCurrent = b.name === state.currentBranch;
542
548
 
543
549
  // Sparkline
@@ -668,8 +674,8 @@ ${pureFnBlock}
668
674
 
669
675
  // ── Log Viewer ─────────────────────────────────────────────────
670
676
  function showLogViewer() {
671
- logViewerMode = true;
672
- logViewerTab = 'server';
677
+ ui.logViewerMode = true;
678
+ ui.logViewerTab = 'server';
673
679
  renderLogViewer();
674
680
  logViewerModal.show();
675
681
  }
@@ -682,11 +688,11 @@ ${pureFnBlock}
682
688
  // Update tab active state
683
689
  const tabs = document.querySelectorAll('.log-viewer-tab');
684
690
  for (let t = 0; t < tabs.length; t++) {
685
- tabs[t].className = 'log-viewer-tab' + (tabs[t].getAttribute('data-tab') === logViewerTab ? ' active' : '');
691
+ tabs[t].className = 'log-viewer-tab' + (tabs[t].getAttribute('data-tab') === ui.logViewerTab ? ' active' : '');
686
692
  }
687
693
 
688
694
  let html = '';
689
- if (logViewerTab === 'server') {
695
+ if (ui.logViewerTab === 'server') {
690
696
  const logs = state.serverLogBuffer || [];
691
697
  if (logs.length === 0) {
692
698
  html = '<div style="color:var(--text-muted);padding:20px;text-align:center;">No server logs</div>';
@@ -721,16 +727,16 @@ ${pureFnBlock}
721
727
  document.getElementById('log-viewer-tabs').addEventListener('click', (e) => {
722
728
  const tab = e.target.closest('.log-viewer-tab');
723
729
  if (!tab) return;
724
- logViewerTab = tab.getAttribute('data-tab');
730
+ ui.logViewerTab = tab.getAttribute('data-tab');
725
731
  renderLogViewer();
726
732
  });
727
733
 
728
734
  // ── Branch Action Modal ────────────────────────────────────────
729
735
  function showBranchActions() {
730
736
  const branches = getDisplayBranches();
731
- if (!branches.length || selectedIndex >= branches.length) return;
732
- const branch = branches[selectedIndex];
733
- branchActionMode = true;
737
+ if (!branches.length || ui.selectedIndex >= branches.length) return;
738
+ const branch = branches[ui.selectedIndex];
739
+ ui.branchActionMode = true;
734
740
  branchActionModal.show();
735
741
  document.getElementById('branch-action-title').textContent = 'Actions: ' + branch.name;
736
742
 
@@ -836,7 +842,7 @@ ${pureFnBlock}
836
842
  // ── Info Panel ─────────────────────────────────────────────────
837
843
  function showInfo() {
838
844
  if (!state) return;
839
- infoMode = true;
845
+ ui.infoMode = true;
840
846
  const grid = document.getElementById('info-grid');
841
847
  const rows = [
842
848
  ['Project', state.projectName || '-'],
@@ -863,8 +869,8 @@ ${pureFnBlock}
863
869
 
864
870
  // ── Stash Management ───────────────────────────────────────────
865
871
  function showStashDialog(pendingBranch) {
866
- stashMode = true;
867
- pendingStashBranch = pendingBranch || null;
872
+ ui.stashMode = true;
873
+ ui.pendingStashBranch = pendingBranch || null;
868
874
  const msg = pendingBranch
869
875
  ? 'You have uncommitted changes. Stash them before switching to <strong>' + escHtml(pendingBranch) + '</strong>?'
870
876
  : 'Stash all uncommitted changes in the working directory?';
@@ -877,7 +883,7 @@ ${pureFnBlock}
877
883
  stashModal.show();
878
884
  document.getElementById('stash-cancel').onclick = hideStash;
879
885
  document.getElementById('stash-confirm').onclick = () => {
880
- sendAction('stash', { pendingBranch: pendingStashBranch });
886
+ sendAction('stash', { pendingBranch: ui.pendingStashBranch });
881
887
  showToast('Stashing changes...', 'info');
882
888
  hideStash();
883
889
  };
@@ -887,7 +893,7 @@ ${pureFnBlock}
887
893
 
888
894
  // ── Branch Cleanup ─────────────────────────────────────────────
889
895
  function showCleanup() {
890
- cleanupMode = true;
896
+ ui.cleanupMode = true;
891
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
899
  cleanupModal.show();
@@ -950,7 +956,7 @@ ${pureFnBlock}
950
956
  // ── Update Notification ────────────────────────────────────────
951
957
  function showUpdateModal() {
952
958
  if (!state || !state.updateAvailable) return;
953
- updateMode = true;
959
+ ui.updateMode = true;
954
960
  const html = '<div class="update-versions">';
955
961
  html += '<span class="old-version">v' + escHtml(state.version || '?') + '</span>';
956
962
  html += '<span class="arrow">&#x2192;</span>';
@@ -1029,7 +1035,7 @@ ${pureFnBlock}
1029
1035
  hintEl.addEventListener('click', (e) => {
1030
1036
  const h = e.currentTarget.getAttribute('data-hint');
1031
1037
  if (h === 'Press S to stash') {
1032
- showStashDialog(pendingStashBranch);
1038
+ showStashDialog(ui.pendingStashBranch);
1033
1039
  }
1034
1040
  toast.classList.remove('visible');
1035
1041
  setTimeout(() => { if (toast.parentNode) toast.parentNode.removeChild(toast); }, 300);
@@ -1043,6 +1049,99 @@ ${pureFnBlock}
1043
1049
  }
1044
1050
 
1045
1051
  // ── Keyboard ───────────────────────────────────────────────────
1052
+
1053
+ // Key-to-action mapping for normal mode.
1054
+ // Declarative — easy to test, extend, and share with TUI.
1055
+ const KEY_MAP = {
1056
+ 'j': 'moveDown',
1057
+ 'ArrowDown': 'moveDown',
1058
+ 'k': 'moveUp',
1059
+ 'ArrowUp': 'moveUp',
1060
+ 'Enter': 'selectBranch',
1061
+ '/': 'search',
1062
+ 'p': 'pull',
1063
+ 'f': 'fetch',
1064
+ 'r': 'reloadBrowsers',
1065
+ 'R': 'restartServer',
1066
+ 'c': 'toggleCasino',
1067
+ 'o': 'openBrowser',
1068
+ 'h': 'showHistory',
1069
+ 'u': 'undo',
1070
+ 's': 'toggleSound',
1071
+ 'b': 'branchActions',
1072
+ 'i': 'info',
1073
+ 'l': 'logViewer',
1074
+ 'S': 'stash',
1075
+ 'd': 'cleanup',
1076
+ 'Escape': 'escape',
1077
+ };
1078
+
1079
+ // Action handlers for normal mode.
1080
+ // Each receives the KeyboardEvent for cases that need it.
1081
+ const KEY_ACTIONS = {
1082
+ moveDown() { moveSelection(1); },
1083
+ moveUp() { moveSelection(-1); },
1084
+ selectBranch() {
1085
+ const branches = getDisplayBranches();
1086
+ if (branches.length > 0 && ui.selectedIndex < branches.length) {
1087
+ const b = branches[ui.selectedIndex];
1088
+ if (b.isDeleted) {
1089
+ showToast('Cannot switch to a deleted branch', 'error');
1090
+ } else if (b.name === state.currentBranch) {
1091
+ showToast('Already on ' + b.name, 'info');
1092
+ } else {
1093
+ sendAction('switchBranch', { branch: b.name });
1094
+ showToast('Switching to ' + b.name + '...', 'info');
1095
+ }
1096
+ }
1097
+ },
1098
+ search() {
1099
+ ui.searchMode = true;
1100
+ ui.searchQuery = '';
1101
+ ui.selectedIndex = 0;
1102
+ document.getElementById('search-bar').className = 'search-bar active';
1103
+ const input = document.getElementById('search-input');
1104
+ input.value = '';
1105
+ input.focus();
1106
+ },
1107
+ pull() { sendAction('pull'); showToast('Pulling current branch...', 'info'); },
1108
+ fetch() { sendAction('fetch'); showToast('Fetching all branches...', 'info'); },
1109
+ reloadBrowsers() {
1110
+ if (state && state.serverMode === 'static') {
1111
+ sendAction('reloadBrowsers');
1112
+ showToast('Reloading browsers...', 'info');
1113
+ }
1114
+ },
1115
+ restartServer() {
1116
+ if (state && state.serverMode === 'command') {
1117
+ showConfirm('Restart Server', 'Restart the dev server process?', () => {
1118
+ sendAction('restartServer');
1119
+ showToast('Restarting server...', 'info');
1120
+ }, { label: 'Restart' });
1121
+ }
1122
+ },
1123
+ toggleCasino() { sendAction('toggleCasino'); },
1124
+ openBrowser() { sendAction('openBrowser'); showToast('Opening in browser...', 'info'); },
1125
+ showHistory() {
1126
+ if (state && state.switchHistory && state.switchHistory.length > 0) {
1127
+ const last = state.switchHistory[0];
1128
+ let histMsg = 'Last: ' + last.from + ' \\u2192 ' + last.to;
1129
+ if (state.switchHistory.length > 1) histMsg += ' (+' + (state.switchHistory.length - 1) + ' more)';
1130
+ showToast(histMsg, 'info');
1131
+ } else {
1132
+ showToast('No switch history yet', 'info');
1133
+ }
1134
+ },
1135
+ undo() { sendAction('undo'); showToast('Undoing last switch...', 'info'); },
1136
+ toggleSound() { sendAction('toggleSound'); showToast(state && state.soundEnabled ? 'Sound off' : 'Sound on', 'info'); },
1137
+ branchActions() { showBranchActions(); },
1138
+ info() { showInfo(); },
1139
+ logViewer() { showLogViewer(); },
1140
+ stash() { showStashDialog(null); },
1141
+ cleanup() { showCleanup(); },
1142
+ escape() { /* no-op in normal mode */ },
1143
+ };
1144
+
1046
1145
  document.addEventListener('keydown', (e) => {
1047
1146
  // Ignore when typing in input fields (other than search)
1048
1147
  if (e.target.tagName === 'INPUT' && e.target.id !== 'search-input') return;
@@ -1056,10 +1155,10 @@ ${pureFnBlock}
1056
1155
  }
1057
1156
 
1058
1157
  // Log viewer tab switching
1059
- if (logViewerMode) {
1158
+ if (ui.logViewerMode) {
1060
1159
  if (e.key === 'Tab' || e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
1061
1160
  e.preventDefault();
1062
- logViewerTab = logViewerTab === 'server' ? 'activity' : 'server';
1161
+ ui.logViewerTab = ui.logViewerTab === 'server' ? 'activity' : 'server';
1063
1162
  renderLogViewer();
1064
1163
  }
1065
1164
  return;
@@ -1069,11 +1168,11 @@ ${pureFnBlock}
1069
1168
  if (_openModals.length > 0) return;
1070
1169
 
1071
1170
  // Confirm dialog mode — Escape to cancel, Enter to confirm
1072
- if (confirmMode) {
1171
+ if (ui.confirmMode) {
1073
1172
  if (e.key === 'Escape') { e.preventDefault(); hideConfirm(); }
1074
1173
  if (e.key === 'Enter') {
1075
1174
  e.preventDefault();
1076
- const cb = confirmCallback;
1175
+ const cb = ui.confirmCallback;
1077
1176
  hideConfirm();
1078
1177
  if (cb) cb();
1079
1178
  }
@@ -1081,20 +1180,20 @@ ${pureFnBlock}
1081
1180
  }
1082
1181
 
1083
1182
  // Search mode
1084
- if (searchMode) {
1183
+ if (ui.searchMode) {
1085
1184
  if (e.key === 'Escape') {
1086
1185
  e.preventDefault();
1087
- searchMode = false;
1088
- searchQuery = '';
1186
+ ui.searchMode = false;
1187
+ ui.searchQuery = '';
1089
1188
  document.getElementById('search-bar').className = 'search-bar';
1090
1189
  document.getElementById('search-input').value = '';
1091
- selectedIndex = 0;
1190
+ ui.selectedIndex = 0;
1092
1191
  renderBranches();
1093
1192
  return;
1094
1193
  }
1095
1194
  if (e.key === 'Enter') {
1096
1195
  e.preventDefault();
1097
- searchMode = false;
1196
+ ui.searchMode = false;
1098
1197
  document.getElementById('search-bar').className = 'search-bar';
1099
1198
  return;
1100
1199
  }
@@ -1125,7 +1224,7 @@ ${pureFnBlock}
1125
1224
  // Tab cycling with Tab key
1126
1225
  if (e.key === 'Tab' && projects.length > 1) {
1127
1226
  e.preventDefault();
1128
- const curIdx = projects.findIndex((p) => p.id === activeTabId);
1227
+ const curIdx = projects.findIndex((p) => p.id === ui.activeTabId);
1129
1228
  const nextIdx = e.shiftKey
1130
1229
  ? (curIdx - 1 + projects.length) % projects.length
1131
1230
  : (curIdx + 1) % projects.length;
@@ -1133,142 +1232,26 @@ ${pureFnBlock}
1133
1232
  return;
1134
1233
  }
1135
1234
 
1136
- // Normal mode
1137
- switch (e.key) {
1138
- case 'j':
1139
- case 'ArrowDown':
1140
- e.preventDefault();
1141
- moveSelection(1);
1142
- break;
1143
- case 'k':
1144
- case 'ArrowUp':
1145
- e.preventDefault();
1146
- moveSelection(-1);
1147
- break;
1148
- case 'Enter':
1149
- e.preventDefault();
1150
- const branches = getDisplayBranches();
1151
- if (branches.length > 0 && selectedIndex < branches.length) {
1152
- const b = branches[selectedIndex];
1153
- if (b.isDeleted) {
1154
- showToast('Cannot switch to a deleted branch', 'error');
1155
- } else if (b.name === state.currentBranch) {
1156
- showToast('Already on ' + b.name, 'info');
1157
- } else {
1158
- sendAction('switchBranch', { branch: b.name });
1159
- showToast('Switching to ' + b.name + '...', 'info');
1160
- }
1161
- }
1162
- break;
1163
- case '/':
1164
- e.preventDefault();
1165
- searchMode = true;
1166
- searchQuery = '';
1167
- selectedIndex = 0;
1168
- document.getElementById('search-bar').className = 'search-bar active';
1169
- const input = document.getElementById('search-input');
1170
- input.value = '';
1171
- input.focus();
1172
- break;
1173
- case 'p':
1174
- e.preventDefault();
1175
- sendAction('pull');
1176
- showToast('Pulling current branch...', 'info');
1177
- break;
1178
- case 'f':
1179
- e.preventDefault();
1180
- sendAction('fetch');
1181
- showToast('Fetching all branches...', 'info');
1182
- break;
1183
- case 'r':
1184
- e.preventDefault();
1185
- if (state && state.serverMode === 'static') {
1186
- sendAction('reloadBrowsers');
1187
- showToast('Reloading browsers...', 'info');
1188
- }
1189
- break;
1190
- case 'R':
1191
- e.preventDefault();
1192
- if (state && state.serverMode === 'command') {
1193
- showConfirm(
1194
- 'Restart Server',
1195
- 'Restart the dev server process?',
1196
- () => {
1197
- sendAction('restartServer');
1198
- showToast('Restarting server...', 'info');
1199
- },
1200
- { label: 'Restart' }
1201
- );
1202
- }
1203
- break;
1204
- case 'c':
1205
- e.preventDefault();
1206
- sendAction('toggleCasino');
1207
- break;
1208
- case 'o':
1209
- e.preventDefault();
1210
- sendAction('openBrowser');
1211
- showToast('Opening in browser...', 'info');
1212
- break;
1213
- case 'h':
1214
- e.preventDefault();
1215
- if (state && state.switchHistory && state.switchHistory.length > 0) {
1216
- const last = state.switchHistory[0];
1217
- const histMsg = 'Last: ' + last.from + ' \\u2192 ' + last.to;
1218
- if (state.switchHistory.length > 1) histMsg += ' (+' + (state.switchHistory.length - 1) + ' more)';
1219
- showToast(histMsg, 'info');
1220
- } else {
1221
- showToast('No switch history yet', 'info');
1222
- }
1223
- break;
1224
- case 'u':
1225
- e.preventDefault();
1226
- sendAction('undo');
1227
- showToast('Undoing last switch...', 'info');
1228
- break;
1229
- case 's':
1230
- e.preventDefault();
1231
- sendAction('toggleSound');
1232
- showToast(state && state.soundEnabled ? 'Sound off' : 'Sound on', 'info');
1233
- break;
1234
- case 'b':
1235
- e.preventDefault();
1236
- showBranchActions();
1237
- break;
1238
- case 'i':
1239
- e.preventDefault();
1240
- showInfo();
1241
- break;
1242
- case 'l':
1243
- e.preventDefault();
1244
- showLogViewer();
1245
- break;
1246
- case 'S':
1247
- e.preventDefault();
1248
- showStashDialog(null);
1249
- break;
1250
- case 'd':
1251
- e.preventDefault();
1252
- showCleanup();
1253
- break;
1254
- case 'Escape':
1255
- e.preventDefault();
1256
- break;
1235
+ // Normal mode — look up action from key map
1236
+ const action = KEY_MAP[e.key];
1237
+ if (action && KEY_ACTIONS[action]) {
1238
+ e.preventDefault();
1239
+ KEY_ACTIONS[action](e);
1257
1240
  }
1258
1241
  });
1259
1242
 
1260
1243
  // Search input handler
1261
1244
  document.getElementById('search-input').addEventListener('input', (e) => {
1262
- searchQuery = e.target.value;
1263
- selectedIndex = 0;
1245
+ ui.searchQuery = e.target.value;
1246
+ ui.selectedIndex = 0;
1264
1247
  renderBranches();
1265
1248
  });
1266
1249
 
1267
1250
  function moveSelection(delta) {
1268
1251
  const branches = getDisplayBranches();
1269
- const newIndex = selectedIndex + delta;
1252
+ const newIndex = ui.selectedIndex + delta;
1270
1253
  if (newIndex >= 0 && newIndex < branches.length) {
1271
- selectedIndex = newIndex;
1254
+ ui.selectedIndex = newIndex;
1272
1255
  renderBranches();
1273
1256
  }
1274
1257
  }
@@ -1279,7 +1262,7 @@ ${pureFnBlock}
1279
1262
  if (!item) return;
1280
1263
  const idx = parseInt(item.getAttribute('data-index'), 10);
1281
1264
  if (isNaN(idx)) return;
1282
- selectedIndex = idx;
1265
+ ui.selectedIndex = idx;
1283
1266
  renderBranches();
1284
1267
 
1285
1268
  // Double-click to switch with confirmation
@@ -1339,8 +1322,8 @@ ${pureFnBlock}
1339
1322
  }
1340
1323
  if (e.target.id === 'pin-selected-btn') {
1341
1324
  const branches = getDisplayBranches();
1342
- if (branches.length > 0 && selectedIndex < branches.length) {
1343
- const bn = branches[selectedIndex].name;
1325
+ if (branches.length > 0 && ui.selectedIndex < branches.length) {
1326
+ const bn = branches[ui.selectedIndex].name;
1344
1327
  const idx = pinnedBranches.indexOf(bn);
1345
1328
  if (idx === -1) {
1346
1329
  pinnedBranches.push(bn);