git-watchtower 1.8.5 → 1.9.0
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/bin/git-watchtower.js +72 -6
- package/package.json +1 -4
- package/src/git/branch.js +2 -2
- package/src/git/commands.js +50 -0
- package/src/state/store.js +2 -0
- package/src/stats/session.js +109 -0
- package/src/telemetry/analytics.js +132 -45
- package/src/telemetry/index.js +9 -1
- package/src/ui/renderer.js +178 -22
- package/src/utils/time.js +21 -1
package/bin/git-watchtower.js
CHANGED
|
@@ -66,7 +66,7 @@ const casinoSounds = require('../src/casino/sounds');
|
|
|
66
66
|
// Gitignore utilities for file watcher
|
|
67
67
|
const { loadGitignorePatterns, shouldIgnoreFile } = require('../src/utils/gitignore');
|
|
68
68
|
|
|
69
|
-
// Telemetry (opt-in PostHog
|
|
69
|
+
// Telemetry (opt-in analytics via PostHog HTTP API — zero dependencies)
|
|
70
70
|
const telemetry = require('../src/telemetry');
|
|
71
71
|
|
|
72
72
|
// Extracted modules
|
|
@@ -82,7 +82,10 @@ const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBa
|
|
|
82
82
|
// Security & Validation (imported from src/git/branch.js and src/git/commands.js)
|
|
83
83
|
// ============================================================================
|
|
84
84
|
const { isValidBranchName, sanitizeBranchName, getGoneBranches, deleteGoneBranches } = require('../src/git/branch');
|
|
85
|
-
const { isGitAvailable: checkGitAvailable, execGit, execGitSilent, getDiffStats: getDiffStatsSafe } = require('../src/git/commands');
|
|
85
|
+
const { isGitAvailable: checkGitAvailable, execGit, execGitSilent, getDiffStats: getDiffStatsSafe, getAheadBehind, getDiffShortstat } = require('../src/git/commands');
|
|
86
|
+
|
|
87
|
+
// Session stats (always-on, non-casino stats)
|
|
88
|
+
const sessionStats = require('../src/stats/session');
|
|
86
89
|
|
|
87
90
|
// ============================================================================
|
|
88
91
|
// Configuration (imports from src/config/, inline wizard kept here)
|
|
@@ -858,6 +861,53 @@ async function getDiffStats(fromCommit, toCommit = 'HEAD') {
|
|
|
858
861
|
return getDiffStatsSafe(fromCommit, toCommit, { cwd: PROJECT_ROOT });
|
|
859
862
|
}
|
|
860
863
|
|
|
864
|
+
// Ahead/behind: detect default branch and fetch counts
|
|
865
|
+
let detectedDefaultBranch = null;
|
|
866
|
+
|
|
867
|
+
async function detectDefaultBranch() {
|
|
868
|
+
const candidates = ['main', 'master', 'develop', 'development', 'trunk'];
|
|
869
|
+
for (const name of candidates) {
|
|
870
|
+
try {
|
|
871
|
+
await execGit(['rev-parse', '--verify', `${REMOTE_NAME}/${name}`], { cwd: PROJECT_ROOT });
|
|
872
|
+
detectedDefaultBranch = `${REMOTE_NAME}/${name}`;
|
|
873
|
+
return;
|
|
874
|
+
} catch (e) {
|
|
875
|
+
// Try next candidate
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// Fallback: try HEAD of remote
|
|
879
|
+
try {
|
|
880
|
+
const { stdout } = await execGit(['symbolic-ref', `refs/remotes/${REMOTE_NAME}/HEAD`], { cwd: PROJECT_ROOT });
|
|
881
|
+
detectedDefaultBranch = stdout.trim().replace('refs/remotes/', '');
|
|
882
|
+
} catch (e) {
|
|
883
|
+
detectedDefaultBranch = null;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function fetchAheadBehindForBranches(branches) {
|
|
888
|
+
if (!detectedDefaultBranch) return;
|
|
889
|
+
const visible = branches.slice(0, store.get('visibleBranchCount'));
|
|
890
|
+
const cache = new Map(store.get('aheadBehindCache'));
|
|
891
|
+
const promises = visible.map(async (branch) => {
|
|
892
|
+
if (isBaseBranch(branch.name)) return;
|
|
893
|
+
// Use local ref if local, otherwise remote ref
|
|
894
|
+
const branchRef = branch.isLocal ? branch.name : `${REMOTE_NAME}/${branch.name}`;
|
|
895
|
+
const [abResult, diffResult] = await Promise.all([
|
|
896
|
+
getAheadBehind(branchRef, detectedDefaultBranch, { cwd: PROJECT_ROOT }),
|
|
897
|
+
getDiffShortstat(detectedDefaultBranch, branchRef, { cwd: PROJECT_ROOT }),
|
|
898
|
+
]);
|
|
899
|
+
cache.set(branch.name, {
|
|
900
|
+
ahead: abResult.ahead,
|
|
901
|
+
behind: abResult.behind,
|
|
902
|
+
linesAdded: diffResult.added,
|
|
903
|
+
linesDeleted: diffResult.deleted,
|
|
904
|
+
});
|
|
905
|
+
});
|
|
906
|
+
await Promise.all(promises);
|
|
907
|
+
store.setState({ aheadBehindCache: cache });
|
|
908
|
+
render();
|
|
909
|
+
}
|
|
910
|
+
|
|
861
911
|
// formatTimeAgo imported from src/utils/time.js
|
|
862
912
|
|
|
863
913
|
// truncate imported from src/ui/ansi.js
|
|
@@ -1118,6 +1168,7 @@ function renderCasinoStats(startRow) {
|
|
|
1118
1168
|
function getRenderState() {
|
|
1119
1169
|
const s = store.getState();
|
|
1120
1170
|
s.clientCount = clients.size;
|
|
1171
|
+
s.sessionStats = sessionStats.getStats();
|
|
1121
1172
|
return s;
|
|
1122
1173
|
}
|
|
1123
1174
|
|
|
@@ -1141,7 +1192,8 @@ function render() {
|
|
|
1141
1192
|
renderer.renderHeader(state, write);
|
|
1142
1193
|
const logStart = renderer.renderBranchList(state, write);
|
|
1143
1194
|
const statsStart = renderer.renderActivityLog(state, write, logStart);
|
|
1144
|
-
|
|
1195
|
+
const casinoStart = renderer.renderSessionStats(state, write, statsStart);
|
|
1196
|
+
renderCasinoStats(casinoStart);
|
|
1145
1197
|
renderer.renderFooter(state, write);
|
|
1146
1198
|
|
|
1147
1199
|
// Casino mode: full border (top, bottom, left, right)
|
|
@@ -1819,6 +1871,9 @@ async function pollGitChanges() {
|
|
|
1819
1871
|
casino.recordPoll(false);
|
|
1820
1872
|
}
|
|
1821
1873
|
|
|
1874
|
+
// Session stats: always track polls (independent of casino mode)
|
|
1875
|
+
sessionStats.recordPoll(notifyBranches.length > 0);
|
|
1876
|
+
|
|
1822
1877
|
// Remember which branch was selected before updating the list
|
|
1823
1878
|
const { selectedBranchName: prevSelName, selectedIndex: prevSelIdx } = store.getState();
|
|
1824
1879
|
const previouslySelectedName = prevSelName || (currentBranches[prevSelIdx] ? currentBranches[prevSelIdx].name : null);
|
|
@@ -1875,6 +1930,9 @@ async function pollGitChanges() {
|
|
|
1875
1930
|
});
|
|
1876
1931
|
}
|
|
1877
1932
|
|
|
1933
|
+
// Background ahead/behind fetch for visible branches
|
|
1934
|
+
fetchAheadBehindForBranches(pollFilteredBranches).catch(() => {});
|
|
1935
|
+
|
|
1878
1936
|
// AUTO-PULL: If current branch has remote updates, pull automatically (if enabled)
|
|
1879
1937
|
const autoPullBranchName = store.get('currentBranch');
|
|
1880
1938
|
const currentInfo = store.get('branches').find(b => b.name === autoPullBranchName);
|
|
@@ -1897,11 +1955,14 @@ async function pollGitChanges() {
|
|
|
1897
1955
|
// Reload browsers
|
|
1898
1956
|
notifyClients();
|
|
1899
1957
|
|
|
1900
|
-
//
|
|
1901
|
-
if (
|
|
1958
|
+
// Calculate actual diff for stats tracking
|
|
1959
|
+
if (oldCommit) {
|
|
1902
1960
|
const diffStats = await getDiffStats(oldCommit, 'HEAD');
|
|
1903
1961
|
const totalLines = diffStats.added + diffStats.deleted;
|
|
1904
|
-
|
|
1962
|
+
// Always track session churn
|
|
1963
|
+
sessionStats.recordChurn(diffStats.added, diffStats.deleted);
|
|
1964
|
+
// Casino mode: trigger win effect
|
|
1965
|
+
if (store.get('casinoModeEnabled') && totalLines > 0) {
|
|
1905
1966
|
casino.triggerWin(diffStats.added, diffStats.deleted, render);
|
|
1906
1967
|
const winLevel = casino.getWinLevel(totalLines);
|
|
1907
1968
|
if (winLevel) {
|
|
@@ -2975,6 +3036,11 @@ async function start() {
|
|
|
2975
3036
|
store.setState({ selectedBranchName: initBranches[0].name });
|
|
2976
3037
|
}
|
|
2977
3038
|
|
|
3039
|
+
// Detect default branch for ahead/behind counts, then fetch initial data
|
|
3040
|
+
detectDefaultBranch().then(() => {
|
|
3041
|
+
fetchAheadBehindForBranches(initBranches).catch(() => {});
|
|
3042
|
+
}).catch(() => {});
|
|
3043
|
+
|
|
2978
3044
|
// Load sparklines and action cache in background
|
|
2979
3045
|
refreshAllSparklines().catch(() => {});
|
|
2980
3046
|
initActionCache().then(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-watchtower",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.0",
|
|
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": {
|
|
@@ -87,8 +87,5 @@
|
|
|
87
87
|
}
|
|
88
88
|
]
|
|
89
89
|
]
|
|
90
|
-
},
|
|
91
|
-
"dependencies": {
|
|
92
|
-
"posthog-node": "^5.28.0"
|
|
93
90
|
}
|
|
94
91
|
}
|
package/src/git/branch.js
CHANGED
|
@@ -225,7 +225,7 @@ async function checkout(branchName, options = {}) {
|
|
|
225
225
|
const { stdout: localBranches } = await execGit(['branch', '--list'], { cwd });
|
|
226
226
|
const hasLocal = localBranches
|
|
227
227
|
.split('\n')
|
|
228
|
-
.some((b) => b.trim().replace(
|
|
228
|
+
.some((b) => b.trim().replace(/^\* /, '') === safeName);
|
|
229
229
|
|
|
230
230
|
if (hasLocal) {
|
|
231
231
|
// Local branch exists - just check out
|
|
@@ -335,7 +335,7 @@ async function getLocalBranches(cwd) {
|
|
|
335
335
|
const { stdout } = await execGit(['branch', '--list'], { cwd });
|
|
336
336
|
return stdout
|
|
337
337
|
.split('\n')
|
|
338
|
-
.map((b) => b.trim().replace(
|
|
338
|
+
.map((b) => b.trim().replace(/^\* /, ''))
|
|
339
339
|
.filter(Boolean);
|
|
340
340
|
} catch (error) {
|
|
341
341
|
return [];
|
package/src/git/commands.js
CHANGED
|
@@ -13,6 +13,9 @@ const DEFAULT_TIMEOUT = 30000;
|
|
|
13
13
|
// Longer timeout for fetch operations (60 seconds)
|
|
14
14
|
const FETCH_TIMEOUT = 60000;
|
|
15
15
|
|
|
16
|
+
// Short timeout for quick local operations (5 seconds)
|
|
17
|
+
const SHORT_TIMEOUT = 5000;
|
|
18
|
+
|
|
16
19
|
/**
|
|
17
20
|
* Execute a git command safely using execFile (no shell).
|
|
18
21
|
* @param {string | string[]} args - Git arguments as an array (e.g. ['log', '--oneline'])
|
|
@@ -393,6 +396,51 @@ async function deleteLocalBranch(branchName, options = {}) {
|
|
|
393
396
|
}
|
|
394
397
|
}
|
|
395
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Get ahead/behind counts for a branch relative to a base ref.
|
|
401
|
+
* Uses `git rev-list --left-right --count base...branch`.
|
|
402
|
+
* @param {string} branchRef - Branch ref (e.g. "feature/foo" or "origin/feature/foo")
|
|
403
|
+
* @param {string} baseRef - Base ref to compare against (e.g. "origin/main")
|
|
404
|
+
* @param {Object} [options] - Options
|
|
405
|
+
* @param {string} [options.cwd] - Working directory
|
|
406
|
+
* @returns {Promise<{ahead: number, behind: number}>}
|
|
407
|
+
*/
|
|
408
|
+
async function getAheadBehind(branchRef, baseRef, options = {}) {
|
|
409
|
+
try {
|
|
410
|
+
const { stdout } = await execGit(
|
|
411
|
+
['rev-list', '--left-right', '--count', `${baseRef}...${branchRef}`],
|
|
412
|
+
{ ...options, timeout: SHORT_TIMEOUT }
|
|
413
|
+
);
|
|
414
|
+
const parts = stdout.trim().split(/\s+/);
|
|
415
|
+
return {
|
|
416
|
+
behind: parseInt(parts[0], 10) || 0,
|
|
417
|
+
ahead: parseInt(parts[1], 10) || 0,
|
|
418
|
+
};
|
|
419
|
+
} catch (e) {
|
|
420
|
+
return { ahead: 0, behind: 0 };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Get diff stats (lines added/deleted) between two refs using three-dot syntax.
|
|
426
|
+
* @param {string} baseRef - Base ref (e.g. "origin/main")
|
|
427
|
+
* @param {string} branchRef - Branch ref (e.g. "feature/foo")
|
|
428
|
+
* @param {Object} [options] - Options
|
|
429
|
+
* @param {string} [options.cwd] - Working directory
|
|
430
|
+
* @returns {Promise<{added: number, deleted: number}>}
|
|
431
|
+
*/
|
|
432
|
+
async function getDiffShortstat(baseRef, branchRef, options = {}) {
|
|
433
|
+
try {
|
|
434
|
+
const { stdout } = await execGit(
|
|
435
|
+
['diff', '--shortstat', `${baseRef}...${branchRef}`],
|
|
436
|
+
{ ...options, timeout: SHORT_TIMEOUT }
|
|
437
|
+
);
|
|
438
|
+
return parseDiffStats(stdout);
|
|
439
|
+
} catch (e) {
|
|
440
|
+
return { added: 0, deleted: 0 };
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
396
444
|
module.exports = {
|
|
397
445
|
execGit,
|
|
398
446
|
execGitSilent,
|
|
@@ -411,6 +459,8 @@ module.exports = {
|
|
|
411
459
|
parseDiffStats,
|
|
412
460
|
getDiffStats,
|
|
413
461
|
deleteLocalBranch,
|
|
462
|
+
getAheadBehind,
|
|
463
|
+
getDiffShortstat,
|
|
414
464
|
DEFAULT_TIMEOUT,
|
|
415
465
|
FETCH_TIMEOUT,
|
|
416
466
|
};
|
package/src/state/store.js
CHANGED
|
@@ -95,6 +95,7 @@
|
|
|
95
95
|
* @property {boolean} casinoModeEnabled - Casino mode enabled
|
|
96
96
|
* @property {Map<string, string>} sparklineCache - Branch sparkline cache
|
|
97
97
|
* @property {Map<string, Object>} branchPrStatusMap - Branch PR status cache
|
|
98
|
+
* @property {Map<string, Object>} aheadBehindCache - Branch ahead/behind cache
|
|
98
99
|
* @property {string} serverMode - Server mode ('static' | 'command' | 'none')
|
|
99
100
|
* @property {boolean} noServer - No server mode
|
|
100
101
|
* @property {number} port - Server port
|
|
@@ -176,6 +177,7 @@ function getInitialState() {
|
|
|
176
177
|
// Caches (Maps — shallow-copied by getState())
|
|
177
178
|
sparklineCache: new Map(),
|
|
178
179
|
branchPrStatusMap: new Map(),
|
|
180
|
+
aheadBehindCache: new Map(),
|
|
179
181
|
|
|
180
182
|
// Config (set once at startup, treated as read-only after)
|
|
181
183
|
serverMode: 'static',
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session statistics tracker for Git Watchtower.
|
|
3
|
+
*
|
|
4
|
+
* Tracks real, grounded stats about repository activity during the current
|
|
5
|
+
* session — independent of casino mode. These stats are always available
|
|
6
|
+
* in normal mode.
|
|
7
|
+
*
|
|
8
|
+
* @module stats/session
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Internal state
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
let sessionStart = Date.now();
|
|
16
|
+
let totalLinesAdded = 0;
|
|
17
|
+
let totalLinesDeleted = 0;
|
|
18
|
+
let totalPolls = 0;
|
|
19
|
+
let pollsWithUpdates = 0;
|
|
20
|
+
let lastUpdateTime = null;
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// Recording
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Record that a poll cycle completed.
|
|
28
|
+
* @param {boolean} hadUpdates - Whether any branch had updates
|
|
29
|
+
*/
|
|
30
|
+
function recordPoll(hadUpdates) {
|
|
31
|
+
totalPolls++;
|
|
32
|
+
if (hadUpdates) {
|
|
33
|
+
pollsWithUpdates++;
|
|
34
|
+
lastUpdateTime = Date.now();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Record line changes from a detected update.
|
|
40
|
+
* @param {number} added - Lines added
|
|
41
|
+
* @param {number} deleted - Lines deleted
|
|
42
|
+
*/
|
|
43
|
+
function recordChurn(added, deleted) {
|
|
44
|
+
totalLinesAdded += added;
|
|
45
|
+
totalLinesDeleted += deleted;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ============================================================================
|
|
49
|
+
// Queries
|
|
50
|
+
// ============================================================================
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Format a duration in ms to a human-readable string.
|
|
54
|
+
* @param {number} ms
|
|
55
|
+
* @returns {string}
|
|
56
|
+
*/
|
|
57
|
+
function formatDuration(ms) {
|
|
58
|
+
const hours = Math.floor(ms / 3600000);
|
|
59
|
+
const minutes = Math.floor((ms % 3600000) / 60000);
|
|
60
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
61
|
+
return `${minutes}m`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get current session stats snapshot.
|
|
66
|
+
* @returns {{sessionDuration: string, linesAdded: number, linesDeleted: number, totalPolls: number, pollsWithUpdates: number, hitRate: number, lastUpdate: string|null}}
|
|
67
|
+
*/
|
|
68
|
+
function getStats() {
|
|
69
|
+
const elapsed = Date.now() - sessionStart;
|
|
70
|
+
|
|
71
|
+
const hitRate = totalPolls > 0
|
|
72
|
+
? Math.round((pollsWithUpdates / totalPolls) * 100)
|
|
73
|
+
: 0;
|
|
74
|
+
|
|
75
|
+
let lastUpdate = null;
|
|
76
|
+
if (lastUpdateTime) {
|
|
77
|
+
const sinceUpdate = Date.now() - lastUpdateTime;
|
|
78
|
+
lastUpdate = formatDuration(sinceUpdate) + ' ago';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
sessionDuration: formatDuration(elapsed),
|
|
83
|
+
linesAdded: totalLinesAdded,
|
|
84
|
+
linesDeleted: totalLinesDeleted,
|
|
85
|
+
totalPolls,
|
|
86
|
+
pollsWithUpdates,
|
|
87
|
+
hitRate,
|
|
88
|
+
lastUpdate,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Reset all session stats (e.g. for testing).
|
|
94
|
+
*/
|
|
95
|
+
function reset() {
|
|
96
|
+
sessionStart = Date.now();
|
|
97
|
+
totalLinesAdded = 0;
|
|
98
|
+
totalLinesDeleted = 0;
|
|
99
|
+
totalPolls = 0;
|
|
100
|
+
pollsWithUpdates = 0;
|
|
101
|
+
lastUpdateTime = null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = {
|
|
105
|
+
recordPoll,
|
|
106
|
+
recordChurn,
|
|
107
|
+
getStats,
|
|
108
|
+
reset,
|
|
109
|
+
};
|
|
@@ -1,23 +1,88 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PostHog
|
|
2
|
+
* Analytics wrapper using direct PostHog HTTP API (zero dependencies)
|
|
3
3
|
*
|
|
4
4
|
* All methods are safe no-ops when telemetry is disabled.
|
|
5
5
|
* Events are fire-and-forget — never blocks the TUI.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const https = require('https');
|
|
8
9
|
const { isTelemetryEnabled, getOrCreateDistinctId } = require('./config');
|
|
9
10
|
|
|
10
11
|
const POSTHOG_API_KEY = 'phc_fdGL8TVN5aFPXmQ4f1hI8y6sqnscD7dy9j5SM5gTylG';
|
|
11
|
-
const POSTHOG_HOST = '
|
|
12
|
+
const POSTHOG_HOST = 'us.i.posthog.com';
|
|
12
13
|
|
|
13
|
-
/** @type {import('posthog-node').PostHog | null} */
|
|
14
|
-
let client = null;
|
|
15
14
|
let distinctId = '';
|
|
16
15
|
let appVersion = '';
|
|
17
16
|
let enabled = false;
|
|
18
17
|
|
|
18
|
+
/** @type {Array<Record<string, any>>} */
|
|
19
|
+
let eventQueue = [];
|
|
20
|
+
let flushTimer = null;
|
|
21
|
+
const FLUSH_INTERVAL = 30000; // 30 seconds
|
|
22
|
+
const FLUSH_AT = 10; // flush when 10 events accumulated
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Send a batch of events to PostHog via HTTPS POST (fire-and-forget)
|
|
26
|
+
* @param {Array<Record<string, any>>} events
|
|
27
|
+
*/
|
|
28
|
+
function sendBatch(events) {
|
|
29
|
+
if (events.length === 0) return;
|
|
30
|
+
|
|
31
|
+
const payload = JSON.stringify({ api_key: POSTHOG_API_KEY, batch: events });
|
|
32
|
+
|
|
33
|
+
const req = https.request({
|
|
34
|
+
hostname: POSTHOG_HOST,
|
|
35
|
+
port: 443,
|
|
36
|
+
path: '/batch',
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'Content-Type': 'application/json',
|
|
40
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
41
|
+
},
|
|
42
|
+
timeout: 5000,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Fire-and-forget: ignore all errors and responses
|
|
46
|
+
req.on('error', () => {});
|
|
47
|
+
req.on('timeout', () => req.destroy());
|
|
48
|
+
req.end(payload);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Flush pending events
|
|
53
|
+
*/
|
|
54
|
+
function flush() {
|
|
55
|
+
if (eventQueue.length === 0) return;
|
|
56
|
+
const batch = eventQueue;
|
|
57
|
+
eventQueue = [];
|
|
58
|
+
sendBatch(batch);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Queue an event for sending
|
|
63
|
+
* @param {string} event - Event name
|
|
64
|
+
* @param {Record<string, any>} properties - Event properties
|
|
65
|
+
* @param {string} [overrideDistinctId] - Override distinct ID (for pre-consent events)
|
|
66
|
+
*/
|
|
67
|
+
function queueEvent(event, properties, overrideDistinctId) {
|
|
68
|
+
eventQueue.push({
|
|
69
|
+
event,
|
|
70
|
+
distinct_id: overrideDistinctId || distinctId,
|
|
71
|
+
properties: {
|
|
72
|
+
...properties,
|
|
73
|
+
$lib: 'git-watchtower',
|
|
74
|
+
$lib_version: appVersion,
|
|
75
|
+
},
|
|
76
|
+
timestamp: new Date().toISOString(),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
if (eventQueue.length >= FLUSH_AT) {
|
|
80
|
+
flush();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
19
84
|
/**
|
|
20
|
-
* Initialize the
|
|
85
|
+
* Initialize the analytics client if telemetry is enabled
|
|
21
86
|
* @param {{ version: string }} options
|
|
22
87
|
*/
|
|
23
88
|
function init({ version }) {
|
|
@@ -28,22 +93,12 @@ function init({ version }) {
|
|
|
28
93
|
return;
|
|
29
94
|
}
|
|
30
95
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
flushInterval: 30000,
|
|
38
|
-
requestTimeout: 5000,
|
|
39
|
-
disableGeoip: true,
|
|
40
|
-
});
|
|
41
|
-
enabled = true;
|
|
42
|
-
} catch {
|
|
43
|
-
// If posthog-node fails to load, silently disable telemetry
|
|
44
|
-
enabled = false;
|
|
45
|
-
client = null;
|
|
46
|
-
}
|
|
96
|
+
distinctId = getOrCreateDistinctId();
|
|
97
|
+
enabled = true;
|
|
98
|
+
|
|
99
|
+
// Periodic flush
|
|
100
|
+
flushTimer = setInterval(flush, FLUSH_INTERVAL);
|
|
101
|
+
if (flushTimer.unref) flushTimer.unref(); // Don't keep process alive
|
|
47
102
|
}
|
|
48
103
|
|
|
49
104
|
/**
|
|
@@ -52,18 +107,10 @@ function init({ version }) {
|
|
|
52
107
|
* @param {Record<string, any>} [properties] - Event properties
|
|
53
108
|
*/
|
|
54
109
|
function capture(event, properties = {}) {
|
|
55
|
-
if (!enabled
|
|
110
|
+
if (!enabled) return;
|
|
56
111
|
|
|
57
112
|
try {
|
|
58
|
-
|
|
59
|
-
distinctId,
|
|
60
|
-
event,
|
|
61
|
-
properties: {
|
|
62
|
-
...properties,
|
|
63
|
-
$lib: 'git-watchtower',
|
|
64
|
-
$lib_version: appVersion,
|
|
65
|
-
},
|
|
66
|
-
});
|
|
113
|
+
queueEvent(event, properties);
|
|
67
114
|
} catch {
|
|
68
115
|
// Never let telemetry errors affect the app
|
|
69
116
|
}
|
|
@@ -74,7 +121,7 @@ function capture(event, properties = {}) {
|
|
|
74
121
|
* @param {Error} error
|
|
75
122
|
*/
|
|
76
123
|
function captureError(error) {
|
|
77
|
-
if (!enabled
|
|
124
|
+
if (!enabled) return;
|
|
78
125
|
|
|
79
126
|
try {
|
|
80
127
|
const errorType = error.constructor?.name || 'Error';
|
|
@@ -86,41 +133,80 @@ function captureError(error) {
|
|
|
86
133
|
$exception_type: errorType,
|
|
87
134
|
$exception_message: errorMessage,
|
|
88
135
|
$exception_source: 'node',
|
|
89
|
-
$lib: 'git-watchtower',
|
|
90
|
-
$lib_version: appVersion,
|
|
91
136
|
};
|
|
92
137
|
|
|
93
|
-
// Include stack trace — contains only our package's file paths and
|
|
94
|
-
// line numbers, no user data. Required for PostHog error tracking
|
|
95
|
-
// to group, deduplicate, and show useful backtraces.
|
|
96
138
|
if (error.stack) {
|
|
97
139
|
properties.$exception_stack_trace_raw = error.stack;
|
|
98
140
|
}
|
|
99
141
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
142
|
+
queueEvent('$exception', properties);
|
|
143
|
+
} catch {
|
|
144
|
+
// Never let telemetry errors affect the app
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Send a one-off event that bypasses the enabled check.
|
|
150
|
+
* Used for prompt_shown and analytics_decision events that fire
|
|
151
|
+
* before the user has made their telemetry choice.
|
|
152
|
+
* @param {string} event - Event name
|
|
153
|
+
* @param {string} userDistinctId - The user's distinct ID
|
|
154
|
+
* @param {Record<string, any>} [properties] - Event properties
|
|
155
|
+
*/
|
|
156
|
+
function captureAlways(event, userDistinctId, properties = {}) {
|
|
157
|
+
try {
|
|
158
|
+
const payload = JSON.stringify({
|
|
159
|
+
api_key: POSTHOG_API_KEY,
|
|
160
|
+
batch: [{
|
|
161
|
+
event,
|
|
162
|
+
distinct_id: userDistinctId,
|
|
163
|
+
properties: {
|
|
164
|
+
...properties,
|
|
165
|
+
$lib: 'git-watchtower',
|
|
166
|
+
$lib_version: appVersion,
|
|
167
|
+
},
|
|
168
|
+
timestamp: new Date().toISOString(),
|
|
169
|
+
}],
|
|
104
170
|
});
|
|
171
|
+
|
|
172
|
+
const req = https.request({
|
|
173
|
+
hostname: POSTHOG_HOST,
|
|
174
|
+
port: 443,
|
|
175
|
+
path: '/batch',
|
|
176
|
+
method: 'POST',
|
|
177
|
+
headers: {
|
|
178
|
+
'Content-Type': 'application/json',
|
|
179
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
180
|
+
},
|
|
181
|
+
timeout: 5000,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
req.on('error', () => {});
|
|
185
|
+
req.on('timeout', () => req.destroy());
|
|
186
|
+
req.end(payload);
|
|
105
187
|
} catch {
|
|
106
188
|
// Never let telemetry errors affect the app
|
|
107
189
|
}
|
|
108
190
|
}
|
|
109
191
|
|
|
110
192
|
/**
|
|
111
|
-
* Flush pending events and shutdown
|
|
193
|
+
* Flush pending events and shutdown
|
|
112
194
|
* Call this before process exit to ensure events are sent.
|
|
113
195
|
* @returns {Promise<void>}
|
|
114
196
|
*/
|
|
115
197
|
async function shutdown() {
|
|
116
|
-
if (
|
|
198
|
+
if (flushTimer) {
|
|
199
|
+
clearInterval(flushTimer);
|
|
200
|
+
flushTimer = null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (!enabled) return;
|
|
117
204
|
|
|
118
205
|
try {
|
|
119
|
-
|
|
206
|
+
flush();
|
|
120
207
|
} catch {
|
|
121
208
|
// Best-effort flush
|
|
122
209
|
} finally {
|
|
123
|
-
client = null;
|
|
124
210
|
enabled = false;
|
|
125
211
|
}
|
|
126
212
|
}
|
|
@@ -137,6 +223,7 @@ module.exports = {
|
|
|
137
223
|
init,
|
|
138
224
|
capture,
|
|
139
225
|
captureError,
|
|
226
|
+
captureAlways,
|
|
140
227
|
shutdown,
|
|
141
228
|
isEnabled,
|
|
142
229
|
};
|
package/src/telemetry/index.js
CHANGED
|
@@ -58,9 +58,16 @@ async function promptIfNeeded(promptYesNo) {
|
|
|
58
58
|
console.log('\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518');
|
|
59
59
|
console.log('');
|
|
60
60
|
|
|
61
|
+
const distinctId = config.getOrCreateDistinctId();
|
|
62
|
+
|
|
63
|
+
// Fire analytics_prompt_shown event — always sent regardless of user's choice
|
|
64
|
+
analytics.captureAlways('analytics_prompt_shown', distinctId);
|
|
65
|
+
|
|
61
66
|
const answer = await promptYesNo('Enable anonymous telemetry to help improve Git Watchtower?', false);
|
|
62
67
|
|
|
63
|
-
|
|
68
|
+
// Fire analytics_decision event — always sent so we know opt-in/out rates
|
|
69
|
+
analytics.captureAlways('analytics_decision', distinctId, { opted_in: answer });
|
|
70
|
+
|
|
64
71
|
config.saveTelemetryConfig({
|
|
65
72
|
telemetryEnabled: answer,
|
|
66
73
|
distinctId,
|
|
@@ -79,6 +86,7 @@ module.exports = {
|
|
|
79
86
|
init: analytics.init,
|
|
80
87
|
capture: analytics.capture,
|
|
81
88
|
captureError: analytics.captureError,
|
|
89
|
+
captureAlways: analytics.captureAlways,
|
|
82
90
|
shutdown: analytics.shutdown,
|
|
83
91
|
isEnabled: analytics.isEnabled,
|
|
84
92
|
|
package/src/ui/renderer.js
CHANGED
|
@@ -21,10 +21,28 @@ const {
|
|
|
21
21
|
visibleLength,
|
|
22
22
|
stripAnsi,
|
|
23
23
|
} = require('../ui/ansi');
|
|
24
|
-
const { formatTimeAgo } = require('../utils/time');
|
|
24
|
+
const { formatTimeAgo, formatTimeCompact } = require('../utils/time');
|
|
25
25
|
const { isBaseBranch } = require('../git/pr');
|
|
26
26
|
const { version: PACKAGE_VERSION } = require('../../package.json');
|
|
27
27
|
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// Compact number formatting
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Format a number compactly: 0-999 as-is, 1k-9.9k with decimal,
|
|
34
|
+
* 10k-999k without decimal, 1m+ with decimal.
|
|
35
|
+
* @param {number} n
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function fmtCompact(n) {
|
|
39
|
+
if (n < 1000) return String(n);
|
|
40
|
+
if (n < 10000) return (n / 1000).toFixed(1) + 'k';
|
|
41
|
+
if (n < 1000000) return Math.round(n / 1000) + 'k';
|
|
42
|
+
if (n < 10000000) return (n / 1000000).toFixed(1) + 'm';
|
|
43
|
+
return Math.round(n / 1000000) + 'm';
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
// ---------------------------------------------------------------------------
|
|
29
47
|
// renderHeader
|
|
30
48
|
// ---------------------------------------------------------------------------
|
|
@@ -167,16 +185,58 @@ function renderBranchList(state, write) {
|
|
|
167
185
|
const branch = displayBranches[i];
|
|
168
186
|
const isSelected = i === state.selectedIndex;
|
|
169
187
|
const isCurrent = branch.name === state.currentBranch;
|
|
170
|
-
const timeAgo =
|
|
188
|
+
const timeAgo = formatTimeCompact(branch.date);
|
|
171
189
|
const sparkline = state.sparklineCache.get(branch.name) || ' ';
|
|
172
190
|
const prStatus = state.branchPrStatusMap.get(branch.name);
|
|
173
191
|
const isBranchBase = isBaseBranch(branch.name);
|
|
174
192
|
const isMerged = !isBranchBase && prStatus && prStatus.state === 'MERGED';
|
|
175
193
|
const hasOpenPr = prStatus && prStatus.state === 'OPEN';
|
|
194
|
+
const aheadBehind = state.aheadBehindCache ? state.aheadBehindCache.get(branch.name) : null;
|
|
195
|
+
|
|
196
|
+
// Diff stats: two right-justified columns, always reserving space for alignment
|
|
197
|
+
// Col 1: "+N/-N commits" right-justified in 16 chars
|
|
198
|
+
// Col 2: "+N/-N lines" right-justified in 14 chars
|
|
199
|
+
// Total: 16 + 1(gap) + 14 + 1(gap) = 32 chars always reserved
|
|
200
|
+
const COL_COMMITS = 16;
|
|
201
|
+
const COL_LINES = 14;
|
|
202
|
+
const DIFF_TAG_FIXED_LEN = COL_COMMITS + 1 + COL_LINES + 1; // 32 total
|
|
203
|
+
let diffTag = ' '.repeat(DIFF_TAG_FIXED_LEN); // blank by default for alignment
|
|
204
|
+
if (!isBranchBase && !branch.isDeleted && aheadBehind) {
|
|
205
|
+
const a = aheadBehind.ahead;
|
|
206
|
+
const b = aheadBehind.behind;
|
|
207
|
+
const la = aheadBehind.linesAdded || 0;
|
|
208
|
+
const ld = aheadBehind.linesDeleted || 0;
|
|
209
|
+
|
|
210
|
+
// Commits: "+N/-N commits", right-justified in COL_COMMITS
|
|
211
|
+
const aFmt = fmtCompact(a);
|
|
212
|
+
const bFmt = fmtCompact(b);
|
|
213
|
+
const commitText = '+' + aFmt + '/-' + bFmt + ' commits';
|
|
214
|
+
const cPad = ' '.repeat(Math.max(0, COL_COMMITS - commitText.length));
|
|
215
|
+
const commitColored = cPad +
|
|
216
|
+
(a > 0 ? ansi.brightCyan : ansi.dim + ansi.gray) + '+' + aFmt + ansi.reset +
|
|
217
|
+
ansi.gray + '/' + ansi.reset +
|
|
218
|
+
(b > 0 ? ansi.fg256(209) : ansi.dim + ansi.gray) + '-' + bFmt + ansi.reset +
|
|
219
|
+
ansi.gray + ' commits' + ansi.reset;
|
|
220
|
+
|
|
221
|
+
// Lines: "+N/-N lines", right-justified in COL_LINES
|
|
222
|
+
const laFmt = fmtCompact(la);
|
|
223
|
+
const ldFmt = fmtCompact(ld);
|
|
224
|
+
const linesText = '+' + laFmt + '/-' + ldFmt + ' lines';
|
|
225
|
+
const lPad = ' '.repeat(Math.max(0, COL_LINES - linesText.length));
|
|
226
|
+
const linesColored = lPad +
|
|
227
|
+
(la > 0 ? ansi.green : ansi.dim + ansi.gray) + '+' + laFmt + ansi.reset +
|
|
228
|
+
ansi.gray + '/' + ansi.reset +
|
|
229
|
+
(ld > 0 ? ansi.red : ansi.dim + ansi.gray) + '-' + ldFmt + ansi.reset +
|
|
230
|
+
ansi.gray + ' lines' + ansi.reset;
|
|
231
|
+
|
|
232
|
+
diffTag = commitColored + ' ' + linesColored + ' ';
|
|
233
|
+
}
|
|
176
234
|
|
|
177
235
|
write(ansi.moveTo(row, 2));
|
|
178
236
|
const cursor = isSelected ? ' \u25B6 ' : ' ';
|
|
179
|
-
|
|
237
|
+
// Reserve: cursor(3) + sparkline(7) + PR dot(1) + diffTag(32) + status(9) + gap(1) + time(4) + padding(2)
|
|
238
|
+
const fixedWidth = 27 + DIFF_TAG_FIXED_LEN;
|
|
239
|
+
const maxNameLen = contentWidth - fixedWidth;
|
|
180
240
|
const displayName = truncate(branch.name, maxNameLen);
|
|
181
241
|
const namePadding = Math.max(1, maxNameLen - displayName.length + 2);
|
|
182
242
|
|
|
@@ -201,17 +261,18 @@ function renderBranchList(state, write) {
|
|
|
201
261
|
|
|
202
262
|
write(' '.repeat(namePadding));
|
|
203
263
|
|
|
204
|
-
// Sparkline
|
|
264
|
+
// Sparkline (inside highlight)
|
|
205
265
|
if (isSelected) write(ansi.reset);
|
|
206
266
|
if (isMerged && !isCurrent) {
|
|
207
267
|
write(ansi.dim + ansi.fg256(60) + sparkline + ansi.reset);
|
|
208
268
|
} else {
|
|
209
269
|
write(ansi.fg256(39) + sparkline + ansi.reset);
|
|
210
270
|
}
|
|
211
|
-
if (isSelected) write(ansi.inverse);
|
|
212
271
|
|
|
213
|
-
//
|
|
272
|
+
// End the selected-row highlight here — everything after is outside
|
|
214
273
|
if (isSelected) write(ansi.reset);
|
|
274
|
+
|
|
275
|
+
// PR status dot (just before columns)
|
|
215
276
|
if (isMerged) {
|
|
216
277
|
write(ansi.dim + ansi.magenta + '\u25CF' + ansi.reset);
|
|
217
278
|
} else if (hasOpenPr) {
|
|
@@ -219,38 +280,28 @@ function renderBranchList(state, write) {
|
|
|
219
280
|
} else {
|
|
220
281
|
write(' ');
|
|
221
282
|
}
|
|
222
|
-
|
|
283
|
+
|
|
284
|
+
// Diff stats columns
|
|
285
|
+
write(diffTag);
|
|
223
286
|
|
|
224
287
|
// Status badge
|
|
225
288
|
if (branch.isDeleted) {
|
|
226
|
-
if (isSelected) write(ansi.reset);
|
|
227
289
|
write(ansi.red + ansi.dim + '\u2717 DELETED' + ansi.reset);
|
|
228
|
-
if (isSelected) write(ansi.inverse);
|
|
229
290
|
} else if (isMerged && !isCurrent && !branch.isNew && !branch.hasUpdates) {
|
|
230
|
-
if (isSelected) write(ansi.reset);
|
|
231
291
|
write(ansi.dim + ansi.magenta + '\u2713 MERGED ' + ansi.reset);
|
|
232
|
-
if (isSelected) write(ansi.inverse);
|
|
233
292
|
} else if (isCurrent) {
|
|
234
|
-
if (isSelected) write(ansi.reset);
|
|
235
293
|
write(ansi.green + '\u2605 CURRENT' + ansi.reset);
|
|
236
|
-
if (isSelected) write(ansi.inverse);
|
|
237
294
|
} else if (branch.isNew) {
|
|
238
|
-
if (isSelected) write(ansi.reset);
|
|
239
295
|
write(ansi.magenta + '\u2726 NEW ' + ansi.reset);
|
|
240
|
-
if (isSelected) write(ansi.inverse);
|
|
241
296
|
} else if (branch.hasUpdates) {
|
|
242
|
-
if (isSelected) write(ansi.reset);
|
|
243
297
|
write(ansi.yellow + '\u2193 UPDATES' + ansi.reset);
|
|
244
|
-
if (isSelected) write(ansi.inverse);
|
|
245
298
|
} else {
|
|
246
299
|
write(' ');
|
|
247
300
|
}
|
|
248
301
|
|
|
249
|
-
// Time ago
|
|
250
|
-
write('
|
|
251
|
-
|
|
252
|
-
write(ansi.gray + padLeft(timeAgo, 10) + ansi.reset);
|
|
253
|
-
if (isSelected) write(ansi.reset);
|
|
302
|
+
// Time ago (compact)
|
|
303
|
+
write(' ');
|
|
304
|
+
write(ansi.gray + padLeft(timeAgo, 4) + ansi.reset);
|
|
254
305
|
|
|
255
306
|
row++;
|
|
256
307
|
|
|
@@ -322,6 +373,110 @@ function renderActivityLog(state, write, startRow) {
|
|
|
322
373
|
return startRow + height;
|
|
323
374
|
}
|
|
324
375
|
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// renderSessionStats
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Render the session statistics panel (always visible in normal mode).
|
|
382
|
+
*
|
|
383
|
+
* @param {object} state
|
|
384
|
+
* @param {function} write
|
|
385
|
+
* @param {number} startRow - Row where the box should begin.
|
|
386
|
+
* @returns {number} The row immediately after the session stats box.
|
|
387
|
+
*/
|
|
388
|
+
function renderSessionStats(state, write, startRow) {
|
|
389
|
+
if (state.casinoModeEnabled) return startRow;
|
|
390
|
+
|
|
391
|
+
const stats = state.sessionStats;
|
|
392
|
+
if (!stats) return startRow;
|
|
393
|
+
|
|
394
|
+
const boxWidth = state.terminalWidth;
|
|
395
|
+
const contentWidth = boxWidth - 4;
|
|
396
|
+
const STALE_DAYS = 30;
|
|
397
|
+
const STALE_WARNING_THRESHOLD = 5;
|
|
398
|
+
|
|
399
|
+
// Count active vs stale branches
|
|
400
|
+
const branches = state.branches || [];
|
|
401
|
+
const now = Date.now();
|
|
402
|
+
const staleMs = STALE_DAYS * 24 * 60 * 60 * 1000;
|
|
403
|
+
let staleBranches = 0;
|
|
404
|
+
let activeBranches = 0;
|
|
405
|
+
for (const b of branches) {
|
|
406
|
+
if (b.date && (now - b.date.getTime()) > staleMs) {
|
|
407
|
+
staleBranches++;
|
|
408
|
+
} else {
|
|
409
|
+
activeBranches++;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const showStaleHint = staleBranches >= STALE_WARNING_THRESHOLD;
|
|
414
|
+
|
|
415
|
+
// Build the stats line to measure if it fits in one row
|
|
416
|
+
// Parts: Duration | Lines | Polls (hits, %) | Last update | Branches: N active, N stale
|
|
417
|
+
let partsPlain = 'Duration: ' + stats.sessionDuration
|
|
418
|
+
+ ' | Lines: +' + stats.linesAdded + '/-' + stats.linesDeleted
|
|
419
|
+
+ ' | Polls: ' + stats.totalPolls;
|
|
420
|
+
if (stats.pollsWithUpdates > 0) {
|
|
421
|
+
partsPlain += ' (' + stats.pollsWithUpdates + ' hits, ' + stats.hitRate + '%)';
|
|
422
|
+
}
|
|
423
|
+
if (stats.lastUpdate) {
|
|
424
|
+
partsPlain += ' | Last update: ' + stats.lastUpdate;
|
|
425
|
+
}
|
|
426
|
+
partsPlain += ' | Branches: ' + activeBranches + ' active';
|
|
427
|
+
if (staleBranches > 0) {
|
|
428
|
+
partsPlain += ', ' + staleBranches + ' stale (>' + STALE_DAYS + 'd)';
|
|
429
|
+
}
|
|
430
|
+
if (showStaleHint) {
|
|
431
|
+
partsPlain += ' — press d to clean up';
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const fitOneLine = partsPlain.length <= contentWidth;
|
|
435
|
+
const height = fitOneLine ? 4 : 5;
|
|
436
|
+
|
|
437
|
+
// Don't draw if not enough space
|
|
438
|
+
if (startRow + height > state.terminalHeight - 3) return startRow;
|
|
439
|
+
|
|
440
|
+
write(drawBox(startRow, 1, boxWidth, height, 'SESSION STATS', ansi.gray));
|
|
441
|
+
|
|
442
|
+
// Clear content area
|
|
443
|
+
for (let i = 1; i < height - 1; i++) {
|
|
444
|
+
write(ansi.moveTo(startRow + i, 2));
|
|
445
|
+
write(' '.repeat(boxWidth - 2));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Line 1: duration, lines, polls, last update
|
|
449
|
+
write(ansi.moveTo(startRow + 1, 3));
|
|
450
|
+
write(ansi.gray + 'Duration: ' + ansi.reset + ansi.cyan + stats.sessionDuration + ansi.reset);
|
|
451
|
+
write(ansi.gray + ' | Lines: ' + ansi.reset);
|
|
452
|
+
write(ansi.green + '+' + stats.linesAdded + ansi.reset);
|
|
453
|
+
write(ansi.gray + '/' + ansi.reset);
|
|
454
|
+
write(ansi.red + '-' + stats.linesDeleted + ansi.reset);
|
|
455
|
+
write(ansi.gray + ' | Polls: ' + ansi.reset + stats.totalPolls);
|
|
456
|
+
if (stats.pollsWithUpdates > 0) {
|
|
457
|
+
write(ansi.gray + ' (' + ansi.reset + ansi.green + stats.pollsWithUpdates + ' hits' + ansi.reset + ansi.gray + ', ' + stats.hitRate + '%)' + ansi.reset);
|
|
458
|
+
}
|
|
459
|
+
if (stats.lastUpdate) {
|
|
460
|
+
write(ansi.gray + ' | Last update: ' + ansi.reset + ansi.yellow + stats.lastUpdate + ansi.reset);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Branch counts — same line or next line depending on width
|
|
464
|
+
const branchRow = fitOneLine ? startRow + 1 : startRow + 2;
|
|
465
|
+
if (!fitOneLine) {
|
|
466
|
+
write(ansi.moveTo(branchRow, 3));
|
|
467
|
+
}
|
|
468
|
+
write(ansi.gray + ' | Branches: ' + ansi.reset + ansi.green + activeBranches + ' active' + ansi.reset);
|
|
469
|
+
if (staleBranches > 0) {
|
|
470
|
+
write(ansi.gray + ', ' + ansi.reset + ansi.yellow + staleBranches + ' stale' + ansi.reset);
|
|
471
|
+
write(ansi.gray + ' (>' + STALE_DAYS + 'd)' + ansi.reset);
|
|
472
|
+
}
|
|
473
|
+
if (showStaleHint) {
|
|
474
|
+
write(ansi.gray + ' \u2014 press ' + ansi.reset + ansi.white + 'd' + ansi.reset + ansi.gray + ' to clean up' + ansi.reset);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return startRow + height;
|
|
478
|
+
}
|
|
479
|
+
|
|
325
480
|
// ---------------------------------------------------------------------------
|
|
326
481
|
// renderCasinoStats (stub)
|
|
327
482
|
// ---------------------------------------------------------------------------
|
|
@@ -1424,6 +1579,7 @@ module.exports = {
|
|
|
1424
1579
|
renderHeader,
|
|
1425
1580
|
renderBranchList,
|
|
1426
1581
|
renderActivityLog,
|
|
1582
|
+
renderSessionStats,
|
|
1427
1583
|
renderCasinoStats,
|
|
1428
1584
|
renderFooter,
|
|
1429
1585
|
renderFlash,
|
package/src/utils/time.js
CHANGED
|
@@ -24,4 +24,24 @@ function formatTimeAgo(date) {
|
|
|
24
24
|
return `${diffDay} days ago`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Format a date as a compact relative time string (no "ago").
|
|
29
|
+
* @param {Date} date - The date to format
|
|
30
|
+
* @returns {string} Compact time string (e.g., "5m", "2h", "6d")
|
|
31
|
+
*/
|
|
32
|
+
function formatTimeCompact(date) {
|
|
33
|
+
const now = new Date();
|
|
34
|
+
const diffMs = now.getTime() - date.getTime();
|
|
35
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
36
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
37
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
38
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
39
|
+
|
|
40
|
+
if (diffSec < 10) return 'now';
|
|
41
|
+
if (diffSec < 60) return `${diffSec}s`;
|
|
42
|
+
if (diffMin < 60) return `${diffMin}m`;
|
|
43
|
+
if (diffHr < 24) return `${diffHr}h`;
|
|
44
|
+
return `${diffDay}d`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { formatTimeAgo, formatTimeCompact };
|