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 +9 -8
- package/src/server/web-ui/js.js +84 -109
- package/src/server/web-ui/pure.js +137 -0
- package/src/server/web.js +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-watchtower",
|
|
3
|
-
"version": "1.10.
|
|
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": ">=
|
|
52
|
+
"node": ">=20.0.0"
|
|
52
53
|
},
|
|
53
54
|
"files": [
|
|
54
55
|
"bin/git-watchtower.js",
|
package/src/server/web-ui/js.js
CHANGED
|
@@ -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
|
-
|
|
191
|
-
|
|
192
|
-
diffBranchesForNotifications(state.branches, newState.branches || []);
|
|
209
|
+
if (!activeTabId && newState.activeProjectId) {
|
|
210
|
+
activeTabId = newState.activeProjectId;
|
|
193
211
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
// ──
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
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
|
|
432
|
+
try { res.write(': keepalive\n\n'); } catch (e) { clearInterval(keepalive); }
|
|
433
433
|
}, SSE_KEEPALIVE_INTERVAL);
|
|
434
434
|
|
|
435
435
|
req.on('close', () => {
|