git-watchtower 1.10.3 → 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.3",
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": {
@@ -49,7 +49,7 @@
49
49
  },
50
50
  "homepage": "https://github.com/drummel/git-watchtower#readme",
51
51
  "engines": {
52
- "node": ">=18.0.0"
52
+ "node": ">=20.0.0"
53
53
  },
54
54
  "files": [
55
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';
@@ -403,95 +421,21 @@ function getDashboardJs() {
403
421
  }
404
422
  }
405
423
 
406
- // ── Time Formatting ────────────────────────────────────────────
407
- function timeAgo(dateStr) {
408
- if (!dateStr) return '';
409
- var ts = new Date(dateStr).getTime();
410
- if (isNaN(ts)) return '';
411
- var diff = Date.now() - ts;
412
- if (diff < 0) return 'now';
413
- var s = Math.floor(diff / 1000);
414
- if (s < 60) return s + 's ago';
415
- var m = Math.floor(s / 60);
416
- if (m < 60) return m + 'm ago';
417
- var h = Math.floor(m / 60);
418
- if (h < 24) return h + 'h ago';
419
- var d = Math.floor(h / 24);
420
- return d + 'd ago';
421
- }
424
+ // ── Pure Utility Functions (inlined from pure.js) ──────────────
425
+ ${pureFnBlock}
422
426
 
423
- // ── Sparkline Rendering ────────────────────────────────────────
424
- function renderSparklineBars(sparkStr) {
425
- if (!sparkStr) return '';
426
- var chars = '\\u2581\\u2582\\u2583\\u2584\\u2585\\u2586\\u2587\\u2588';
427
- var html = '<div class="sparkline-bar">';
428
- for (var i = 0; i < sparkStr.length; i++) {
429
- var ch = sparkStr[i];
430
- var idx = chars.indexOf(ch);
431
- if (idx < 0) {
432
- html += '<div class="spark-bar" style="height:1px"></div>';
433
- } else {
434
- var pct = Math.round(((idx + 1) / 8) * 100);
435
- html += '<div class="spark-bar" style="height:' + pct + '%"></div>';
436
- }
437
- }
438
- html += '</div>';
439
- return html;
440
- }
441
-
442
- // ── Compact number ─────────────────────────────────────────────
443
- function fmtCompact(n) {
444
- if (n < 1000) return String(n);
445
- if (n < 10000) return (n / 1000).toFixed(1) + 'k';
446
- if (n < 1000000) return Math.round(n / 1000) + 'k';
447
- return (n / 1000000).toFixed(1) + 'm';
448
- }
449
-
450
- // ── Get Display Branches ───────────────────────────────────────
451
- function getDisplayBranches() {
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() {
452
432
  if (!state || !state.branches) return [];
453
- var branches = state.branches.slice();
454
- if (searchQuery) {
455
- var q = searchQuery.toLowerCase();
456
- branches = branches.filter(function(b) {
457
- return b.name.toLowerCase().indexOf(q) !== -1;
458
- });
459
- }
460
- // Pin branches to top
461
- if (pinnedBranches.length > 0) {
462
- var pinSet = {};
463
- for (var i = 0; i < pinnedBranches.length; i++) pinSet[pinnedBranches[i]] = true;
464
- branches.sort(function(a, b) {
465
- var aPin = pinSet[a.name] ? 1 : 0;
466
- var bPin = pinSet[b.name] ? 1 : 0;
467
- return bPin - aPin; // pinned first
468
- });
469
- }
470
- // Sort
471
- if (sortOrder === 'alpha') {
472
- var pinSet2 = {};
473
- for (var j = 0; j < pinnedBranches.length; j++) pinSet2[pinnedBranches[j]] = true;
474
- branches.sort(function(a, b) {
475
- // Pinned branches always first
476
- var aPin = pinSet2[a.name] ? 1 : 0;
477
- var bPin = pinSet2[b.name] ? 1 : 0;
478
- if (aPin !== bPin) return bPin - aPin;
479
- return a.name.localeCompare(b.name);
480
- });
481
- } else if (sortOrder === 'recent') {
482
- var pinSet3 = {};
483
- for (var k = 0; k < pinnedBranches.length; k++) pinSet3[pinnedBranches[k]] = true;
484
- branches.sort(function(a, b) {
485
- var aPin = pinSet3[a.name] ? 1 : 0;
486
- var bPin = pinSet3[b.name] ? 1 : 0;
487
- if (aPin !== bPin) return bPin - aPin;
488
- var aDate = a.date ? new Date(a.date).getTime() : 0;
489
- var bDate = b.date ? new Date(b.date).getTime() : 0;
490
- return bDate - aDate;
491
- });
492
- }
493
- return branches;
494
- }
433
+ return _pureGetDisplayBranches(state.branches, {
434
+ searchQuery: searchQuery,
435
+ pinnedBranches: pinnedBranches,
436
+ sortOrder: sortOrder,
437
+ });
438
+ };
495
439
 
496
440
  // ── Render ─────────────────────────────────────────────────────
497
441
  function render() {
@@ -1468,12 +1412,6 @@ function getDashboardJs() {
1468
1412
  }
1469
1413
  });
1470
1414
 
1471
- // ── Utility ────────────────────────────────────────────────────
1472
- function escHtml(s) {
1473
- if (!s) return '';
1474
- return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
1475
- }
1476
-
1477
1415
  // ── Init ───────────────────────────────────────────────────────
1478
1416
  connect();
1479
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', () => {