git-watchtower 1.10.2 → 1.10.4

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.2",
3
+ "version": "1.10.4",
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": {
@@ -8,13 +8,13 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/git-watchtower.js",
11
- "test": "node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/integration/**/*.test.js",
12
- "test:unit": "node --require ./tests/setup.js --test tests/unit/**/*.test.js",
11
+ "test": "node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/unit/**/**/*.test.js tests/integration/**/*.test.js",
12
+ "test:unit": "node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/unit/**/**/*.test.js",
13
13
  "test:integration": "node --require ./tests/setup.js --test tests/integration/**/*.test.js",
14
- "test:watch": "node --require ./tests/setup.js --test --watch tests/unit/**/*.test.js",
15
- "test:coverage": "c8 --reporter=text --reporter=html --reporter=lcov node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/integration/**/*.test.js",
16
- "test:coverage:text": "c8 --reporter=text node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/integration/**/*.test.js",
17
- "test:coverage:html": "c8 --reporter=html node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/integration/**/*.test.js && echo 'Coverage report: coverage/index.html'",
14
+ "test:watch": "node --require ./tests/setup.js --test --watch tests/unit/**/*.test.js tests/unit/**/**/*.test.js",
15
+ "test:coverage": "c8 --reporter=text --reporter=html --reporter=lcov node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/unit/**/**/*.test.js tests/integration/**/*.test.js",
16
+ "test:coverage:text": "c8 --reporter=text node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/unit/**/**/*.test.js tests/integration/**/*.test.js",
17
+ "test:coverage:html": "c8 --reporter=html node --require ./tests/setup.js --test tests/unit/**/*.test.js tests/unit/**/**/*.test.js tests/integration/**/*.test.js && echo 'Coverage report: coverage/index.html'",
18
18
  "typecheck": "tsc --noEmit"
19
19
  },
20
20
  "devDependencies": {
@@ -22,6 +22,7 @@
22
22
  "@semantic-release/git": "^10.0.1",
23
23
  "@types/node": "^22.0.0",
24
24
  "c8": "^10.1.2",
25
+ "jsdom": "^24.1.3",
25
26
  "semantic-release": "^25.0.3",
26
27
  "typescript": "^5.7.0"
27
28
  },
@@ -48,7 +49,7 @@
48
49
  },
49
50
  "homepage": "https://github.com/drummel/git-watchtower#readme",
50
51
  "engines": {
51
- "node": ">=18.0.0"
52
+ "node": ">=20.0.0"
52
53
  },
53
54
  "files": [
54
55
  "bin/git-watchtower.js",
@@ -2,14 +2,32 @@
2
2
  * Client-side JavaScript for the Git Watchtower web dashboard.
3
3
  * Contains all interactive behavior: SSE connection, rendering, keyboard
4
4
  * navigation, modals, notifications, and preferences.
5
+ *
6
+ * Pure utility functions (escHtml, timeAgo, etc.) live in pure.js and are
7
+ * inlined here at assembly time so they can be unit-tested in Node.
5
8
  * @module server/web-ui/js
6
9
  */
7
10
 
11
+ const pureFns = require('./pure');
12
+
13
+ /**
14
+ * Serialize pure functions into a block of JS source that can be embedded
15
+ * in a browser <script>. Each function is emitted verbatim using
16
+ * Function.prototype.toString().
17
+ * @returns {string}
18
+ */
19
+ function inlinePureFunctions() {
20
+ return Object.entries(pureFns)
21
+ .map(([name, fn]) => ` var ${name} = ${fn.toString()};`)
22
+ .join('\n\n');
23
+ }
24
+
8
25
  /**
9
26
  * Get the dashboard client-side JavaScript.
10
27
  * @returns {string} JavaScript content (without script tags)
11
28
  */
12
29
  function getDashboardJs() {
30
+ const pureFnBlock = inlinePureFunctions();
13
31
  return `
14
32
  (function() {
15
33
  'use strict';
@@ -34,6 +52,7 @@ function getDashboardJs() {
34
52
  var stashMode = false;
35
53
  var pendingStashBranch = null;
36
54
  var updateNotificationShown = false;
55
+ var remoteTabPollTimer = null;
37
56
 
38
57
  // ── Persistent Preferences (localStorage) ─────────────────────
39
58
  var PREFS_KEY = 'git-watchtower-prefs';
@@ -187,14 +206,33 @@ function getDashboardJs() {
187
206
  evtSource.addEventListener('state', function(e) {
188
207
  try {
189
208
  var newState = JSON.parse(e.data);
190
- // Diff branches for desktop notifications
191
- if (state && state.branches) {
192
- diffBranchesForNotifications(state.branches, newState.branches || []);
209
+ if (!activeTabId && newState.activeProjectId) {
210
+ activeTabId = newState.activeProjectId;
193
211
  }
194
- prevBranches = state ? state.branches : null;
195
- state = newState;
196
- if (!activeTabId && state.activeProjectId) {
197
- activeTabId = state.activeProjectId;
212
+ // SSE always pushes the local project's state. When the user
213
+ // is viewing a different tab we must NOT overwrite the per-project
214
+ // data (branches, PRs, activity, etc.) — only update global
215
+ // metadata so the tab bar, connection status, and version info
216
+ // stay current.
217
+ var viewingLocalProject = !activeTabId || activeTabId === newState.activeProjectId;
218
+ if (viewingLocalProject) {
219
+ // Diff branches for desktop notifications
220
+ if (state && state.branches) {
221
+ diffBranchesForNotifications(state.branches, newState.branches || []);
222
+ }
223
+ prevBranches = state ? state.branches : null;
224
+ state = newState;
225
+ } else {
226
+ // Viewing a remote tab — preserve per-project fields, update globals only
227
+ if (state) {
228
+ state.projects = newState.projects;
229
+ state.version = newState.version;
230
+ state.updateAvailable = newState.updateAvailable;
231
+ state.updateInProgress = newState.updateInProgress;
232
+ state.clientCount = newState.clientCount;
233
+ } else {
234
+ state = newState;
235
+ }
198
236
  }
199
237
  renderTabs();
200
238
  render();
@@ -331,23 +369,17 @@ function getDashboardJs() {
331
369
  tabBar.innerHTML = html;
332
370
  }
333
371
 
334
- function switchTab(projectId) {
335
- if (projectId === activeTabId) return;
336
- activeTabId = projectId;
337
- selectedIndex = 0;
338
- searchQuery = '';
339
- searchMode = false;
340
- document.getElementById('search-bar').className = 'search-bar';
341
- document.getElementById('search-input').value = '';
342
- renderTabs();
343
- // Fetch the project's state
372
+ /**
373
+ * Fetch a project's state from the server and merge it into the
374
+ * current client-side state for rendering.
375
+ */
376
+ function fetchAndApplyProjectState(projectId) {
344
377
  var xhr = new XMLHttpRequest();
345
378
  xhr.open('GET', '/api/projects/' + projectId + '/state');
346
379
  xhr.onload = function() {
347
- if (xhr.status === 200) {
380
+ if (xhr.status === 200 && activeTabId === projectId) {
348
381
  try {
349
382
  var pState = JSON.parse(xhr.responseText);
350
- // Merge into current state for rendering
351
383
  state.branches = pState.branches || [];
352
384
  state.currentBranch = pState.currentBranch;
353
385
  state.activityLog = pState.activityLog || [];
@@ -359,6 +391,7 @@ function getDashboardJs() {
359
391
  state.pollingStatus = pState.pollingStatus || 'idle';
360
392
  state.isOffline = pState.isOffline || false;
361
393
  state.serverMode = pState.serverMode || 'none';
394
+ state.repoWebUrl = pState.repoWebUrl || null;
362
395
  render();
363
396
  } catch (err) { /* ignore */ }
364
397
  }
@@ -366,95 +399,43 @@ function getDashboardJs() {
366
399
  xhr.send();
367
400
  }
368
401
 
369
- // ── Time Formatting ────────────────────────────────────────────
370
- function timeAgo(dateStr) {
371
- if (!dateStr) return '';
372
- var ts = new Date(dateStr).getTime();
373
- if (isNaN(ts)) return '';
374
- var diff = Date.now() - ts;
375
- if (diff < 0) return 'now';
376
- var s = Math.floor(diff / 1000);
377
- if (s < 60) return s + 's ago';
378
- var m = Math.floor(s / 60);
379
- if (m < 60) return m + 'm ago';
380
- var h = Math.floor(m / 60);
381
- if (h < 24) return h + 'h ago';
382
- var d = Math.floor(h / 24);
383
- return d + 'd ago';
384
- }
385
-
386
- // ── Sparkline Rendering ────────────────────────────────────────
387
- function renderSparklineBars(sparkStr) {
388
- if (!sparkStr) return '';
389
- var chars = '\\u2581\\u2582\\u2583\\u2584\\u2585\\u2586\\u2587\\u2588';
390
- var html = '<div class="sparkline-bar">';
391
- for (var i = 0; i < sparkStr.length; i++) {
392
- var ch = sparkStr[i];
393
- var idx = chars.indexOf(ch);
394
- if (idx < 0) {
395
- html += '<div class="spark-bar" style="height:1px"></div>';
396
- } else {
397
- var pct = Math.round(((idx + 1) / 8) * 100);
398
- html += '<div class="spark-bar" style="height:' + pct + '%"></div>';
399
- }
402
+ function switchTab(projectId) {
403
+ if (projectId === activeTabId) return;
404
+ activeTabId = projectId;
405
+ selectedIndex = 0;
406
+ searchQuery = '';
407
+ searchMode = false;
408
+ document.getElementById('search-bar').className = 'search-bar';
409
+ document.getElementById('search-input').value = '';
410
+ renderTabs();
411
+ fetchAndApplyProjectState(projectId);
412
+
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;
417
+ if (state && projectId !== state.activeProjectId) {
418
+ remoteTabPollTimer = setInterval(function() {
419
+ fetchAndApplyProjectState(projectId);
420
+ }, 2000);
400
421
  }
401
- html += '</div>';
402
- return html;
403
422
  }
404
423
 
405
- // ── Compact number ─────────────────────────────────────────────
406
- function fmtCompact(n) {
407
- if (n < 1000) return String(n);
408
- if (n < 10000) return (n / 1000).toFixed(1) + 'k';
409
- if (n < 1000000) return Math.round(n / 1000) + 'k';
410
- return (n / 1000000).toFixed(1) + 'm';
411
- }
424
+ // ── Pure Utility Functions (inlined from pure.js) ──────────────
425
+ ${pureFnBlock}
412
426
 
413
- // ── Get Display Branches ───────────────────────────────────────
414
- function getDisplayBranches() {
427
+ // ── Get Display Branches (wrapper) ─────────────────────────────
428
+ // The pure getDisplayBranches is inlined above as a var assignment.
429
+ // Wrap it to pass closure state as args, keeping the same call-site API.
430
+ var _pureGetDisplayBranches = getDisplayBranches;
431
+ getDisplayBranches = function() {
415
432
  if (!state || !state.branches) return [];
416
- var branches = state.branches.slice();
417
- if (searchQuery) {
418
- var q = searchQuery.toLowerCase();
419
- branches = branches.filter(function(b) {
420
- return b.name.toLowerCase().indexOf(q) !== -1;
421
- });
422
- }
423
- // Pin branches to top
424
- if (pinnedBranches.length > 0) {
425
- var pinSet = {};
426
- for (var i = 0; i < pinnedBranches.length; i++) pinSet[pinnedBranches[i]] = true;
427
- branches.sort(function(a, b) {
428
- var aPin = pinSet[a.name] ? 1 : 0;
429
- var bPin = pinSet[b.name] ? 1 : 0;
430
- return bPin - aPin; // pinned first
431
- });
432
- }
433
- // Sort
434
- if (sortOrder === 'alpha') {
435
- var pinSet2 = {};
436
- for (var j = 0; j < pinnedBranches.length; j++) pinSet2[pinnedBranches[j]] = true;
437
- branches.sort(function(a, b) {
438
- // Pinned branches always first
439
- var aPin = pinSet2[a.name] ? 1 : 0;
440
- var bPin = pinSet2[b.name] ? 1 : 0;
441
- if (aPin !== bPin) return bPin - aPin;
442
- return a.name.localeCompare(b.name);
443
- });
444
- } else if (sortOrder === 'recent') {
445
- var pinSet3 = {};
446
- for (var k = 0; k < pinnedBranches.length; k++) pinSet3[pinnedBranches[k]] = true;
447
- branches.sort(function(a, b) {
448
- var aPin = pinSet3[a.name] ? 1 : 0;
449
- var bPin = pinSet3[b.name] ? 1 : 0;
450
- if (aPin !== bPin) return bPin - aPin;
451
- var aDate = a.date ? new Date(a.date).getTime() : 0;
452
- var bDate = b.date ? new Date(b.date).getTime() : 0;
453
- return bDate - aDate;
454
- });
455
- }
456
- return branches;
457
- }
433
+ return _pureGetDisplayBranches(state.branches, {
434
+ searchQuery: searchQuery,
435
+ pinnedBranches: pinnedBranches,
436
+ sortOrder: sortOrder,
437
+ });
438
+ };
458
439
 
459
440
  // ── Render ─────────────────────────────────────────────────────
460
441
  function render() {
@@ -1431,12 +1412,6 @@ function getDashboardJs() {
1431
1412
  }
1432
1413
  });
1433
1414
 
1434
- // ── Utility ────────────────────────────────────────────────────
1435
- function escHtml(s) {
1436
- if (!s) return '';
1437
- return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1438
- }
1439
-
1440
1415
  // ── Init ───────────────────────────────────────────────────────
1441
1416
  connect();
1442
1417
  })();
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Pure utility functions shared between Node (server) and the web dashboard.
3
+ *
4
+ * These are extracted so they can be unit-tested in Node while still being
5
+ * inlined into the browser bundle by the assembly step in js.js.
6
+ *
7
+ * Every function here MUST be self-contained (no closures over external
8
+ * state) so it can be serialised with Function.prototype.toString() and
9
+ * embedded in a <script> tag.
10
+ *
11
+ * @module server/web-ui/pure
12
+ */
13
+
14
+ /**
15
+ * Escape a string for safe insertion into HTML.
16
+ * @param {string} s
17
+ * @returns {string}
18
+ */
19
+ function escHtml(s) {
20
+ if (!s) return '';
21
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
22
+ }
23
+
24
+ /**
25
+ * Format a date string as a relative time (e.g. "5m ago", "2d ago").
26
+ * @param {string} dateStr - ISO date string or any value accepted by `new Date()`
27
+ * @returns {string}
28
+ */
29
+ function timeAgo(dateStr) {
30
+ if (!dateStr) return '';
31
+ var ts = new Date(dateStr).getTime();
32
+ if (isNaN(ts)) return '';
33
+ var diff = Date.now() - ts;
34
+ if (diff < 0) return 'now';
35
+ var s = Math.floor(diff / 1000);
36
+ if (s < 60) return s + 's ago';
37
+ var m = Math.floor(s / 60);
38
+ if (m < 60) return m + 'm ago';
39
+ var h = Math.floor(m / 60);
40
+ if (h < 24) return h + 'h ago';
41
+ var d = Math.floor(h / 24);
42
+ return d + 'd ago';
43
+ }
44
+
45
+ /**
46
+ * Render a sparkline Unicode string as HTML bar elements.
47
+ * @param {string} sparkStr - String of Unicode block characters (U+2581–U+2588)
48
+ * @returns {string} HTML string
49
+ */
50
+ function renderSparklineBars(sparkStr) {
51
+ if (!sparkStr) return '';
52
+ var chars = '\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588';
53
+ var html = '<div class="sparkline-bar">';
54
+ for (var i = 0; i < sparkStr.length; i++) {
55
+ var ch = sparkStr[i];
56
+ var idx = chars.indexOf(ch);
57
+ if (idx < 0) {
58
+ html += '<div class="spark-bar" style="height:1px"></div>';
59
+ } else {
60
+ var pct = Math.round(((idx + 1) / 8) * 100);
61
+ html += '<div class="spark-bar" style="height:' + pct + '%"></div>';
62
+ }
63
+ }
64
+ html += '</div>';
65
+ return html;
66
+ }
67
+
68
+ /**
69
+ * Format a number in compact notation (e.g. 1500 → "1.5k").
70
+ * @param {number} n
71
+ * @returns {string}
72
+ */
73
+ function fmtCompact(n) {
74
+ if (n < 1000) return String(n);
75
+ if (n < 10000) return (n / 1000).toFixed(1) + 'k';
76
+ if (n < 1000000) return Math.round(n / 1000) + 'k';
77
+ return (n / 1000000).toFixed(1) + 'm';
78
+ }
79
+
80
+ /**
81
+ * Filter and sort branches for display.
82
+ * Pure function — all dependencies passed as arguments.
83
+ * @param {Array} branches - Full branch list from state
84
+ * @param {Object} options
85
+ * @param {string} [options.searchQuery=''] - Filter string
86
+ * @param {string[]} [options.pinnedBranches=[]] - Branch names pinned to top
87
+ * @param {string} [options.sortOrder='default'] - 'default' | 'alpha' | 'recent'
88
+ * @returns {Array} Filtered and sorted branch list
89
+ */
90
+ function getDisplayBranches(branches, options) {
91
+ if (!branches) return [];
92
+ var searchQuery = (options && options.searchQuery) || '';
93
+ var pinnedBranches = (options && options.pinnedBranches) || [];
94
+ var sortOrder = (options && options.sortOrder) || 'default';
95
+
96
+ var result = branches.slice();
97
+
98
+ if (searchQuery) {
99
+ var q = searchQuery.toLowerCase();
100
+ result = result.filter(function(b) {
101
+ return b.name.toLowerCase().indexOf(q) !== -1;
102
+ });
103
+ }
104
+
105
+ // Build pin lookup once
106
+ var pinSet = {};
107
+ for (var i = 0; i < pinnedBranches.length; i++) pinSet[pinnedBranches[i]] = true;
108
+
109
+ if (sortOrder === 'alpha') {
110
+ result.sort(function(a, b) {
111
+ var aPin = pinSet[a.name] ? 1 : 0;
112
+ var bPin = pinSet[b.name] ? 1 : 0;
113
+ if (aPin !== bPin) return bPin - aPin;
114
+ return a.name.localeCompare(b.name);
115
+ });
116
+ } else if (sortOrder === 'recent') {
117
+ result.sort(function(a, b) {
118
+ var aPin = pinSet[a.name] ? 1 : 0;
119
+ var bPin = pinSet[b.name] ? 1 : 0;
120
+ if (aPin !== bPin) return bPin - aPin;
121
+ var aDate = a.date ? new Date(a.date).getTime() : 0;
122
+ var bDate = b.date ? new Date(b.date).getTime() : 0;
123
+ return bDate - aDate;
124
+ });
125
+ } else if (pinnedBranches.length > 0) {
126
+ // Default sort: only move pinned branches to top
127
+ result.sort(function(a, b) {
128
+ var aPin = pinSet[a.name] ? 1 : 0;
129
+ var bPin = pinSet[b.name] ? 1 : 0;
130
+ return bPin - aPin;
131
+ });
132
+ }
133
+
134
+ return result;
135
+ }
136
+
137
+ module.exports = { escHtml, timeAgo, renderSparklineBars, fmtCompact, getDisplayBranches };
package/src/server/web.js CHANGED
@@ -429,7 +429,7 @@ class WebDashboardServer {
429
429
 
430
430
  // Keepalive heartbeat to prevent proxy/LB timeouts
431
431
  const keepalive = setInterval(() => {
432
- try { res.write(': keepalive\\n\\n'); } catch (e) { clearInterval(keepalive); }
432
+ try { res.write(': keepalive\n\n'); } catch (e) { clearInterval(keepalive); }
433
433
  }, SSE_KEEPALIVE_INTERVAL);
434
434
 
435
435
  req.on('close', () => {