git-watchtower 1.0.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/LICENSE +21 -0
- package/README.md +331 -0
- package/bin/git-watchtower.js +2966 -0
- package/package.json +55 -0
|
@@ -0,0 +1,2966 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Git Watchtower - Branch Monitor & Dev Server (Zero Dependencies)
|
|
5
|
+
*
|
|
6
|
+
* Features:
|
|
7
|
+
* - Full terminal UI with branch dashboard
|
|
8
|
+
* - Shows active branches with 7-day activity sparklines
|
|
9
|
+
* - Arrow key navigation to switch between branches
|
|
10
|
+
* - Search/filter branches by name
|
|
11
|
+
* - Branch preview pane showing recent commits and changed files
|
|
12
|
+
* - Session history with undo support
|
|
13
|
+
* - Visual flash alerts when updates arrive
|
|
14
|
+
* - Audio notifications (toggle with 's')
|
|
15
|
+
* - Auto-pull and live reload via Server-Sent Events (SSE)
|
|
16
|
+
* - Edge case handling: merge conflicts, dirty working dir, detached HEAD
|
|
17
|
+
* - Network failure detection with offline indicator
|
|
18
|
+
* - Graceful shutdown handling
|
|
19
|
+
* - Support for custom dev server commands (Next.js, Vite, etc.)
|
|
20
|
+
*
|
|
21
|
+
* Usage:
|
|
22
|
+
* git-watchtower # Run with config or defaults
|
|
23
|
+
* git-watchtower --port 8080 # Override port
|
|
24
|
+
* git-watchtower --no-server # Branch monitoring only
|
|
25
|
+
* git-watchtower --init # Run configuration wizard
|
|
26
|
+
* git-watchtower --version # Show version
|
|
27
|
+
*
|
|
28
|
+
* No npm install required - uses only Node.js built-in modules.
|
|
29
|
+
*
|
|
30
|
+
* Keyboard Controls:
|
|
31
|
+
* ↑/k - Move selection up
|
|
32
|
+
* ↓/j - Move selection down
|
|
33
|
+
* Enter - Switch to selected branch
|
|
34
|
+
* / - Search/filter branches
|
|
35
|
+
* v - Preview selected branch (commits & files)
|
|
36
|
+
* h - Show switch history
|
|
37
|
+
* u - Undo last branch switch
|
|
38
|
+
* p - Force pull current branch
|
|
39
|
+
* r - Force reload all browsers (static mode)
|
|
40
|
+
* R - Restart dev server (command mode)
|
|
41
|
+
* l - View server logs (command mode)
|
|
42
|
+
* f - Fetch all branches + refresh sparklines
|
|
43
|
+
* s - Toggle sound notifications
|
|
44
|
+
* i - Show server info (port, connections)
|
|
45
|
+
* 1-0 - Set visible branch count (1-10)
|
|
46
|
+
* +/- - Increase/decrease visible branches
|
|
47
|
+
* q/Esc - Quit (Esc also clears search)
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
const http = require('http');
|
|
51
|
+
const fs = require('fs');
|
|
52
|
+
const path = require('path');
|
|
53
|
+
const { exec, spawn } = require('child_process');
|
|
54
|
+
const readline = require('readline');
|
|
55
|
+
|
|
56
|
+
// Package info for --version
|
|
57
|
+
const PACKAGE_VERSION = '1.0.0';
|
|
58
|
+
|
|
59
|
+
// ============================================================================
|
|
60
|
+
// Security & Validation
|
|
61
|
+
// ============================================================================
|
|
62
|
+
|
|
63
|
+
// Valid git branch name pattern (conservative)
|
|
64
|
+
const VALID_BRANCH_PATTERN = /^[a-zA-Z0-9_\-./]+$/;
|
|
65
|
+
|
|
66
|
+
function isValidBranchName(name) {
|
|
67
|
+
if (!name || typeof name !== 'string') return false;
|
|
68
|
+
if (name.length > 255) return false;
|
|
69
|
+
if (!VALID_BRANCH_PATTERN.test(name)) return false;
|
|
70
|
+
// Reject dangerous patterns
|
|
71
|
+
if (name.includes('..')) return false;
|
|
72
|
+
if (name.startsWith('-')) return false;
|
|
73
|
+
if (name.startsWith('/') || name.endsWith('/')) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sanitizeBranchName(name) {
|
|
78
|
+
if (!isValidBranchName(name)) {
|
|
79
|
+
throw new Error(`Invalid branch name: ${name}`);
|
|
80
|
+
}
|
|
81
|
+
return name;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function checkGitAvailable() {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
exec('git --version', (error) => {
|
|
87
|
+
resolve(!error);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ============================================================================
|
|
93
|
+
// Configuration File Support
|
|
94
|
+
// ============================================================================
|
|
95
|
+
|
|
96
|
+
const CONFIG_FILE_NAME = '.watchtowerrc.json';
|
|
97
|
+
const PROJECT_ROOT = process.cwd();
|
|
98
|
+
|
|
99
|
+
function getConfigPath() {
|
|
100
|
+
return path.join(PROJECT_ROOT, CONFIG_FILE_NAME);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function loadConfig() {
|
|
104
|
+
const configPath = getConfigPath();
|
|
105
|
+
if (fs.existsSync(configPath)) {
|
|
106
|
+
try {
|
|
107
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
108
|
+
return JSON.parse(content);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
console.error(`Warning: Could not parse ${CONFIG_FILE_NAME}: ${e.message}`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function saveConfig(config) {
|
|
118
|
+
const configPath = getConfigPath();
|
|
119
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getDefaultConfig() {
|
|
123
|
+
return {
|
|
124
|
+
// Server settings
|
|
125
|
+
server: {
|
|
126
|
+
mode: 'static', // 'static' | 'command' | 'none'
|
|
127
|
+
staticDir: 'public', // Directory for static mode
|
|
128
|
+
command: '', // Command for command mode (e.g., 'npm run dev')
|
|
129
|
+
port: 3000, // Port for static mode / display for command mode
|
|
130
|
+
restartOnSwitch: true, // Restart server on branch switch (command mode)
|
|
131
|
+
},
|
|
132
|
+
// Git settings
|
|
133
|
+
remoteName: 'origin', // Git remote name
|
|
134
|
+
autoPull: true, // Auto-pull when current branch has updates
|
|
135
|
+
gitPollInterval: 5000, // Polling interval in ms
|
|
136
|
+
// UI settings
|
|
137
|
+
soundEnabled: true,
|
|
138
|
+
visibleBranches: 7,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Migrate old config format to new format
|
|
143
|
+
function migrateConfig(config) {
|
|
144
|
+
if (config.server) return config; // Already new format
|
|
145
|
+
|
|
146
|
+
// Convert old format to new
|
|
147
|
+
const newConfig = getDefaultConfig();
|
|
148
|
+
|
|
149
|
+
if (config.noServer) {
|
|
150
|
+
newConfig.server.mode = 'none';
|
|
151
|
+
}
|
|
152
|
+
if (config.port) {
|
|
153
|
+
newConfig.server.port = config.port;
|
|
154
|
+
}
|
|
155
|
+
if (config.staticDir) {
|
|
156
|
+
newConfig.server.staticDir = config.staticDir;
|
|
157
|
+
}
|
|
158
|
+
if (config.gitPollInterval) {
|
|
159
|
+
newConfig.gitPollInterval = config.gitPollInterval;
|
|
160
|
+
}
|
|
161
|
+
if (typeof config.soundEnabled === 'boolean') {
|
|
162
|
+
newConfig.soundEnabled = config.soundEnabled;
|
|
163
|
+
}
|
|
164
|
+
if (config.visibleBranches) {
|
|
165
|
+
newConfig.visibleBranches = config.visibleBranches;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return newConfig;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function promptUser(question, defaultValue = '') {
|
|
172
|
+
const rl = readline.createInterface({
|
|
173
|
+
input: process.stdin,
|
|
174
|
+
output: process.stdout,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return new Promise((resolve) => {
|
|
178
|
+
const defaultHint = defaultValue !== '' ? ` (${defaultValue})` : '';
|
|
179
|
+
rl.question(`${question}${defaultHint}: `, (answer) => {
|
|
180
|
+
rl.close();
|
|
181
|
+
resolve(answer.trim() || defaultValue);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function promptYesNo(question, defaultValue = true) {
|
|
187
|
+
const defaultHint = defaultValue ? 'Y/n' : 'y/N';
|
|
188
|
+
const answer = await promptUser(`${question} [${defaultHint}]`, '');
|
|
189
|
+
if (answer === '') return defaultValue;
|
|
190
|
+
return answer.toLowerCase().startsWith('y');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function runConfigurationWizard() {
|
|
194
|
+
console.log('\n┌─────────────────────────────────────────────────────────┐');
|
|
195
|
+
console.log('│ 🏰 Git Watchtower Configuration Wizard │');
|
|
196
|
+
console.log('├─────────────────────────────────────────────────────────┤');
|
|
197
|
+
console.log('│ No configuration file found in this directory. │');
|
|
198
|
+
console.log('│ Let\'s set up Git Watchtower for this project. │');
|
|
199
|
+
console.log('└─────────────────────────────────────────────────────────┘\n');
|
|
200
|
+
|
|
201
|
+
const config = getDefaultConfig();
|
|
202
|
+
|
|
203
|
+
// Ask about server mode
|
|
204
|
+
console.log('Server Mode Options:');
|
|
205
|
+
console.log(' 1. Static - Serve static files with live reload (HTML/CSS/JS)');
|
|
206
|
+
console.log(' 2. Command - Run your own dev server (Next.js, Vite, etc.)');
|
|
207
|
+
console.log(' 3. None - Branch monitoring only (no server)\n');
|
|
208
|
+
|
|
209
|
+
const modeAnswer = await promptUser('Server mode (1/2/3)', '1');
|
|
210
|
+
if (modeAnswer === '2' || modeAnswer.toLowerCase() === 'command') {
|
|
211
|
+
config.server.mode = 'command';
|
|
212
|
+
} else if (modeAnswer === '3' || modeAnswer.toLowerCase() === 'none') {
|
|
213
|
+
config.server.mode = 'none';
|
|
214
|
+
} else {
|
|
215
|
+
config.server.mode = 'static';
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (config.server.mode === 'static') {
|
|
219
|
+
// Ask about port
|
|
220
|
+
const portAnswer = await promptUser('Server port', '3000');
|
|
221
|
+
const port = parseInt(portAnswer, 10);
|
|
222
|
+
if (!isNaN(port) && port > 0 && port < 65536) {
|
|
223
|
+
config.server.port = port;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Ask about static directory
|
|
227
|
+
config.server.staticDir = await promptUser('Static files directory', 'public');
|
|
228
|
+
} else if (config.server.mode === 'command') {
|
|
229
|
+
// Ask about command
|
|
230
|
+
console.log('\nExamples: npm run dev, next dev, nuxt dev, vite');
|
|
231
|
+
config.server.command = await promptUser('Dev server command', 'npm run dev');
|
|
232
|
+
|
|
233
|
+
// Ask about port (for display)
|
|
234
|
+
const portAnswer = await promptUser('Server port (for display)', '3000');
|
|
235
|
+
const port = parseInt(portAnswer, 10);
|
|
236
|
+
if (!isNaN(port) && port > 0 && port < 65536) {
|
|
237
|
+
config.server.port = port;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Ask about restart on switch
|
|
241
|
+
config.server.restartOnSwitch = await promptYesNo('Restart server when switching branches?', true);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Ask about auto-pull
|
|
245
|
+
config.autoPull = await promptYesNo('Auto-pull when current branch has updates?', true);
|
|
246
|
+
|
|
247
|
+
// Ask about git polling interval
|
|
248
|
+
const pollAnswer = await promptUser('Git polling interval in seconds', '5');
|
|
249
|
+
const pollSec = parseFloat(pollAnswer);
|
|
250
|
+
if (!isNaN(pollSec) && pollSec >= 1) {
|
|
251
|
+
config.gitPollInterval = Math.round(pollSec * 1000);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Ask about sound notifications
|
|
255
|
+
config.soundEnabled = await promptYesNo('Enable sound notifications for updates?', true);
|
|
256
|
+
|
|
257
|
+
// Ask about visible branches
|
|
258
|
+
const branchesAnswer = await promptUser('Default number of visible branches', '7');
|
|
259
|
+
const branches = parseInt(branchesAnswer, 10);
|
|
260
|
+
if (!isNaN(branches) && branches >= 1 && branches <= 20) {
|
|
261
|
+
config.visibleBranches = branches;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Save configuration
|
|
265
|
+
saveConfig(config);
|
|
266
|
+
|
|
267
|
+
console.log('\n✓ Configuration saved to ' + CONFIG_FILE_NAME);
|
|
268
|
+
console.log(' You can edit this file manually or delete it to reconfigure.\n');
|
|
269
|
+
|
|
270
|
+
return config;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function ensureConfig(cliArgs) {
|
|
274
|
+
// Check if --init flag was passed (force reconfiguration)
|
|
275
|
+
if (cliArgs.init) {
|
|
276
|
+
const config = await runConfigurationWizard();
|
|
277
|
+
return applyCliArgsToConfig(config, cliArgs);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Load existing config
|
|
281
|
+
let config = loadConfig();
|
|
282
|
+
|
|
283
|
+
// If no config exists, run the wizard or use defaults
|
|
284
|
+
if (!config) {
|
|
285
|
+
// Check if running non-interactively (no TTY)
|
|
286
|
+
if (!process.stdin.isTTY) {
|
|
287
|
+
console.log('No configuration file found. Using defaults.');
|
|
288
|
+
console.log('Run interactively or create .watchtowerrc.json manually.\n');
|
|
289
|
+
config = getDefaultConfig();
|
|
290
|
+
} else {
|
|
291
|
+
config = await runConfigurationWizard();
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
// Migrate old config format if needed
|
|
295
|
+
config = migrateConfig(config);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Merge CLI args over config (CLI takes precedence)
|
|
299
|
+
return applyCliArgsToConfig(config, cliArgs);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function applyCliArgsToConfig(config, cliArgs) {
|
|
303
|
+
// Server settings
|
|
304
|
+
if (cliArgs.mode !== null) {
|
|
305
|
+
config.server.mode = cliArgs.mode;
|
|
306
|
+
}
|
|
307
|
+
if (cliArgs.noServer) {
|
|
308
|
+
config.server.mode = 'none';
|
|
309
|
+
}
|
|
310
|
+
if (cliArgs.port !== null) {
|
|
311
|
+
config.server.port = cliArgs.port;
|
|
312
|
+
}
|
|
313
|
+
if (cliArgs.staticDir !== null) {
|
|
314
|
+
config.server.staticDir = cliArgs.staticDir;
|
|
315
|
+
}
|
|
316
|
+
if (cliArgs.command !== null) {
|
|
317
|
+
config.server.command = cliArgs.command;
|
|
318
|
+
}
|
|
319
|
+
if (cliArgs.restartOnSwitch !== null) {
|
|
320
|
+
config.server.restartOnSwitch = cliArgs.restartOnSwitch;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Git settings
|
|
324
|
+
if (cliArgs.remote !== null) {
|
|
325
|
+
config.remoteName = cliArgs.remote;
|
|
326
|
+
}
|
|
327
|
+
if (cliArgs.autoPull !== null) {
|
|
328
|
+
config.autoPull = cliArgs.autoPull;
|
|
329
|
+
}
|
|
330
|
+
if (cliArgs.pollInterval !== null) {
|
|
331
|
+
config.gitPollInterval = cliArgs.pollInterval;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// UI settings
|
|
335
|
+
if (cliArgs.sound !== null) {
|
|
336
|
+
config.soundEnabled = cliArgs.sound;
|
|
337
|
+
}
|
|
338
|
+
if (cliArgs.visibleBranches !== null) {
|
|
339
|
+
config.visibleBranches = cliArgs.visibleBranches;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return config;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Parse CLI arguments
|
|
346
|
+
function parseArgs() {
|
|
347
|
+
const args = process.argv.slice(2);
|
|
348
|
+
const result = {
|
|
349
|
+
// Server settings
|
|
350
|
+
mode: null,
|
|
351
|
+
noServer: false,
|
|
352
|
+
port: null,
|
|
353
|
+
staticDir: null,
|
|
354
|
+
command: null,
|
|
355
|
+
restartOnSwitch: null,
|
|
356
|
+
// Git settings
|
|
357
|
+
remote: null,
|
|
358
|
+
autoPull: null,
|
|
359
|
+
pollInterval: null,
|
|
360
|
+
// UI settings
|
|
361
|
+
sound: null,
|
|
362
|
+
visibleBranches: null,
|
|
363
|
+
// Actions
|
|
364
|
+
init: false,
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
for (let i = 0; i < args.length; i++) {
|
|
368
|
+
// Server settings
|
|
369
|
+
if (args[i] === '--mode' || args[i] === '-m') {
|
|
370
|
+
const mode = args[i + 1];
|
|
371
|
+
if (['static', 'command', 'none'].includes(mode)) {
|
|
372
|
+
result.mode = mode;
|
|
373
|
+
}
|
|
374
|
+
i++;
|
|
375
|
+
} else if (args[i] === '--port' || args[i] === '-p') {
|
|
376
|
+
const portValue = parseInt(args[i + 1], 10);
|
|
377
|
+
if (!isNaN(portValue) && portValue > 0 && portValue < 65536) {
|
|
378
|
+
result.port = portValue;
|
|
379
|
+
}
|
|
380
|
+
i++;
|
|
381
|
+
} else if (args[i] === '--no-server' || args[i] === '-n') {
|
|
382
|
+
result.noServer = true;
|
|
383
|
+
} else if (args[i] === '--static-dir') {
|
|
384
|
+
result.staticDir = args[i + 1];
|
|
385
|
+
i++;
|
|
386
|
+
} else if (args[i] === '--command' || args[i] === '-c') {
|
|
387
|
+
result.command = args[i + 1];
|
|
388
|
+
i++;
|
|
389
|
+
} else if (args[i] === '--restart-on-switch') {
|
|
390
|
+
result.restartOnSwitch = true;
|
|
391
|
+
} else if (args[i] === '--no-restart-on-switch') {
|
|
392
|
+
result.restartOnSwitch = false;
|
|
393
|
+
}
|
|
394
|
+
// Git settings
|
|
395
|
+
else if (args[i] === '--remote' || args[i] === '-r') {
|
|
396
|
+
result.remote = args[i + 1];
|
|
397
|
+
i++;
|
|
398
|
+
} else if (args[i] === '--auto-pull') {
|
|
399
|
+
result.autoPull = true;
|
|
400
|
+
} else if (args[i] === '--no-auto-pull') {
|
|
401
|
+
result.autoPull = false;
|
|
402
|
+
} else if (args[i] === '--poll-interval') {
|
|
403
|
+
const interval = parseInt(args[i + 1], 10);
|
|
404
|
+
if (!isNaN(interval) && interval > 0) {
|
|
405
|
+
result.pollInterval = interval;
|
|
406
|
+
}
|
|
407
|
+
i++;
|
|
408
|
+
}
|
|
409
|
+
// UI settings
|
|
410
|
+
else if (args[i] === '--sound') {
|
|
411
|
+
result.sound = true;
|
|
412
|
+
} else if (args[i] === '--no-sound') {
|
|
413
|
+
result.sound = false;
|
|
414
|
+
} else if (args[i] === '--visible-branches') {
|
|
415
|
+
const count = parseInt(args[i + 1], 10);
|
|
416
|
+
if (!isNaN(count) && count > 0) {
|
|
417
|
+
result.visibleBranches = count;
|
|
418
|
+
}
|
|
419
|
+
i++;
|
|
420
|
+
}
|
|
421
|
+
// Actions and info
|
|
422
|
+
else if (args[i] === '--init') {
|
|
423
|
+
result.init = true;
|
|
424
|
+
} else if (args[i] === '--version' || args[i] === '-v') {
|
|
425
|
+
console.log(`git-watchtower v${PACKAGE_VERSION}`);
|
|
426
|
+
process.exit(0);
|
|
427
|
+
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
428
|
+
console.log(`
|
|
429
|
+
Git Watchtower v${PACKAGE_VERSION} - Branch Monitor & Dev Server
|
|
430
|
+
|
|
431
|
+
Usage:
|
|
432
|
+
git-watchtower [options]
|
|
433
|
+
|
|
434
|
+
Server Options:
|
|
435
|
+
-m, --mode <mode> Server mode: static, command, or none
|
|
436
|
+
-p, --port <port> Server port (default: 3000)
|
|
437
|
+
-n, --no-server Shorthand for --mode none
|
|
438
|
+
--static-dir <dir> Directory for static file serving (default: public)
|
|
439
|
+
-c, --command <cmd> Command to run in command mode (e.g., "npm run dev")
|
|
440
|
+
--restart-on-switch Restart server on branch switch (default)
|
|
441
|
+
--no-restart-on-switch Don't restart server on branch switch
|
|
442
|
+
|
|
443
|
+
Git Options:
|
|
444
|
+
-r, --remote <name> Git remote name (default: origin)
|
|
445
|
+
--auto-pull Auto-pull on branch switch (default)
|
|
446
|
+
--no-auto-pull Don't auto-pull on branch switch
|
|
447
|
+
--poll-interval <ms> Git polling interval in ms (default: 5000)
|
|
448
|
+
|
|
449
|
+
UI Options:
|
|
450
|
+
--sound Enable sound notifications (default)
|
|
451
|
+
--no-sound Disable sound notifications
|
|
452
|
+
--visible-branches <n> Number of branches to display (default: 7)
|
|
453
|
+
|
|
454
|
+
General:
|
|
455
|
+
--init Run the configuration wizard
|
|
456
|
+
-v, --version Show version number
|
|
457
|
+
-h, --help Show this help message
|
|
458
|
+
|
|
459
|
+
Server Modes:
|
|
460
|
+
static Serve static files with live reload (default)
|
|
461
|
+
command Run your own dev server (Next.js, Vite, Nuxt, etc.)
|
|
462
|
+
none Branch monitoring only
|
|
463
|
+
|
|
464
|
+
Configuration:
|
|
465
|
+
On first run, Git Watchtower will prompt you to configure settings.
|
|
466
|
+
Settings are saved to .watchtowerrc.json in your project directory.
|
|
467
|
+
CLI options override config file settings for the current session.
|
|
468
|
+
|
|
469
|
+
Examples:
|
|
470
|
+
git-watchtower # Start with config or defaults
|
|
471
|
+
git-watchtower --init # Re-run configuration wizard
|
|
472
|
+
git-watchtower --no-server # Branch monitoring only
|
|
473
|
+
git-watchtower -p 8080 # Override port
|
|
474
|
+
git-watchtower -m command -c "npm run dev" # Use custom dev server
|
|
475
|
+
git-watchtower --no-sound --poll-interval 10000
|
|
476
|
+
`);
|
|
477
|
+
process.exit(0);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return result;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const cliArgs = parseArgs();
|
|
484
|
+
|
|
485
|
+
// Configuration - these will be set after config is loaded
|
|
486
|
+
let SERVER_MODE = 'static'; // 'static' | 'command' | 'none'
|
|
487
|
+
let NO_SERVER = false; // Derived from SERVER_MODE === 'none'
|
|
488
|
+
let SERVER_COMMAND = ''; // Command for command mode
|
|
489
|
+
let RESTART_ON_SWITCH = true; // Restart server on branch switch
|
|
490
|
+
let PORT = 3000;
|
|
491
|
+
let GIT_POLL_INTERVAL = 5000;
|
|
492
|
+
let STATIC_DIR = path.join(PROJECT_ROOT, 'public');
|
|
493
|
+
let REMOTE_NAME = 'origin';
|
|
494
|
+
let AUTO_PULL = true;
|
|
495
|
+
const MAX_LOG_ENTRIES = 10;
|
|
496
|
+
const MAX_SERVER_LOG_LINES = 500;
|
|
497
|
+
|
|
498
|
+
// Dynamic settings
|
|
499
|
+
let visibleBranchCount = 7;
|
|
500
|
+
let soundEnabled = true;
|
|
501
|
+
|
|
502
|
+
// Server process management (for command mode)
|
|
503
|
+
let serverProcess = null;
|
|
504
|
+
let serverLogBuffer = []; // In-memory log buffer
|
|
505
|
+
let serverRunning = false;
|
|
506
|
+
let serverCrashed = false;
|
|
507
|
+
let logViewMode = false; // Viewing logs modal
|
|
508
|
+
let logViewTab = 'server'; // 'activity' or 'server'
|
|
509
|
+
let logScrollOffset = 0; // Scroll position in log view
|
|
510
|
+
|
|
511
|
+
function applyConfig(config) {
|
|
512
|
+
// Server settings
|
|
513
|
+
SERVER_MODE = config.server?.mode || 'static';
|
|
514
|
+
NO_SERVER = SERVER_MODE === 'none';
|
|
515
|
+
SERVER_COMMAND = config.server?.command || '';
|
|
516
|
+
RESTART_ON_SWITCH = config.server?.restartOnSwitch !== false;
|
|
517
|
+
PORT = config.server?.port || parseInt(process.env.PORT, 10) || 3000;
|
|
518
|
+
STATIC_DIR = path.join(PROJECT_ROOT, config.server?.staticDir || 'public');
|
|
519
|
+
|
|
520
|
+
// Git settings
|
|
521
|
+
REMOTE_NAME = config.remoteName || 'origin';
|
|
522
|
+
AUTO_PULL = config.autoPull !== false;
|
|
523
|
+
GIT_POLL_INTERVAL = config.gitPollInterval || parseInt(process.env.GIT_POLL_INTERVAL, 10) || 5000;
|
|
524
|
+
|
|
525
|
+
// UI settings
|
|
526
|
+
visibleBranchCount = config.visibleBranches || 7;
|
|
527
|
+
soundEnabled = config.soundEnabled !== false;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Server log management
|
|
531
|
+
function addServerLog(line, isError = false) {
|
|
532
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
533
|
+
serverLogBuffer.push({ timestamp, line, isError });
|
|
534
|
+
if (serverLogBuffer.length > MAX_SERVER_LOG_LINES) {
|
|
535
|
+
serverLogBuffer.shift();
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function clearServerLog() {
|
|
540
|
+
serverLogBuffer = [];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Command mode server management
|
|
544
|
+
function startServerProcess() {
|
|
545
|
+
if (SERVER_MODE !== 'command' || !SERVER_COMMAND) return;
|
|
546
|
+
if (serverProcess) {
|
|
547
|
+
stopServerProcess();
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
clearServerLog();
|
|
551
|
+
serverCrashed = false;
|
|
552
|
+
serverRunning = false;
|
|
553
|
+
|
|
554
|
+
addLog(`Starting: ${SERVER_COMMAND}`, 'update');
|
|
555
|
+
addServerLog(`$ ${SERVER_COMMAND}`);
|
|
556
|
+
|
|
557
|
+
// Parse command and args
|
|
558
|
+
const parts = SERVER_COMMAND.split(' ');
|
|
559
|
+
const cmd = parts[0];
|
|
560
|
+
const args = parts.slice(1);
|
|
561
|
+
|
|
562
|
+
// Use shell on Windows, direct spawn elsewhere
|
|
563
|
+
const isWindows = process.platform === 'win32';
|
|
564
|
+
const spawnOptions = {
|
|
565
|
+
cwd: PROJECT_ROOT,
|
|
566
|
+
env: { ...process.env, FORCE_COLOR: '1' },
|
|
567
|
+
shell: isWindows,
|
|
568
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
try {
|
|
572
|
+
serverProcess = spawn(cmd, args, spawnOptions);
|
|
573
|
+
serverRunning = true;
|
|
574
|
+
|
|
575
|
+
serverProcess.stdout.on('data', (data) => {
|
|
576
|
+
const lines = data.toString().split('\n').filter(Boolean);
|
|
577
|
+
lines.forEach(line => addServerLog(line));
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
serverProcess.stderr.on('data', (data) => {
|
|
581
|
+
const lines = data.toString().split('\n').filter(Boolean);
|
|
582
|
+
lines.forEach(line => addServerLog(line, true));
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
serverProcess.on('error', (err) => {
|
|
586
|
+
serverRunning = false;
|
|
587
|
+
serverCrashed = true;
|
|
588
|
+
addServerLog(`Error: ${err.message}`, true);
|
|
589
|
+
addLog(`Server error: ${err.message}`, 'error');
|
|
590
|
+
render();
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
serverProcess.on('close', (code) => {
|
|
594
|
+
serverRunning = false;
|
|
595
|
+
if (code !== 0 && code !== null) {
|
|
596
|
+
serverCrashed = true;
|
|
597
|
+
addServerLog(`Process exited with code ${code}`, true);
|
|
598
|
+
addLog(`Server exited with code ${code}`, 'error');
|
|
599
|
+
} else {
|
|
600
|
+
addServerLog('Process stopped');
|
|
601
|
+
addLog('Server stopped', 'info');
|
|
602
|
+
}
|
|
603
|
+
serverProcess = null;
|
|
604
|
+
render();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
addLog(`Server started (pid: ${serverProcess.pid})`, 'success');
|
|
608
|
+
} catch (err) {
|
|
609
|
+
serverCrashed = true;
|
|
610
|
+
addServerLog(`Failed to start: ${err.message}`, true);
|
|
611
|
+
addLog(`Failed to start server: ${err.message}`, 'error');
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function stopServerProcess() {
|
|
616
|
+
if (!serverProcess) return;
|
|
617
|
+
|
|
618
|
+
addLog('Stopping server...', 'update');
|
|
619
|
+
|
|
620
|
+
// Try graceful shutdown first
|
|
621
|
+
if (process.platform === 'win32') {
|
|
622
|
+
spawn('taskkill', ['/pid', serverProcess.pid, '/f', '/t']);
|
|
623
|
+
} else {
|
|
624
|
+
serverProcess.kill('SIGTERM');
|
|
625
|
+
// Force kill after timeout
|
|
626
|
+
setTimeout(() => {
|
|
627
|
+
if (serverProcess) {
|
|
628
|
+
serverProcess.kill('SIGKILL');
|
|
629
|
+
}
|
|
630
|
+
}, 3000);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
serverProcess = null;
|
|
634
|
+
serverRunning = false;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function restartServerProcess() {
|
|
638
|
+
addLog('Restarting server...', 'update');
|
|
639
|
+
stopServerProcess();
|
|
640
|
+
setTimeout(() => {
|
|
641
|
+
startServerProcess();
|
|
642
|
+
render();
|
|
643
|
+
}, 500);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Network and polling state
|
|
647
|
+
let consecutiveNetworkFailures = 0;
|
|
648
|
+
let isOffline = false;
|
|
649
|
+
let lastFetchDuration = 0;
|
|
650
|
+
let slowFetchWarningShown = false;
|
|
651
|
+
let verySlowFetchWarningShown = false;
|
|
652
|
+
let adaptivePollInterval = GIT_POLL_INTERVAL;
|
|
653
|
+
let pollIntervalId = null;
|
|
654
|
+
|
|
655
|
+
// Git state
|
|
656
|
+
let isDetachedHead = false;
|
|
657
|
+
let hasMergeConflict = false;
|
|
658
|
+
|
|
659
|
+
// ANSI escape codes
|
|
660
|
+
const ESC = '\x1b';
|
|
661
|
+
const CSI = `${ESC}[`;
|
|
662
|
+
|
|
663
|
+
const ansi = {
|
|
664
|
+
// Screen
|
|
665
|
+
clearScreen: `${CSI}2J`,
|
|
666
|
+
clearLine: `${CSI}2K`,
|
|
667
|
+
moveTo: (row, col) => `${CSI}${row};${col}H`,
|
|
668
|
+
moveToTop: `${CSI}H`,
|
|
669
|
+
hideCursor: `${CSI}?25l`,
|
|
670
|
+
showCursor: `${CSI}?25h`,
|
|
671
|
+
saveScreen: `${CSI}?1049h`,
|
|
672
|
+
restoreScreen: `${CSI}?1049l`,
|
|
673
|
+
|
|
674
|
+
// Colors
|
|
675
|
+
reset: `${CSI}0m`,
|
|
676
|
+
bold: `${CSI}1m`,
|
|
677
|
+
dim: `${CSI}2m`,
|
|
678
|
+
italic: `${CSI}3m`,
|
|
679
|
+
underline: `${CSI}4m`,
|
|
680
|
+
inverse: `${CSI}7m`,
|
|
681
|
+
|
|
682
|
+
// Foreground colors
|
|
683
|
+
black: `${CSI}30m`,
|
|
684
|
+
red: `${CSI}31m`,
|
|
685
|
+
green: `${CSI}32m`,
|
|
686
|
+
yellow: `${CSI}33m`,
|
|
687
|
+
blue: `${CSI}34m`,
|
|
688
|
+
magenta: `${CSI}35m`,
|
|
689
|
+
cyan: `${CSI}36m`,
|
|
690
|
+
white: `${CSI}37m`,
|
|
691
|
+
gray: `${CSI}90m`,
|
|
692
|
+
|
|
693
|
+
// Background colors
|
|
694
|
+
bgBlack: `${CSI}40m`,
|
|
695
|
+
bgRed: `${CSI}41m`,
|
|
696
|
+
bgGreen: `${CSI}42m`,
|
|
697
|
+
bgYellow: `${CSI}43m`,
|
|
698
|
+
bgBlue: `${CSI}44m`,
|
|
699
|
+
bgMagenta: `${CSI}45m`,
|
|
700
|
+
bgCyan: `${CSI}46m`,
|
|
701
|
+
bgWhite: `${CSI}47m`,
|
|
702
|
+
|
|
703
|
+
// 256 colors
|
|
704
|
+
fg256: (n) => `${CSI}38;5;${n}m`,
|
|
705
|
+
bg256: (n) => `${CSI}48;5;${n}m`,
|
|
706
|
+
};
|
|
707
|
+
|
|
708
|
+
// Box drawing characters
|
|
709
|
+
const box = {
|
|
710
|
+
topLeft: '┌',
|
|
711
|
+
topRight: '┐',
|
|
712
|
+
bottomLeft: '└',
|
|
713
|
+
bottomRight: '┘',
|
|
714
|
+
horizontal: '─',
|
|
715
|
+
vertical: '│',
|
|
716
|
+
teeRight: '├',
|
|
717
|
+
teeLeft: '┤',
|
|
718
|
+
cross: '┼',
|
|
719
|
+
|
|
720
|
+
// Double line for flash
|
|
721
|
+
dTopLeft: '╔',
|
|
722
|
+
dTopRight: '╗',
|
|
723
|
+
dBottomLeft: '╚',
|
|
724
|
+
dBottomRight: '╝',
|
|
725
|
+
dHorizontal: '═',
|
|
726
|
+
dVertical: '║',
|
|
727
|
+
};
|
|
728
|
+
|
|
729
|
+
// State
|
|
730
|
+
let branches = [];
|
|
731
|
+
let selectedIndex = 0;
|
|
732
|
+
let selectedBranchName = null; // Track selection by name, not just index
|
|
733
|
+
let currentBranch = null;
|
|
734
|
+
let previousBranchStates = new Map(); // branch name -> commit hash
|
|
735
|
+
let knownBranchNames = new Set(); // Track known branches to detect NEW ones
|
|
736
|
+
let isPolling = false;
|
|
737
|
+
let pollingStatus = 'idle';
|
|
738
|
+
let terminalWidth = process.stdout.columns || 80;
|
|
739
|
+
let terminalHeight = process.stdout.rows || 24;
|
|
740
|
+
|
|
741
|
+
// SSE clients for live reload
|
|
742
|
+
const clients = new Set();
|
|
743
|
+
|
|
744
|
+
// Activity log entries
|
|
745
|
+
const activityLog = [];
|
|
746
|
+
|
|
747
|
+
// Flash state
|
|
748
|
+
let flashMessage = null;
|
|
749
|
+
let flashTimeout = null;
|
|
750
|
+
|
|
751
|
+
// Error toast state (more prominent than activity log)
|
|
752
|
+
let errorToast = null;
|
|
753
|
+
let errorToastTimeout = null;
|
|
754
|
+
|
|
755
|
+
// Preview pane state
|
|
756
|
+
let previewMode = false;
|
|
757
|
+
let previewData = null;
|
|
758
|
+
|
|
759
|
+
// Search/filter state
|
|
760
|
+
let searchMode = false;
|
|
761
|
+
let searchQuery = '';
|
|
762
|
+
let filteredBranches = null;
|
|
763
|
+
|
|
764
|
+
// Session history for undo
|
|
765
|
+
const switchHistory = [];
|
|
766
|
+
const MAX_HISTORY = 20;
|
|
767
|
+
|
|
768
|
+
// Sparkline cache (conservative - only update on manual fetch)
|
|
769
|
+
const sparklineCache = new Map(); // branch name -> sparkline string
|
|
770
|
+
let lastSparklineUpdate = 0;
|
|
771
|
+
const SPARKLINE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
772
|
+
|
|
773
|
+
// MIME types
|
|
774
|
+
const MIME_TYPES = {
|
|
775
|
+
'.html': 'text/html',
|
|
776
|
+
'.css': 'text/css',
|
|
777
|
+
'.js': 'application/javascript',
|
|
778
|
+
'.json': 'application/json',
|
|
779
|
+
'.png': 'image/png',
|
|
780
|
+
'.jpg': 'image/jpeg',
|
|
781
|
+
'.jpeg': 'image/jpeg',
|
|
782
|
+
'.gif': 'image/gif',
|
|
783
|
+
'.svg': 'image/svg+xml',
|
|
784
|
+
'.ico': 'image/x-icon',
|
|
785
|
+
'.webp': 'image/webp',
|
|
786
|
+
'.woff': 'font/woff',
|
|
787
|
+
'.woff2': 'font/woff2',
|
|
788
|
+
'.ttf': 'font/ttf',
|
|
789
|
+
'.xml': 'application/xml',
|
|
790
|
+
'.txt': 'text/plain',
|
|
791
|
+
'.md': 'text/markdown',
|
|
792
|
+
'.pdf': 'application/pdf',
|
|
793
|
+
};
|
|
794
|
+
|
|
795
|
+
// Live reload script
|
|
796
|
+
const LIVE_RELOAD_SCRIPT = `
|
|
797
|
+
<script>
|
|
798
|
+
(function() {
|
|
799
|
+
var source = new EventSource('/livereload');
|
|
800
|
+
source.onmessage = function(e) {
|
|
801
|
+
if (e.data === 'reload') location.reload();
|
|
802
|
+
};
|
|
803
|
+
})();
|
|
804
|
+
</script>
|
|
805
|
+
</body>`;
|
|
806
|
+
|
|
807
|
+
// ============================================================================
|
|
808
|
+
// Utility Functions
|
|
809
|
+
// ============================================================================
|
|
810
|
+
|
|
811
|
+
function execAsync(command, options = {}) {
|
|
812
|
+
return new Promise((resolve, reject) => {
|
|
813
|
+
exec(command, { cwd: PROJECT_ROOT, ...options }, (error, stdout, stderr) => {
|
|
814
|
+
if (error) {
|
|
815
|
+
reject({ error, stdout, stderr });
|
|
816
|
+
} else {
|
|
817
|
+
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
|
818
|
+
}
|
|
819
|
+
});
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function formatTimeAgo(date) {
|
|
824
|
+
const now = new Date();
|
|
825
|
+
const diffMs = now - date;
|
|
826
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
827
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
828
|
+
const diffHr = Math.floor(diffMin / 60);
|
|
829
|
+
const diffDay = Math.floor(diffHr / 24);
|
|
830
|
+
|
|
831
|
+
if (diffSec < 10) return 'just now';
|
|
832
|
+
if (diffSec < 60) return `${diffSec}s ago`;
|
|
833
|
+
if (diffMin < 60) return `${diffMin}m ago`;
|
|
834
|
+
if (diffHr < 24) return `${diffHr}h ago`;
|
|
835
|
+
if (diffDay === 1) return '1 day ago';
|
|
836
|
+
return `${diffDay} days ago`;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function truncate(str, maxLen) {
|
|
840
|
+
if (!str) return '';
|
|
841
|
+
if (str.length <= maxLen) return str;
|
|
842
|
+
return str.substring(0, maxLen - 3) + '...';
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function padRight(str, len) {
|
|
846
|
+
if (str.length >= len) return str.substring(0, len);
|
|
847
|
+
return str + ' '.repeat(len - str.length);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function getMaxBranchesForScreen() {
|
|
851
|
+
// Calculate max branches that fit: header(2) + branch box + log box(~12) + footer(2)
|
|
852
|
+
// Each branch takes 2 rows, plus 4 for box borders
|
|
853
|
+
const availableHeight = terminalHeight - 2 - MAX_LOG_ENTRIES - 5 - 2;
|
|
854
|
+
return Math.max(1, Math.floor(availableHeight / 2));
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function padLeft(str, len) {
|
|
858
|
+
if (str.length >= len) return str.substring(0, len);
|
|
859
|
+
return ' '.repeat(len - str.length) + str;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function addLog(message, type = 'info') {
|
|
863
|
+
const timestamp = new Date().toLocaleTimeString();
|
|
864
|
+
const icons = { info: '○', success: '✓', warning: '●', error: '✗', update: '⟳' };
|
|
865
|
+
const colors = { info: 'white', success: 'green', warning: 'yellow', error: 'red', update: 'cyan' };
|
|
866
|
+
|
|
867
|
+
activityLog.unshift({ timestamp, message, icon: icons[type] || '○', color: colors[type] || 'white' });
|
|
868
|
+
if (activityLog.length > MAX_LOG_ENTRIES) activityLog.pop();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// Sparkline characters (8 levels)
|
|
872
|
+
const SPARKLINE_CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
873
|
+
|
|
874
|
+
function generateSparkline(commitCounts) {
|
|
875
|
+
if (!commitCounts || commitCounts.length === 0) return ' ';
|
|
876
|
+
const max = Math.max(...commitCounts, 1);
|
|
877
|
+
return commitCounts.map(count => {
|
|
878
|
+
const level = Math.floor((count / max) * 7);
|
|
879
|
+
return SPARKLINE_CHARS[level];
|
|
880
|
+
}).join('');
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
async function getBranchSparkline(branchName) {
|
|
884
|
+
// Check cache first
|
|
885
|
+
const cached = sparklineCache.get(branchName);
|
|
886
|
+
if (cached && (Date.now() - lastSparklineUpdate) < SPARKLINE_CACHE_TTL) {
|
|
887
|
+
return cached;
|
|
888
|
+
}
|
|
889
|
+
return null; // Will be populated during sparkline refresh
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
async function refreshAllSparklines() {
|
|
893
|
+
const now = Date.now();
|
|
894
|
+
if ((now - lastSparklineUpdate) < SPARKLINE_CACHE_TTL) {
|
|
895
|
+
return; // Don't refresh too often
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
try {
|
|
899
|
+
for (const branch of branches.slice(0, 20)) { // Limit to top 20
|
|
900
|
+
if (branch.isDeleted) continue;
|
|
901
|
+
|
|
902
|
+
// Get commit counts for last 7 days
|
|
903
|
+
const { stdout } = await execAsync(
|
|
904
|
+
`git log origin/${branch.name} --since="7 days ago" --format="%ad" --date=format:"%Y-%m-%d" 2>/dev/null || git log ${branch.name} --since="7 days ago" --format="%ad" --date=format:"%Y-%m-%d" 2>/dev/null`
|
|
905
|
+
).catch(() => ({ stdout: '' }));
|
|
906
|
+
|
|
907
|
+
// Count commits per day
|
|
908
|
+
const dayCounts = new Map();
|
|
909
|
+
const dates = stdout.split('\n').filter(Boolean);
|
|
910
|
+
|
|
911
|
+
// Initialize last 7 days
|
|
912
|
+
for (let i = 6; i >= 0; i--) {
|
|
913
|
+
const d = new Date();
|
|
914
|
+
d.setDate(d.getDate() - i);
|
|
915
|
+
const key = d.toISOString().split('T')[0];
|
|
916
|
+
dayCounts.set(key, 0);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Count commits
|
|
920
|
+
for (const date of dates) {
|
|
921
|
+
dayCounts.set(date, (dayCounts.get(date) || 0) + 1);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const counts = Array.from(dayCounts.values());
|
|
925
|
+
sparklineCache.set(branch.name, generateSparkline(counts));
|
|
926
|
+
}
|
|
927
|
+
lastSparklineUpdate = now;
|
|
928
|
+
} catch (e) {
|
|
929
|
+
// Silently fail - sparklines are optional
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function getPreviewData(branchName) {
|
|
934
|
+
try {
|
|
935
|
+
// Get last 5 commits
|
|
936
|
+
const { stdout: logOutput } = await execAsync(
|
|
937
|
+
`git log origin/${branchName} -5 --oneline 2>/dev/null || git log ${branchName} -5 --oneline 2>/dev/null`
|
|
938
|
+
).catch(() => ({ stdout: '' }));
|
|
939
|
+
|
|
940
|
+
const commits = logOutput.split('\n').filter(Boolean).map(line => {
|
|
941
|
+
const [hash, ...msgParts] = line.split(' ');
|
|
942
|
+
return { hash, message: msgParts.join(' ') };
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// Get files changed (comparing to current branch)
|
|
946
|
+
let filesChanged = [];
|
|
947
|
+
try {
|
|
948
|
+
const { stdout: diffOutput } = await execAsync(
|
|
949
|
+
`git diff --stat --name-only HEAD...origin/${branchName} 2>/dev/null || git diff --stat --name-only HEAD...${branchName} 2>/dev/null`
|
|
950
|
+
);
|
|
951
|
+
filesChanged = diffOutput.split('\n').filter(Boolean).slice(0, 8);
|
|
952
|
+
} catch (e) {
|
|
953
|
+
// No diff available
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
return { commits, filesChanged };
|
|
957
|
+
} catch (e) {
|
|
958
|
+
return { commits: [], filesChanged: [] };
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function playSound() {
|
|
963
|
+
if (!soundEnabled) return;
|
|
964
|
+
|
|
965
|
+
// Try to play a friendly system sound (non-blocking)
|
|
966
|
+
const { platform } = process;
|
|
967
|
+
|
|
968
|
+
if (platform === 'darwin') {
|
|
969
|
+
// macOS: Use afplay with a gentle system sound
|
|
970
|
+
// Options: Glass, Pop, Ping, Purr, Submarine, Tink, Blow, Bottle, Frog, Funk, Hero, Morse, Sosumi
|
|
971
|
+
exec('afplay /System/Library/Sounds/Pop.aiff 2>/dev/null', { cwd: PROJECT_ROOT });
|
|
972
|
+
} else if (platform === 'linux') {
|
|
973
|
+
// Linux: Try paplay (PulseAudio) or aplay (ALSA) with a system sound
|
|
974
|
+
// First try freedesktop sound theme, then fall back to terminal bell
|
|
975
|
+
exec(
|
|
976
|
+
'paplay /usr/share/sounds/freedesktop/stereo/message-new-instant.oga 2>/dev/null || ' +
|
|
977
|
+
'paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || ' +
|
|
978
|
+
'aplay /usr/share/sounds/sound-icons/prompt.wav 2>/dev/null || ' +
|
|
979
|
+
'printf "\\a"',
|
|
980
|
+
{ cwd: PROJECT_ROOT }
|
|
981
|
+
);
|
|
982
|
+
} else {
|
|
983
|
+
// Windows or other: Terminal bell
|
|
984
|
+
process.stdout.write('\x07');
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// ============================================================================
|
|
989
|
+
// Terminal Rendering
|
|
990
|
+
// ============================================================================
|
|
991
|
+
|
|
992
|
+
function write(str) {
|
|
993
|
+
process.stdout.write(str);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function setTerminalTitle(title) {
|
|
997
|
+
// Set terminal tab/window title using ANSI escape sequence
|
|
998
|
+
// \x1b]0;title\x07 sets both window and tab title (most compatible)
|
|
999
|
+
process.stdout.write(`\x1b]0;${title}\x07`);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
function restoreTerminalTitle() {
|
|
1003
|
+
// Restore default terminal title behavior by clearing it
|
|
1004
|
+
// Some terminals will revert to showing the running process
|
|
1005
|
+
process.stdout.write('\x1b]0;\x07');
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function updateTerminalSize() {
|
|
1009
|
+
terminalWidth = process.stdout.columns || 80;
|
|
1010
|
+
terminalHeight = process.stdout.rows || 24;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function drawBox(row, col, width, height, title = '', titleColor = ansi.cyan) {
|
|
1014
|
+
// Top border
|
|
1015
|
+
write(ansi.moveTo(row, col));
|
|
1016
|
+
write(ansi.gray + box.topLeft + box.horizontal.repeat(width - 2) + box.topRight + ansi.reset);
|
|
1017
|
+
|
|
1018
|
+
// Title
|
|
1019
|
+
if (title) {
|
|
1020
|
+
write(ansi.moveTo(row, col + 2));
|
|
1021
|
+
write(ansi.gray + ' ' + titleColor + title + ansi.gray + ' ' + ansi.reset);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Sides
|
|
1025
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1026
|
+
write(ansi.moveTo(row + i, col));
|
|
1027
|
+
write(ansi.gray + box.vertical + ansi.reset);
|
|
1028
|
+
write(ansi.moveTo(row + i, col + width - 1));
|
|
1029
|
+
write(ansi.gray + box.vertical + ansi.reset);
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// Bottom border
|
|
1033
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
1034
|
+
write(ansi.gray + box.bottomLeft + box.horizontal.repeat(width - 2) + box.bottomRight + ansi.reset);
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function clearArea(row, col, width, height) {
|
|
1038
|
+
for (let i = 0; i < height; i++) {
|
|
1039
|
+
write(ansi.moveTo(row + i, col));
|
|
1040
|
+
write(' '.repeat(width));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function renderHeader() {
|
|
1045
|
+
const width = terminalWidth;
|
|
1046
|
+
let statusIcon = { idle: ansi.green + '●', fetching: ansi.yellow + '⟳', error: ansi.red + '●' }[pollingStatus];
|
|
1047
|
+
|
|
1048
|
+
// Override status for special states
|
|
1049
|
+
if (isOffline) {
|
|
1050
|
+
statusIcon = ansi.red + '⊘';
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const soundIcon = soundEnabled ? ansi.green + '🔔' : ansi.gray + '🔕';
|
|
1054
|
+
const projectName = path.basename(PROJECT_ROOT);
|
|
1055
|
+
|
|
1056
|
+
write(ansi.moveTo(1, 1));
|
|
1057
|
+
write(ansi.bgBlue + ansi.white + ansi.bold);
|
|
1058
|
+
|
|
1059
|
+
// Left side: Title + separator + project name
|
|
1060
|
+
const leftContent = ` 🏰 Git Watchtower ${ansi.dim}│${ansi.bold} ${projectName}`;
|
|
1061
|
+
const leftVisibleLen = 21 + projectName.length; // " 🏰 Git Watchtower │ " + projectName
|
|
1062
|
+
|
|
1063
|
+
write(leftContent);
|
|
1064
|
+
|
|
1065
|
+
// Warning badges (center area)
|
|
1066
|
+
let badges = '';
|
|
1067
|
+
let badgesVisibleLen = 0;
|
|
1068
|
+
if (SERVER_MODE === 'command' && serverCrashed) {
|
|
1069
|
+
const label = ' CRASHED ';
|
|
1070
|
+
badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
|
|
1071
|
+
badgesVisibleLen += 1 + label.length;
|
|
1072
|
+
}
|
|
1073
|
+
if (isOffline) {
|
|
1074
|
+
const label = ' OFFLINE ';
|
|
1075
|
+
badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
|
|
1076
|
+
badgesVisibleLen += 1 + label.length;
|
|
1077
|
+
}
|
|
1078
|
+
if (isDetachedHead) {
|
|
1079
|
+
const label = ' DETACHED HEAD ';
|
|
1080
|
+
badges += ' ' + ansi.bgYellow + ansi.black + label + ansi.bgBlue + ansi.white;
|
|
1081
|
+
badgesVisibleLen += 1 + label.length;
|
|
1082
|
+
}
|
|
1083
|
+
if (hasMergeConflict) {
|
|
1084
|
+
const label = ' MERGE CONFLICT ';
|
|
1085
|
+
badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
|
|
1086
|
+
badgesVisibleLen += 1 + label.length;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
write(badges);
|
|
1090
|
+
|
|
1091
|
+
// Right side: Server mode + URL + status icons
|
|
1092
|
+
let modeLabel = '';
|
|
1093
|
+
let modeBadge = '';
|
|
1094
|
+
if (SERVER_MODE === 'static') {
|
|
1095
|
+
modeLabel = ' STATIC ';
|
|
1096
|
+
modeBadge = ansi.bgCyan + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
|
|
1097
|
+
} else if (SERVER_MODE === 'command') {
|
|
1098
|
+
modeLabel = ' COMMAND ';
|
|
1099
|
+
modeBadge = ansi.bgGreen + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
|
|
1100
|
+
} else {
|
|
1101
|
+
modeLabel = ' MONITOR ';
|
|
1102
|
+
modeBadge = ansi.bgMagenta + ansi.white + modeLabel + ansi.bgBlue + ansi.white;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
let serverInfo = '';
|
|
1106
|
+
let serverInfoVisible = '';
|
|
1107
|
+
if (SERVER_MODE === 'none') {
|
|
1108
|
+
serverInfoVisible = '';
|
|
1109
|
+
} else {
|
|
1110
|
+
const statusDot = serverRunning ? ansi.green + '●' : (serverCrashed ? ansi.red + '●' : ansi.gray + '○');
|
|
1111
|
+
serverInfoVisible = `localhost:${PORT} `;
|
|
1112
|
+
serverInfo = statusDot + ansi.white + ` localhost:${PORT} `;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
const rightContent = `${modeBadge} ${serverInfo}${statusIcon}${ansi.bgBlue} ${soundIcon}${ansi.bgBlue} `;
|
|
1116
|
+
const rightVisibleLen = modeLabel.length + 1 + serverInfoVisible.length + 5; // mode + space + serverInfo + "● 🔔 "
|
|
1117
|
+
|
|
1118
|
+
// Calculate padding to fill full width
|
|
1119
|
+
const usedSpace = leftVisibleLen + badgesVisibleLen + rightVisibleLen;
|
|
1120
|
+
const padding = Math.max(1, width - usedSpace);
|
|
1121
|
+
write(' '.repeat(padding));
|
|
1122
|
+
write(rightContent);
|
|
1123
|
+
write(ansi.reset);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
function renderBranchList() {
|
|
1127
|
+
const startRow = 3;
|
|
1128
|
+
const boxWidth = terminalWidth;
|
|
1129
|
+
const contentWidth = boxWidth - 4; // Space between borders
|
|
1130
|
+
const height = Math.min(visibleBranchCount * 2 + 4, Math.floor(terminalHeight * 0.5));
|
|
1131
|
+
|
|
1132
|
+
// Determine which branches to show (filtered or all)
|
|
1133
|
+
const displayBranches = filteredBranches !== null ? filteredBranches : branches;
|
|
1134
|
+
const boxTitle = searchMode
|
|
1135
|
+
? `BRANCHES (/${searchQuery}_)`
|
|
1136
|
+
: 'ACTIVE BRANCHES';
|
|
1137
|
+
|
|
1138
|
+
drawBox(startRow, 1, boxWidth, height, boxTitle, ansi.cyan);
|
|
1139
|
+
|
|
1140
|
+
// Clear content area first (fixes border gaps)
|
|
1141
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1142
|
+
write(ansi.moveTo(startRow + i, 2));
|
|
1143
|
+
write(' '.repeat(contentWidth + 2));
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// Header line
|
|
1147
|
+
write(ansi.moveTo(startRow + 1, 2));
|
|
1148
|
+
write(ansi.gray + '─'.repeat(contentWidth + 2) + ansi.reset);
|
|
1149
|
+
|
|
1150
|
+
if (displayBranches.length === 0) {
|
|
1151
|
+
write(ansi.moveTo(startRow + 3, 4));
|
|
1152
|
+
if (searchMode && searchQuery) {
|
|
1153
|
+
write(ansi.gray + `No branches matching "${searchQuery}"` + ansi.reset);
|
|
1154
|
+
} else {
|
|
1155
|
+
write(ansi.gray + "No branches found. Press 'f' to fetch." + ansi.reset);
|
|
1156
|
+
}
|
|
1157
|
+
return startRow + height;
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
let row = startRow + 2;
|
|
1161
|
+
for (let i = 0; i < displayBranches.length && i < visibleBranchCount; i++) {
|
|
1162
|
+
const branch = displayBranches[i];
|
|
1163
|
+
const isSelected = i === selectedIndex;
|
|
1164
|
+
const isCurrent = branch.name === currentBranch;
|
|
1165
|
+
const timeAgo = formatTimeAgo(branch.date);
|
|
1166
|
+
const sparkline = sparklineCache.get(branch.name) || ' ';
|
|
1167
|
+
|
|
1168
|
+
// Branch name line
|
|
1169
|
+
write(ansi.moveTo(row, 2));
|
|
1170
|
+
|
|
1171
|
+
// Cursor indicator
|
|
1172
|
+
const cursor = isSelected ? ' ▶ ' : ' ';
|
|
1173
|
+
|
|
1174
|
+
// Branch name - adjust for sparkline
|
|
1175
|
+
const maxNameLen = contentWidth - 38; // Extra space for sparkline
|
|
1176
|
+
const displayName = truncate(branch.name, maxNameLen);
|
|
1177
|
+
|
|
1178
|
+
// Padding after name
|
|
1179
|
+
const namePadding = Math.max(1, maxNameLen - displayName.length + 2);
|
|
1180
|
+
|
|
1181
|
+
// Write the line
|
|
1182
|
+
if (isSelected) write(ansi.inverse);
|
|
1183
|
+
write(cursor);
|
|
1184
|
+
|
|
1185
|
+
if (branch.isDeleted) {
|
|
1186
|
+
write(ansi.gray + ansi.dim + displayName + ansi.reset);
|
|
1187
|
+
if (isSelected) write(ansi.inverse);
|
|
1188
|
+
} else if (isCurrent) {
|
|
1189
|
+
write(ansi.green + ansi.bold + displayName + ansi.reset);
|
|
1190
|
+
if (isSelected) write(ansi.inverse);
|
|
1191
|
+
} else if (branch.justUpdated) {
|
|
1192
|
+
write(ansi.yellow + displayName + ansi.reset);
|
|
1193
|
+
if (isSelected) write(ansi.inverse);
|
|
1194
|
+
branch.justUpdated = false;
|
|
1195
|
+
} else {
|
|
1196
|
+
write(displayName);
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
write(' '.repeat(namePadding));
|
|
1200
|
+
|
|
1201
|
+
// Sparkline (7 chars)
|
|
1202
|
+
if (isSelected) write(ansi.reset);
|
|
1203
|
+
write(ansi.fg256(39) + sparkline + ansi.reset); // Nice blue color
|
|
1204
|
+
if (isSelected) write(ansi.inverse);
|
|
1205
|
+
write(' ');
|
|
1206
|
+
|
|
1207
|
+
// Status badge
|
|
1208
|
+
if (branch.isDeleted) {
|
|
1209
|
+
if (isSelected) write(ansi.reset);
|
|
1210
|
+
write(ansi.red + ansi.dim + '✗ DELETED' + ansi.reset);
|
|
1211
|
+
if (isSelected) write(ansi.inverse);
|
|
1212
|
+
} else if (isCurrent) {
|
|
1213
|
+
if (isSelected) write(ansi.reset);
|
|
1214
|
+
write(ansi.green + '★ CURRENT' + ansi.reset);
|
|
1215
|
+
if (isSelected) write(ansi.inverse);
|
|
1216
|
+
} else if (branch.isNew) {
|
|
1217
|
+
if (isSelected) write(ansi.reset);
|
|
1218
|
+
write(ansi.magenta + '✦ NEW ' + ansi.reset);
|
|
1219
|
+
if (isSelected) write(ansi.inverse);
|
|
1220
|
+
} else if (branch.hasUpdates) {
|
|
1221
|
+
if (isSelected) write(ansi.reset);
|
|
1222
|
+
write(ansi.yellow + '↓ UPDATES' + ansi.reset);
|
|
1223
|
+
if (isSelected) write(ansi.inverse);
|
|
1224
|
+
} else {
|
|
1225
|
+
write(' ');
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
// Time ago
|
|
1229
|
+
write(' ');
|
|
1230
|
+
if (isSelected) write(ansi.reset);
|
|
1231
|
+
write(ansi.gray + padLeft(timeAgo, 10) + ansi.reset);
|
|
1232
|
+
|
|
1233
|
+
if (isSelected) write(ansi.reset);
|
|
1234
|
+
|
|
1235
|
+
row++;
|
|
1236
|
+
|
|
1237
|
+
// Commit info line
|
|
1238
|
+
write(ansi.moveTo(row, 2));
|
|
1239
|
+
write(' └─ ');
|
|
1240
|
+
write(ansi.cyan + (branch.commit || '???????') + ansi.reset);
|
|
1241
|
+
write(' • ');
|
|
1242
|
+
write(ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 22) + ansi.reset);
|
|
1243
|
+
|
|
1244
|
+
row++;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
return startRow + height;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function renderActivityLog(startRow) {
|
|
1251
|
+
const boxWidth = terminalWidth;
|
|
1252
|
+
const contentWidth = boxWidth - 4;
|
|
1253
|
+
const height = Math.min(MAX_LOG_ENTRIES + 3, terminalHeight - startRow - 4);
|
|
1254
|
+
|
|
1255
|
+
drawBox(startRow, 1, boxWidth, height, 'ACTIVITY LOG', ansi.gray);
|
|
1256
|
+
|
|
1257
|
+
// Clear content area first (fixes border gaps)
|
|
1258
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1259
|
+
write(ansi.moveTo(startRow + i, 2));
|
|
1260
|
+
write(' '.repeat(contentWidth + 2));
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
let row = startRow + 1;
|
|
1264
|
+
for (let i = 0; i < activityLog.length && i < height - 2; i++) {
|
|
1265
|
+
const entry = activityLog[i];
|
|
1266
|
+
write(ansi.moveTo(row, 3));
|
|
1267
|
+
write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
|
|
1268
|
+
write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
|
|
1269
|
+
write(truncate(entry.message, contentWidth - 16));
|
|
1270
|
+
row++;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
if (activityLog.length === 0) {
|
|
1274
|
+
write(ansi.moveTo(startRow + 1, 3));
|
|
1275
|
+
write(ansi.gray + 'No activity yet...' + ansi.reset);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
return startRow + height;
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function renderFooter() {
|
|
1282
|
+
const row = terminalHeight - 1;
|
|
1283
|
+
|
|
1284
|
+
write(ansi.moveTo(row, 1));
|
|
1285
|
+
write(ansi.bgBlack + ansi.white);
|
|
1286
|
+
write(' ');
|
|
1287
|
+
write(ansi.gray + '[↑↓]' + ansi.reset + ansi.bgBlack + ' Nav ');
|
|
1288
|
+
write(ansi.gray + '[/]' + ansi.reset + ansi.bgBlack + ' Search ');
|
|
1289
|
+
write(ansi.gray + '[v]' + ansi.reset + ansi.bgBlack + ' Preview ');
|
|
1290
|
+
write(ansi.gray + '[Enter]' + ansi.reset + ansi.bgBlack + ' Switch ');
|
|
1291
|
+
write(ansi.gray + '[h]' + ansi.reset + ansi.bgBlack + ' History ');
|
|
1292
|
+
write(ansi.gray + '[i]' + ansi.reset + ansi.bgBlack + ' Info ');
|
|
1293
|
+
|
|
1294
|
+
// Mode-specific keys
|
|
1295
|
+
if (!NO_SERVER) {
|
|
1296
|
+
write(ansi.gray + '[l]' + ansi.reset + ansi.bgBlack + ' Logs ');
|
|
1297
|
+
}
|
|
1298
|
+
if (SERVER_MODE === 'static') {
|
|
1299
|
+
write(ansi.gray + '[r]' + ansi.reset + ansi.bgBlack + ' Reload ');
|
|
1300
|
+
} else if (SERVER_MODE === 'command') {
|
|
1301
|
+
write(ansi.gray + '[R]' + ansi.reset + ansi.bgBlack + ' Restart ');
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
write(ansi.gray + '[±]' + ansi.reset + ansi.bgBlack + ' List:' + ansi.cyan + visibleBranchCount + ansi.reset + ansi.bgBlack + ' ');
|
|
1305
|
+
write(ansi.gray + '[q]' + ansi.reset + ansi.bgBlack + ' Quit ');
|
|
1306
|
+
write(ansi.reset);
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function renderFlash() {
|
|
1310
|
+
if (!flashMessage) return;
|
|
1311
|
+
|
|
1312
|
+
const width = 50;
|
|
1313
|
+
const height = 5;
|
|
1314
|
+
const col = Math.floor((terminalWidth - width) / 2);
|
|
1315
|
+
const row = Math.floor((terminalHeight - height) / 2);
|
|
1316
|
+
|
|
1317
|
+
// Draw double-line box
|
|
1318
|
+
write(ansi.moveTo(row, col));
|
|
1319
|
+
write(ansi.yellow + ansi.bold);
|
|
1320
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1321
|
+
|
|
1322
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1323
|
+
write(ansi.moveTo(row + i, col));
|
|
1324
|
+
write(box.dVertical + ' '.repeat(width - 2) + box.dVertical);
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
1328
|
+
write(box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1329
|
+
write(ansi.reset);
|
|
1330
|
+
|
|
1331
|
+
// Content
|
|
1332
|
+
write(ansi.moveTo(row + 1, col + Math.floor((width - 16) / 2)));
|
|
1333
|
+
write(ansi.yellow + ansi.bold + '⚡ NEW UPDATE ⚡' + ansi.reset);
|
|
1334
|
+
|
|
1335
|
+
write(ansi.moveTo(row + 2, col + 2));
|
|
1336
|
+
const truncMsg = truncate(flashMessage, width - 4);
|
|
1337
|
+
write(ansi.white + truncMsg + ansi.reset);
|
|
1338
|
+
|
|
1339
|
+
write(ansi.moveTo(row + 3, col + Math.floor((width - 22) / 2)));
|
|
1340
|
+
write(ansi.gray + 'Press any key to dismiss' + ansi.reset);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function renderErrorToast() {
|
|
1344
|
+
if (!errorToast) return;
|
|
1345
|
+
|
|
1346
|
+
const width = Math.min(60, terminalWidth - 4);
|
|
1347
|
+
const col = Math.floor((terminalWidth - width) / 2);
|
|
1348
|
+
const row = 2; // Near the top, below header
|
|
1349
|
+
|
|
1350
|
+
// Calculate height based on content
|
|
1351
|
+
const lines = [];
|
|
1352
|
+
lines.push(errorToast.title || 'Git Error');
|
|
1353
|
+
lines.push('');
|
|
1354
|
+
|
|
1355
|
+
// Word wrap the message
|
|
1356
|
+
const msgWords = errorToast.message.split(' ');
|
|
1357
|
+
let currentLine = '';
|
|
1358
|
+
for (const word of msgWords) {
|
|
1359
|
+
if ((currentLine + ' ' + word).length > width - 6) {
|
|
1360
|
+
lines.push(currentLine.trim());
|
|
1361
|
+
currentLine = word;
|
|
1362
|
+
} else {
|
|
1363
|
+
currentLine += (currentLine ? ' ' : '') + word;
|
|
1364
|
+
}
|
|
1365
|
+
}
|
|
1366
|
+
if (currentLine) lines.push(currentLine.trim());
|
|
1367
|
+
|
|
1368
|
+
if (errorToast.hint) {
|
|
1369
|
+
lines.push('');
|
|
1370
|
+
lines.push(errorToast.hint);
|
|
1371
|
+
}
|
|
1372
|
+
lines.push('');
|
|
1373
|
+
lines.push('Press any key to dismiss');
|
|
1374
|
+
|
|
1375
|
+
const height = lines.length + 2;
|
|
1376
|
+
|
|
1377
|
+
// Draw red error box
|
|
1378
|
+
write(ansi.moveTo(row, col));
|
|
1379
|
+
write(ansi.red + ansi.bold);
|
|
1380
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1381
|
+
|
|
1382
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1383
|
+
write(ansi.moveTo(row + i, col));
|
|
1384
|
+
write(ansi.red + box.dVertical + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 2) + ansi.reset + ansi.red + box.dVertical + ansi.reset);
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
1388
|
+
write(ansi.red + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1389
|
+
write(ansi.reset);
|
|
1390
|
+
|
|
1391
|
+
// Render content
|
|
1392
|
+
let contentRow = row + 1;
|
|
1393
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1394
|
+
const line = lines[i];
|
|
1395
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
1396
|
+
write(ansi.bgRed + ansi.white);
|
|
1397
|
+
|
|
1398
|
+
if (i === 0) {
|
|
1399
|
+
// Title line - centered and bold
|
|
1400
|
+
const titlePadding = Math.floor((width - 4 - line.length) / 2);
|
|
1401
|
+
write(' '.repeat(titlePadding) + ansi.bold + line + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 4 - titlePadding - line.length));
|
|
1402
|
+
} else if (line === 'Press any key to dismiss') {
|
|
1403
|
+
// Instruction line - centered and dimmer
|
|
1404
|
+
const padding = Math.floor((width - 4 - line.length) / 2);
|
|
1405
|
+
write(ansi.reset + ansi.bgRed + ansi.gray + ' '.repeat(padding) + line + ' '.repeat(width - 4 - padding - line.length));
|
|
1406
|
+
} else if (errorToast.hint && line === errorToast.hint) {
|
|
1407
|
+
// Hint line - yellow on red
|
|
1408
|
+
const padding = Math.floor((width - 4 - line.length) / 2);
|
|
1409
|
+
write(ansi.reset + ansi.bgRed + ansi.yellow + ' '.repeat(padding) + line + ' '.repeat(width - 4 - padding - line.length));
|
|
1410
|
+
} else {
|
|
1411
|
+
// Regular content
|
|
1412
|
+
write(padRight(line, width - 4));
|
|
1413
|
+
}
|
|
1414
|
+
write(ansi.reset);
|
|
1415
|
+
contentRow++;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function renderPreview() {
|
|
1420
|
+
if (!previewMode || !previewData) return;
|
|
1421
|
+
|
|
1422
|
+
const width = Math.min(60, terminalWidth - 4);
|
|
1423
|
+
const height = 16;
|
|
1424
|
+
const col = Math.floor((terminalWidth - width) / 2);
|
|
1425
|
+
const row = Math.floor((terminalHeight - height) / 2);
|
|
1426
|
+
|
|
1427
|
+
const displayBranches = filteredBranches !== null ? filteredBranches : branches;
|
|
1428
|
+
const branch = displayBranches[selectedIndex];
|
|
1429
|
+
if (!branch) return;
|
|
1430
|
+
|
|
1431
|
+
// Draw box
|
|
1432
|
+
write(ansi.moveTo(row, col));
|
|
1433
|
+
write(ansi.cyan + ansi.bold);
|
|
1434
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1435
|
+
|
|
1436
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1437
|
+
write(ansi.moveTo(row + i, col));
|
|
1438
|
+
write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
1442
|
+
write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1443
|
+
write(ansi.reset);
|
|
1444
|
+
|
|
1445
|
+
// Title
|
|
1446
|
+
const title = ` Preview: ${truncate(branch.name, width - 14)} `;
|
|
1447
|
+
write(ansi.moveTo(row, col + 2));
|
|
1448
|
+
write(ansi.cyan + ansi.bold + title + ansi.reset);
|
|
1449
|
+
|
|
1450
|
+
// Commits section
|
|
1451
|
+
write(ansi.moveTo(row + 2, col + 2));
|
|
1452
|
+
write(ansi.white + ansi.bold + 'Recent Commits:' + ansi.reset);
|
|
1453
|
+
|
|
1454
|
+
let contentRow = row + 3;
|
|
1455
|
+
if (previewData.commits.length === 0) {
|
|
1456
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1457
|
+
write(ansi.gray + '(no commits)' + ansi.reset);
|
|
1458
|
+
contentRow++;
|
|
1459
|
+
} else {
|
|
1460
|
+
for (const commit of previewData.commits.slice(0, 5)) {
|
|
1461
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1462
|
+
write(ansi.yellow + commit.hash + ansi.reset + ' ');
|
|
1463
|
+
write(ansi.gray + truncate(commit.message, width - 14) + ansi.reset);
|
|
1464
|
+
contentRow++;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// Files section
|
|
1469
|
+
contentRow++;
|
|
1470
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
1471
|
+
write(ansi.white + ansi.bold + 'Files Changed vs HEAD:' + ansi.reset);
|
|
1472
|
+
contentRow++;
|
|
1473
|
+
|
|
1474
|
+
if (previewData.filesChanged.length === 0) {
|
|
1475
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1476
|
+
write(ansi.gray + '(no changes or same as current)' + ansi.reset);
|
|
1477
|
+
} else {
|
|
1478
|
+
for (const file of previewData.filesChanged.slice(0, 5)) {
|
|
1479
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1480
|
+
write(ansi.green + '• ' + ansi.reset + truncate(file, width - 8));
|
|
1481
|
+
contentRow++;
|
|
1482
|
+
}
|
|
1483
|
+
if (previewData.filesChanged.length > 5) {
|
|
1484
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1485
|
+
write(ansi.gray + `... and ${previewData.filesChanged.length - 5} more` + ansi.reset);
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
// Instructions
|
|
1490
|
+
write(ansi.moveTo(row + height - 2, col + Math.floor((width - 26) / 2)));
|
|
1491
|
+
write(ansi.gray + 'Press [v] or [Esc] to close' + ansi.reset);
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
function renderHistory() {
|
|
1495
|
+
const width = Math.min(50, terminalWidth - 4);
|
|
1496
|
+
const height = Math.min(switchHistory.length + 5, 15);
|
|
1497
|
+
const col = Math.floor((terminalWidth - width) / 2);
|
|
1498
|
+
const row = Math.floor((terminalHeight - height) / 2);
|
|
1499
|
+
|
|
1500
|
+
// Draw box
|
|
1501
|
+
write(ansi.moveTo(row, col));
|
|
1502
|
+
write(ansi.magenta + ansi.bold);
|
|
1503
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1504
|
+
|
|
1505
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1506
|
+
write(ansi.moveTo(row + i, col));
|
|
1507
|
+
write(ansi.magenta + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.magenta + box.dVertical + ansi.reset);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
1511
|
+
write(ansi.magenta + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1512
|
+
write(ansi.reset);
|
|
1513
|
+
|
|
1514
|
+
// Title
|
|
1515
|
+
write(ansi.moveTo(row, col + 2));
|
|
1516
|
+
write(ansi.magenta + ansi.bold + ' Switch History ' + ansi.reset);
|
|
1517
|
+
|
|
1518
|
+
// Content
|
|
1519
|
+
if (switchHistory.length === 0) {
|
|
1520
|
+
write(ansi.moveTo(row + 2, col + 3));
|
|
1521
|
+
write(ansi.gray + 'No branch switches yet' + ansi.reset);
|
|
1522
|
+
} else {
|
|
1523
|
+
let contentRow = row + 2;
|
|
1524
|
+
for (let i = 0; i < Math.min(switchHistory.length, height - 4); i++) {
|
|
1525
|
+
const entry = switchHistory[i];
|
|
1526
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1527
|
+
if (i === 0) {
|
|
1528
|
+
write(ansi.yellow + '→ ' + ansi.reset); // Most recent
|
|
1529
|
+
} else {
|
|
1530
|
+
write(ansi.gray + ' ' + ansi.reset);
|
|
1531
|
+
}
|
|
1532
|
+
write(truncate(entry.from, 15) + ansi.gray + ' → ' + ansi.reset);
|
|
1533
|
+
write(ansi.cyan + truncate(entry.to, 15) + ansi.reset);
|
|
1534
|
+
contentRow++;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
// Instructions
|
|
1539
|
+
write(ansi.moveTo(row + height - 2, col + 2));
|
|
1540
|
+
write(ansi.gray + '[u] Undo last [h]/[Esc] Close' + ansi.reset);
|
|
1541
|
+
}
|
|
1542
|
+
|
|
1543
|
+
let historyMode = false;
|
|
1544
|
+
let infoMode = false;
|
|
1545
|
+
|
|
1546
|
+
function renderLogView() {
|
|
1547
|
+
if (!logViewMode) return;
|
|
1548
|
+
|
|
1549
|
+
const width = Math.min(terminalWidth - 4, 100);
|
|
1550
|
+
const height = Math.min(terminalHeight - 4, 30);
|
|
1551
|
+
const col = Math.floor((terminalWidth - width) / 2);
|
|
1552
|
+
const row = Math.floor((terminalHeight - height) / 2);
|
|
1553
|
+
|
|
1554
|
+
// Determine which log to display
|
|
1555
|
+
const isServerTab = logViewTab === 'server';
|
|
1556
|
+
const logData = isServerTab ? serverLogBuffer : activityLog;
|
|
1557
|
+
|
|
1558
|
+
// Draw box
|
|
1559
|
+
write(ansi.moveTo(row, col));
|
|
1560
|
+
write(ansi.yellow + ansi.bold);
|
|
1561
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1562
|
+
|
|
1563
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1564
|
+
write(ansi.moveTo(row + i, col));
|
|
1565
|
+
write(ansi.yellow + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.yellow + box.dVertical + ansi.reset);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
1569
|
+
write(ansi.yellow + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1570
|
+
write(ansi.reset);
|
|
1571
|
+
|
|
1572
|
+
// Title with tabs
|
|
1573
|
+
const activityTab = logViewTab === 'activity'
|
|
1574
|
+
? ansi.bgWhite + ansi.black + ' 1:Activity ' + ansi.reset + ansi.yellow
|
|
1575
|
+
: ansi.gray + ' 1:Activity ' + ansi.yellow;
|
|
1576
|
+
const serverTab = logViewTab === 'server'
|
|
1577
|
+
? ansi.bgWhite + ansi.black + ' 2:Server ' + ansi.reset + ansi.yellow
|
|
1578
|
+
: ansi.gray + ' 2:Server ' + ansi.yellow;
|
|
1579
|
+
|
|
1580
|
+
// Server status (only show on server tab)
|
|
1581
|
+
let statusIndicator = '';
|
|
1582
|
+
if (isServerTab && SERVER_MODE === 'command') {
|
|
1583
|
+
const statusText = serverRunning ? ansi.green + 'RUNNING' : (serverCrashed ? ansi.red + 'CRASHED' : ansi.gray + 'STOPPED');
|
|
1584
|
+
statusIndicator = ` [${statusText}${ansi.yellow}]`;
|
|
1585
|
+
} else if (isServerTab && SERVER_MODE === 'static') {
|
|
1586
|
+
statusIndicator = ansi.green + ' [STATIC]' + ansi.yellow;
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
write(ansi.moveTo(row, col + 2));
|
|
1590
|
+
write(ansi.yellow + ansi.bold + ' ' + activityTab + ' ' + serverTab + statusIndicator + ' ' + ansi.reset);
|
|
1591
|
+
|
|
1592
|
+
// Content
|
|
1593
|
+
const contentHeight = height - 4;
|
|
1594
|
+
const maxScroll = Math.max(0, logData.length - contentHeight);
|
|
1595
|
+
logScrollOffset = Math.min(logScrollOffset, maxScroll);
|
|
1596
|
+
logScrollOffset = Math.max(0, logScrollOffset);
|
|
1597
|
+
|
|
1598
|
+
let contentRow = row + 2;
|
|
1599
|
+
|
|
1600
|
+
if (logData.length === 0) {
|
|
1601
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
1602
|
+
write(ansi.gray + (isServerTab ? 'No server output yet...' : 'No activity yet...') + ansi.reset);
|
|
1603
|
+
} else if (isServerTab) {
|
|
1604
|
+
// Server log: newest at bottom, scroll from bottom
|
|
1605
|
+
const startIndex = Math.max(0, serverLogBuffer.length - contentHeight - logScrollOffset);
|
|
1606
|
+
const endIndex = Math.min(serverLogBuffer.length, startIndex + contentHeight);
|
|
1607
|
+
|
|
1608
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
1609
|
+
const entry = serverLogBuffer[i];
|
|
1610
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
1611
|
+
const lineText = truncate(entry.line, width - 4);
|
|
1612
|
+
if (entry.isError) {
|
|
1613
|
+
write(ansi.red + lineText + ansi.reset);
|
|
1614
|
+
} else {
|
|
1615
|
+
write(lineText);
|
|
1616
|
+
}
|
|
1617
|
+
contentRow++;
|
|
1618
|
+
}
|
|
1619
|
+
} else {
|
|
1620
|
+
// Activity log: newest first, scroll from top
|
|
1621
|
+
const startIndex = logScrollOffset;
|
|
1622
|
+
const endIndex = Math.min(activityLog.length, startIndex + contentHeight);
|
|
1623
|
+
|
|
1624
|
+
for (let i = startIndex; i < endIndex; i++) {
|
|
1625
|
+
const entry = activityLog[i];
|
|
1626
|
+
write(ansi.moveTo(contentRow, col + 2));
|
|
1627
|
+
write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
|
|
1628
|
+
write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
|
|
1629
|
+
write(truncate(entry.message, width - 18));
|
|
1630
|
+
contentRow++;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Scroll indicator
|
|
1635
|
+
if (logData.length > contentHeight) {
|
|
1636
|
+
const scrollPercent = isServerTab
|
|
1637
|
+
? Math.round((1 - logScrollOffset / maxScroll) * 100)
|
|
1638
|
+
: Math.round((logScrollOffset / maxScroll) * 100);
|
|
1639
|
+
write(ansi.moveTo(row, col + width - 10));
|
|
1640
|
+
write(ansi.gray + ` ${scrollPercent}% ` + ansi.reset);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Instructions
|
|
1644
|
+
write(ansi.moveTo(row + height - 2, col + 2));
|
|
1645
|
+
const restartHint = SERVER_MODE === 'command' ? '[R] Restart ' : '';
|
|
1646
|
+
write(ansi.gray + '[1/2] Switch Tab [↑↓] Scroll ' + restartHint + '[l]/[Esc] Close' + ansi.reset);
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
function renderInfo() {
|
|
1650
|
+
const width = Math.min(50, terminalWidth - 4);
|
|
1651
|
+
const height = NO_SERVER ? 9 : 12;
|
|
1652
|
+
const col = Math.floor((terminalWidth - width) / 2);
|
|
1653
|
+
const row = Math.floor((terminalHeight - height) / 2);
|
|
1654
|
+
|
|
1655
|
+
// Draw box
|
|
1656
|
+
write(ansi.moveTo(row, col));
|
|
1657
|
+
write(ansi.cyan + ansi.bold);
|
|
1658
|
+
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1659
|
+
|
|
1660
|
+
for (let i = 1; i < height - 1; i++) {
|
|
1661
|
+
write(ansi.moveTo(row + i, col));
|
|
1662
|
+
write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
write(ansi.moveTo(row + height - 1, col));
|
|
1666
|
+
write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1667
|
+
write(ansi.reset);
|
|
1668
|
+
|
|
1669
|
+
// Title
|
|
1670
|
+
write(ansi.moveTo(row, col + 2));
|
|
1671
|
+
write(ansi.cyan + ansi.bold + (NO_SERVER ? ' Status Info ' : ' Server Info ') + ansi.reset);
|
|
1672
|
+
|
|
1673
|
+
// Content
|
|
1674
|
+
let contentRow = row + 2;
|
|
1675
|
+
|
|
1676
|
+
if (!NO_SERVER) {
|
|
1677
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1678
|
+
write(ansi.white + ansi.bold + 'Dev Server' + ansi.reset);
|
|
1679
|
+
contentRow++;
|
|
1680
|
+
|
|
1681
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1682
|
+
write(ansi.gray + 'URL: ' + ansi.reset + ansi.green + `http://localhost:${PORT}` + ansi.reset);
|
|
1683
|
+
contentRow++;
|
|
1684
|
+
|
|
1685
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1686
|
+
write(ansi.gray + 'Port: ' + ansi.reset + ansi.yellow + PORT + ansi.reset);
|
|
1687
|
+
contentRow++;
|
|
1688
|
+
|
|
1689
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1690
|
+
write(ansi.gray + 'Connected browsers: ' + ansi.reset + ansi.cyan + clients.size + ansi.reset);
|
|
1691
|
+
contentRow++;
|
|
1692
|
+
|
|
1693
|
+
contentRow++;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1697
|
+
write(ansi.white + ansi.bold + 'Git Polling' + ansi.reset);
|
|
1698
|
+
contentRow++;
|
|
1699
|
+
|
|
1700
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1701
|
+
write(ansi.gray + 'Interval: ' + ansi.reset + `${adaptivePollInterval / 1000}s`);
|
|
1702
|
+
contentRow++;
|
|
1703
|
+
|
|
1704
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1705
|
+
write(ansi.gray + 'Status: ' + ansi.reset + (isOffline ? ansi.red + 'Offline' : ansi.green + 'Online') + ansi.reset);
|
|
1706
|
+
contentRow++;
|
|
1707
|
+
|
|
1708
|
+
if (NO_SERVER) {
|
|
1709
|
+
write(ansi.moveTo(contentRow, col + 3));
|
|
1710
|
+
write(ansi.gray + 'Mode: ' + ansi.reset + ansi.magenta + 'No-Server (branch monitor only)' + ansi.reset);
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
// Instructions
|
|
1714
|
+
write(ansi.moveTo(row + height - 2, col + Math.floor((width - 20) / 2)));
|
|
1715
|
+
write(ansi.gray + 'Press [i] or [Esc] to close' + ansi.reset);
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
function render() {
|
|
1719
|
+
updateTerminalSize();
|
|
1720
|
+
|
|
1721
|
+
write(ansi.hideCursor);
|
|
1722
|
+
write(ansi.moveToTop);
|
|
1723
|
+
write(ansi.clearScreen);
|
|
1724
|
+
|
|
1725
|
+
renderHeader();
|
|
1726
|
+
const logStart = renderBranchList();
|
|
1727
|
+
renderActivityLog(logStart);
|
|
1728
|
+
renderFooter();
|
|
1729
|
+
|
|
1730
|
+
if (flashMessage) {
|
|
1731
|
+
renderFlash();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
if (previewMode && previewData) {
|
|
1735
|
+
renderPreview();
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
if (historyMode) {
|
|
1739
|
+
renderHistory();
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
if (infoMode) {
|
|
1743
|
+
renderInfo();
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
if (logViewMode) {
|
|
1747
|
+
renderLogView();
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
// Error toast renders on top of everything for maximum visibility
|
|
1751
|
+
if (errorToast) {
|
|
1752
|
+
renderErrorToast();
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
function showFlash(message) {
|
|
1757
|
+
if (flashTimeout) clearTimeout(flashTimeout);
|
|
1758
|
+
|
|
1759
|
+
flashMessage = message;
|
|
1760
|
+
render();
|
|
1761
|
+
|
|
1762
|
+
flashTimeout = setTimeout(() => {
|
|
1763
|
+
flashMessage = null;
|
|
1764
|
+
render();
|
|
1765
|
+
}, 3000);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
function hideFlash() {
|
|
1769
|
+
if (flashTimeout) {
|
|
1770
|
+
clearTimeout(flashTimeout);
|
|
1771
|
+
flashTimeout = null;
|
|
1772
|
+
}
|
|
1773
|
+
if (flashMessage) {
|
|
1774
|
+
flashMessage = null;
|
|
1775
|
+
render();
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
function showErrorToast(title, message, hint = null, duration = 8000) {
|
|
1780
|
+
if (errorToastTimeout) clearTimeout(errorToastTimeout);
|
|
1781
|
+
|
|
1782
|
+
errorToast = { title, message, hint };
|
|
1783
|
+
playSound(); // Alert sound for errors
|
|
1784
|
+
render();
|
|
1785
|
+
|
|
1786
|
+
errorToastTimeout = setTimeout(() => {
|
|
1787
|
+
errorToast = null;
|
|
1788
|
+
render();
|
|
1789
|
+
}, duration);
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function hideErrorToast() {
|
|
1793
|
+
if (errorToastTimeout) {
|
|
1794
|
+
clearTimeout(errorToastTimeout);
|
|
1795
|
+
errorToastTimeout = null;
|
|
1796
|
+
}
|
|
1797
|
+
if (errorToast) {
|
|
1798
|
+
errorToast = null;
|
|
1799
|
+
render();
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
// ============================================================================
|
|
1804
|
+
// Git Functions
|
|
1805
|
+
// ============================================================================
|
|
1806
|
+
|
|
1807
|
+
async function getCurrentBranch() {
|
|
1808
|
+
try {
|
|
1809
|
+
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD');
|
|
1810
|
+
// Check for detached HEAD state
|
|
1811
|
+
if (stdout === 'HEAD') {
|
|
1812
|
+
isDetachedHead = true;
|
|
1813
|
+
// Get the short commit hash instead
|
|
1814
|
+
const { stdout: commitHash } = await execAsync('git rev-parse --short HEAD');
|
|
1815
|
+
return `HEAD@${commitHash}`;
|
|
1816
|
+
}
|
|
1817
|
+
isDetachedHead = false;
|
|
1818
|
+
return stdout;
|
|
1819
|
+
} catch (e) {
|
|
1820
|
+
return null;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
async function checkRemoteExists() {
|
|
1825
|
+
try {
|
|
1826
|
+
const { stdout } = await execAsync('git remote');
|
|
1827
|
+
const remotes = stdout.split('\n').filter(Boolean);
|
|
1828
|
+
return remotes.length > 0;
|
|
1829
|
+
} catch (e) {
|
|
1830
|
+
return false;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
async function hasUncommittedChanges() {
|
|
1835
|
+
try {
|
|
1836
|
+
const { stdout } = await execAsync('git status --porcelain');
|
|
1837
|
+
return stdout.length > 0;
|
|
1838
|
+
} catch (e) {
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
function isAuthError(errorMessage) {
|
|
1844
|
+
const authErrors = [
|
|
1845
|
+
'Authentication failed',
|
|
1846
|
+
'could not read Username',
|
|
1847
|
+
'could not read Password',
|
|
1848
|
+
'Permission denied',
|
|
1849
|
+
'invalid credentials',
|
|
1850
|
+
'authorization failed',
|
|
1851
|
+
'fatal: Authentication',
|
|
1852
|
+
'HTTP 401',
|
|
1853
|
+
'HTTP 403',
|
|
1854
|
+
];
|
|
1855
|
+
const msg = (errorMessage || '').toLowerCase();
|
|
1856
|
+
return authErrors.some(err => msg.includes(err.toLowerCase()));
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function isMergeConflict(errorMessage) {
|
|
1860
|
+
const conflictIndicators = [
|
|
1861
|
+
'CONFLICT',
|
|
1862
|
+
'Automatic merge failed',
|
|
1863
|
+
'fix conflicts',
|
|
1864
|
+
'Merge conflict',
|
|
1865
|
+
];
|
|
1866
|
+
return conflictIndicators.some(ind => (errorMessage || '').includes(ind));
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
function isNetworkError(errorMessage) {
|
|
1870
|
+
const networkErrors = [
|
|
1871
|
+
'Could not resolve host',
|
|
1872
|
+
'unable to access',
|
|
1873
|
+
'Connection refused',
|
|
1874
|
+
'Network is unreachable',
|
|
1875
|
+
'Connection timed out',
|
|
1876
|
+
'Failed to connect',
|
|
1877
|
+
'no route to host',
|
|
1878
|
+
'Temporary failure in name resolution',
|
|
1879
|
+
];
|
|
1880
|
+
const msg = (errorMessage || '').toLowerCase();
|
|
1881
|
+
return networkErrors.some(err => msg.includes(err.toLowerCase()));
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
async function getAllBranches() {
|
|
1885
|
+
try {
|
|
1886
|
+
await execAsync('git fetch --all --prune 2>/dev/null').catch(() => {});
|
|
1887
|
+
|
|
1888
|
+
const branchList = [];
|
|
1889
|
+
const seenBranches = new Set();
|
|
1890
|
+
|
|
1891
|
+
// Get local branches
|
|
1892
|
+
const { stdout: localOutput } = await execAsync(
|
|
1893
|
+
'git for-each-ref --sort=-committerdate --format="%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)" refs/heads/'
|
|
1894
|
+
);
|
|
1895
|
+
|
|
1896
|
+
for (const line of localOutput.split('\n').filter(Boolean)) {
|
|
1897
|
+
const [name, dateStr, commit, subject] = line.split('|');
|
|
1898
|
+
if (!seenBranches.has(name) && isValidBranchName(name)) {
|
|
1899
|
+
seenBranches.add(name);
|
|
1900
|
+
branchList.push({
|
|
1901
|
+
name,
|
|
1902
|
+
commit,
|
|
1903
|
+
subject: subject || '',
|
|
1904
|
+
date: new Date(dateStr),
|
|
1905
|
+
isLocal: true,
|
|
1906
|
+
hasRemote: false,
|
|
1907
|
+
hasUpdates: false,
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// Get remote branches (using configured remote name)
|
|
1913
|
+
const { stdout: remoteOutput } = await execAsync(
|
|
1914
|
+
`git for-each-ref --sort=-committerdate --format="%(refname:short)|%(committerdate:iso8601)|%(objectname:short)|%(subject)" refs/remotes/${REMOTE_NAME}/`
|
|
1915
|
+
).catch(() => ({ stdout: '' }));
|
|
1916
|
+
|
|
1917
|
+
const remotePrefix = `${REMOTE_NAME}/`;
|
|
1918
|
+
for (const line of remoteOutput.split('\n').filter(Boolean)) {
|
|
1919
|
+
const [fullName, dateStr, commit, subject] = line.split('|');
|
|
1920
|
+
const name = fullName.replace(remotePrefix, '');
|
|
1921
|
+
if (name === 'HEAD') continue;
|
|
1922
|
+
if (!isValidBranchName(name)) continue;
|
|
1923
|
+
|
|
1924
|
+
const existing = branchList.find(b => b.name === name);
|
|
1925
|
+
if (existing) {
|
|
1926
|
+
existing.hasRemote = true;
|
|
1927
|
+
existing.remoteCommit = commit;
|
|
1928
|
+
existing.remoteDate = new Date(dateStr);
|
|
1929
|
+
existing.remoteSubject = subject || '';
|
|
1930
|
+
if (commit !== existing.commit) {
|
|
1931
|
+
existing.hasUpdates = true;
|
|
1932
|
+
// Use remote's date when it has updates (so it sorts to top)
|
|
1933
|
+
existing.date = new Date(dateStr);
|
|
1934
|
+
existing.subject = subject || existing.subject;
|
|
1935
|
+
}
|
|
1936
|
+
} else if (!seenBranches.has(name)) {
|
|
1937
|
+
seenBranches.add(name);
|
|
1938
|
+
branchList.push({
|
|
1939
|
+
name,
|
|
1940
|
+
commit,
|
|
1941
|
+
subject: subject || '',
|
|
1942
|
+
date: new Date(dateStr),
|
|
1943
|
+
isLocal: false,
|
|
1944
|
+
hasRemote: true,
|
|
1945
|
+
hasUpdates: false,
|
|
1946
|
+
});
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
branchList.sort((a, b) => b.date - a.date);
|
|
1951
|
+
return branchList; // Return all branches, caller will slice
|
|
1952
|
+
} catch (e) {
|
|
1953
|
+
addLog(`Failed to get branches: ${e.message || e}`, 'error');
|
|
1954
|
+
return [];
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
|
|
1958
|
+
async function switchToBranch(branchName, recordHistory = true) {
|
|
1959
|
+
try {
|
|
1960
|
+
// Validate branch name for security
|
|
1961
|
+
const safeBranchName = sanitizeBranchName(branchName);
|
|
1962
|
+
|
|
1963
|
+
// Check for uncommitted changes first
|
|
1964
|
+
const isDirty = await hasUncommittedChanges();
|
|
1965
|
+
if (isDirty) {
|
|
1966
|
+
addLog(`Cannot switch: uncommitted changes in working directory`, 'error');
|
|
1967
|
+
addLog(`Commit or stash your changes first`, 'warning');
|
|
1968
|
+
showErrorToast(
|
|
1969
|
+
'Cannot Switch Branch',
|
|
1970
|
+
'You have uncommitted changes in your working directory that would be lost.',
|
|
1971
|
+
'Run: git stash or git commit'
|
|
1972
|
+
);
|
|
1973
|
+
return { success: false, reason: 'dirty' };
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
const previousBranch = currentBranch;
|
|
1977
|
+
|
|
1978
|
+
addLog(`Switching to ${safeBranchName}...`, 'update');
|
|
1979
|
+
render();
|
|
1980
|
+
|
|
1981
|
+
const { stdout: localBranches } = await execAsync('git branch --list');
|
|
1982
|
+
const hasLocal = localBranches.split('\n').some(b => b.trim().replace('* ', '') === safeBranchName);
|
|
1983
|
+
|
|
1984
|
+
if (hasLocal) {
|
|
1985
|
+
await execAsync(`git checkout -- . 2>/dev/null; git checkout "${safeBranchName}"`);
|
|
1986
|
+
} else {
|
|
1987
|
+
await execAsync(`git checkout -b "${safeBranchName}" "${REMOTE_NAME}/${safeBranchName}"`);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
currentBranch = safeBranchName;
|
|
1991
|
+
isDetachedHead = false; // Successfully switched to branch
|
|
1992
|
+
|
|
1993
|
+
// Clear NEW flag when branch becomes current
|
|
1994
|
+
const branchInfo = branches.find(b => b.name === safeBranchName);
|
|
1995
|
+
if (branchInfo && branchInfo.isNew) {
|
|
1996
|
+
branchInfo.isNew = false;
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// Record in history (for undo)
|
|
2000
|
+
if (recordHistory && previousBranch && previousBranch !== safeBranchName) {
|
|
2001
|
+
switchHistory.unshift({ from: previousBranch, to: safeBranchName, timestamp: Date.now() });
|
|
2002
|
+
if (switchHistory.length > MAX_HISTORY) switchHistory.pop();
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
addLog(`Switched to ${safeBranchName}`, 'success');
|
|
2006
|
+
|
|
2007
|
+
// Restart server if configured (command mode)
|
|
2008
|
+
if (SERVER_MODE === 'command' && RESTART_ON_SWITCH && serverProcess) {
|
|
2009
|
+
restartServerProcess();
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
notifyClients();
|
|
2013
|
+
return { success: true };
|
|
2014
|
+
} catch (e) {
|
|
2015
|
+
const errMsg = e.stderr || e.message || String(e);
|
|
2016
|
+
if (errMsg.includes('Invalid branch name')) {
|
|
2017
|
+
addLog(`Invalid branch name: ${branchName}`, 'error');
|
|
2018
|
+
showErrorToast(
|
|
2019
|
+
'Invalid Branch Name',
|
|
2020
|
+
`The branch name "${branchName}" is not valid.`,
|
|
2021
|
+
'Check for special characters or typos'
|
|
2022
|
+
);
|
|
2023
|
+
} else if (errMsg.includes('local changes') || errMsg.includes('overwritten')) {
|
|
2024
|
+
addLog(`Cannot switch: local changes would be overwritten`, 'error');
|
|
2025
|
+
addLog(`Commit or stash your changes first`, 'warning');
|
|
2026
|
+
showErrorToast(
|
|
2027
|
+
'Cannot Switch Branch',
|
|
2028
|
+
'Your local changes would be overwritten by checkout.',
|
|
2029
|
+
'Run: git stash or git commit'
|
|
2030
|
+
);
|
|
2031
|
+
} else {
|
|
2032
|
+
addLog(`Failed to switch: ${errMsg}`, 'error');
|
|
2033
|
+
showErrorToast(
|
|
2034
|
+
'Branch Switch Failed',
|
|
2035
|
+
truncate(errMsg, 100),
|
|
2036
|
+
'Check the activity log for details'
|
|
2037
|
+
);
|
|
2038
|
+
}
|
|
2039
|
+
return { success: false };
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
async function undoLastSwitch() {
|
|
2044
|
+
if (switchHistory.length === 0) {
|
|
2045
|
+
addLog('No switch history to undo', 'warning');
|
|
2046
|
+
return { success: false };
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
const lastSwitch = switchHistory[0];
|
|
2050
|
+
addLog(`Undoing: going back to ${lastSwitch.from}`, 'update');
|
|
2051
|
+
|
|
2052
|
+
const result = await switchToBranch(lastSwitch.from, false);
|
|
2053
|
+
if (result.success) {
|
|
2054
|
+
switchHistory.shift(); // Remove the undone entry
|
|
2055
|
+
addLog(`Undone: back on ${lastSwitch.from}`, 'success');
|
|
2056
|
+
}
|
|
2057
|
+
return result;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
async function pullCurrentBranch() {
|
|
2061
|
+
try {
|
|
2062
|
+
const branch = await getCurrentBranch();
|
|
2063
|
+
if (!branch) {
|
|
2064
|
+
addLog('Not in a git repository', 'error');
|
|
2065
|
+
showErrorToast('Pull Failed', 'Not in a git repository.');
|
|
2066
|
+
return { success: false };
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// Validate branch name
|
|
2070
|
+
if (!isValidBranchName(branch) && !branch.startsWith('HEAD@')) {
|
|
2071
|
+
addLog('Cannot pull: invalid branch name', 'error');
|
|
2072
|
+
showErrorToast('Pull Failed', 'Cannot pull: invalid branch name.');
|
|
2073
|
+
return { success: false };
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
addLog(`Pulling from ${REMOTE_NAME}/${branch}...`, 'update');
|
|
2077
|
+
render();
|
|
2078
|
+
|
|
2079
|
+
await execAsync(`git pull "${REMOTE_NAME}" "${branch}"`);
|
|
2080
|
+
addLog('Pulled successfully', 'success');
|
|
2081
|
+
notifyClients();
|
|
2082
|
+
return { success: true };
|
|
2083
|
+
} catch (e) {
|
|
2084
|
+
const errMsg = e.stderr || e.message || String(e);
|
|
2085
|
+
addLog(`Pull failed: ${errMsg}`, 'error');
|
|
2086
|
+
|
|
2087
|
+
if (isMergeConflict(errMsg)) {
|
|
2088
|
+
hasMergeConflict = true;
|
|
2089
|
+
showErrorToast(
|
|
2090
|
+
'Merge Conflict!',
|
|
2091
|
+
'Git pull resulted in merge conflicts that need manual resolution.',
|
|
2092
|
+
'Run: git status to see conflicts'
|
|
2093
|
+
);
|
|
2094
|
+
} else if (isAuthError(errMsg)) {
|
|
2095
|
+
showErrorToast(
|
|
2096
|
+
'Authentication Failed',
|
|
2097
|
+
'Could not authenticate with the remote repository.',
|
|
2098
|
+
'Check your Git credentials'
|
|
2099
|
+
);
|
|
2100
|
+
} else if (isNetworkError(errMsg)) {
|
|
2101
|
+
showErrorToast(
|
|
2102
|
+
'Network Error',
|
|
2103
|
+
'Could not connect to the remote repository.',
|
|
2104
|
+
'Check your internet connection'
|
|
2105
|
+
);
|
|
2106
|
+
} else {
|
|
2107
|
+
showErrorToast(
|
|
2108
|
+
'Pull Failed',
|
|
2109
|
+
truncate(errMsg, 100),
|
|
2110
|
+
'Check the activity log for details'
|
|
2111
|
+
);
|
|
2112
|
+
}
|
|
2113
|
+
return { success: false };
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
// ============================================================================
|
|
2118
|
+
// Polling
|
|
2119
|
+
// ============================================================================
|
|
2120
|
+
|
|
2121
|
+
async function pollGitChanges() {
|
|
2122
|
+
if (isPolling) return;
|
|
2123
|
+
isPolling = true;
|
|
2124
|
+
pollingStatus = 'fetching';
|
|
2125
|
+
render();
|
|
2126
|
+
|
|
2127
|
+
const fetchStartTime = Date.now();
|
|
2128
|
+
|
|
2129
|
+
try {
|
|
2130
|
+
const newCurrentBranch = await getCurrentBranch();
|
|
2131
|
+
|
|
2132
|
+
if (currentBranch && newCurrentBranch !== currentBranch) {
|
|
2133
|
+
addLog(`Branch switched externally: ${currentBranch} → ${newCurrentBranch}`, 'warning');
|
|
2134
|
+
notifyClients();
|
|
2135
|
+
}
|
|
2136
|
+
currentBranch = newCurrentBranch;
|
|
2137
|
+
|
|
2138
|
+
const allBranches = await getAllBranches();
|
|
2139
|
+
|
|
2140
|
+
// Track fetch duration
|
|
2141
|
+
lastFetchDuration = Date.now() - fetchStartTime;
|
|
2142
|
+
|
|
2143
|
+
// Check for slow fetches
|
|
2144
|
+
if (lastFetchDuration > 30000 && !verySlowFetchWarningShown) {
|
|
2145
|
+
addLog(`⚠ Fetches taking ${Math.round(lastFetchDuration / 1000)}s - network may be slow`, 'warning');
|
|
2146
|
+
verySlowFetchWarningShown = true;
|
|
2147
|
+
// Slow down polling
|
|
2148
|
+
adaptivePollInterval = Math.min(adaptivePollInterval * 2, 60000);
|
|
2149
|
+
addLog(`Polling interval increased to ${adaptivePollInterval / 1000}s`, 'info');
|
|
2150
|
+
restartPolling();
|
|
2151
|
+
} else if (lastFetchDuration > 15000 && !slowFetchWarningShown) {
|
|
2152
|
+
addLog(`Fetches taking ${Math.round(lastFetchDuration / 1000)}s`, 'warning');
|
|
2153
|
+
slowFetchWarningShown = true;
|
|
2154
|
+
} else if (lastFetchDuration < 5000) {
|
|
2155
|
+
// Reset warnings if fetches are fast again
|
|
2156
|
+
slowFetchWarningShown = false;
|
|
2157
|
+
verySlowFetchWarningShown = false;
|
|
2158
|
+
if (adaptivePollInterval > GIT_POLL_INTERVAL) {
|
|
2159
|
+
adaptivePollInterval = GIT_POLL_INTERVAL;
|
|
2160
|
+
addLog(`Polling interval restored to ${adaptivePollInterval / 1000}s`, 'info');
|
|
2161
|
+
restartPolling();
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// Network success - reset failure counter
|
|
2166
|
+
consecutiveNetworkFailures = 0;
|
|
2167
|
+
if (isOffline) {
|
|
2168
|
+
isOffline = false;
|
|
2169
|
+
addLog('Connection restored', 'success');
|
|
2170
|
+
}
|
|
2171
|
+
const fetchedBranchNames = new Set(allBranches.map(b => b.name));
|
|
2172
|
+
const now = Date.now();
|
|
2173
|
+
|
|
2174
|
+
// Detect NEW branches (not seen before)
|
|
2175
|
+
const newBranchList = [];
|
|
2176
|
+
for (const branch of allBranches) {
|
|
2177
|
+
if (!knownBranchNames.has(branch.name)) {
|
|
2178
|
+
branch.isNew = true;
|
|
2179
|
+
branch.newAt = now;
|
|
2180
|
+
addLog(`New branch: ${branch.name}`, 'success');
|
|
2181
|
+
newBranchList.push(branch);
|
|
2182
|
+
} else {
|
|
2183
|
+
// Preserve isNew flag from previous poll cycle for branches not yet switched to
|
|
2184
|
+
const prevBranch = branches.find(b => b.name === branch.name);
|
|
2185
|
+
if (prevBranch && prevBranch.isNew) {
|
|
2186
|
+
branch.isNew = true;
|
|
2187
|
+
branch.newAt = prevBranch.newAt;
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
knownBranchNames.add(branch.name);
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// Detect DELETED branches (were known but no longer exist in git)
|
|
2194
|
+
for (const knownName of knownBranchNames) {
|
|
2195
|
+
if (!fetchedBranchNames.has(knownName)) {
|
|
2196
|
+
// This branch was deleted from remote
|
|
2197
|
+
const existingInList = branches.find(b => b.name === knownName);
|
|
2198
|
+
if (existingInList && !existingInList.isDeleted) {
|
|
2199
|
+
existingInList.isDeleted = true;
|
|
2200
|
+
existingInList.deletedAt = now;
|
|
2201
|
+
addLog(`Branch deleted: ${knownName}`, 'warning');
|
|
2202
|
+
// Keep it in the list temporarily
|
|
2203
|
+
allBranches.push(existingInList);
|
|
2204
|
+
}
|
|
2205
|
+
// Remove from known set after a delay (handled below)
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
|
|
2209
|
+
// Note: isNew flag is only cleared when branch becomes current (see below)
|
|
2210
|
+
|
|
2211
|
+
// Keep deleted branches in the list (don't remove them)
|
|
2212
|
+
const filteredBranches = allBranches;
|
|
2213
|
+
|
|
2214
|
+
// Detect updates on other branches (for flash notification)
|
|
2215
|
+
const updatedBranches = [];
|
|
2216
|
+
for (const branch of filteredBranches) {
|
|
2217
|
+
if (branch.isDeleted) continue;
|
|
2218
|
+
const prevCommit = previousBranchStates.get(branch.name);
|
|
2219
|
+
if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranch) {
|
|
2220
|
+
updatedBranches.push(branch);
|
|
2221
|
+
branch.justUpdated = true;
|
|
2222
|
+
}
|
|
2223
|
+
previousBranchStates.set(branch.name, branch.commit);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
// Flash and sound for updates or new branches
|
|
2227
|
+
const notifyBranches = [...updatedBranches, ...newBranchList];
|
|
2228
|
+
if (notifyBranches.length > 0) {
|
|
2229
|
+
for (const branch of updatedBranches) {
|
|
2230
|
+
addLog(`Update on ${branch.name}: ${branch.commit}`, 'update');
|
|
2231
|
+
}
|
|
2232
|
+
const names = notifyBranches.map(b => b.name).join(', ');
|
|
2233
|
+
showFlash(names);
|
|
2234
|
+
playSound();
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// Remember which branch was selected before updating the list
|
|
2238
|
+
const previouslySelectedName = selectedBranchName || (branches[selectedIndex] ? branches[selectedIndex].name : null);
|
|
2239
|
+
|
|
2240
|
+
// Sort: new branches first, then by date, deleted branches at the bottom
|
|
2241
|
+
filteredBranches.sort((a, b) => {
|
|
2242
|
+
if (a.isDeleted && !b.isDeleted) return 1;
|
|
2243
|
+
if (!a.isDeleted && b.isDeleted) return -1;
|
|
2244
|
+
if (a.isNew && !b.isNew) return -1;
|
|
2245
|
+
if (!a.isNew && b.isNew) return 1;
|
|
2246
|
+
return b.date - a.date;
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
// Store all branches (no limit) - visibleBranchCount controls display
|
|
2250
|
+
branches = filteredBranches;
|
|
2251
|
+
|
|
2252
|
+
// Restore selection to the same branch (by name) after reordering
|
|
2253
|
+
if (previouslySelectedName) {
|
|
2254
|
+
const newIndex = branches.findIndex(b => b.name === previouslySelectedName);
|
|
2255
|
+
if (newIndex >= 0) {
|
|
2256
|
+
selectedIndex = newIndex;
|
|
2257
|
+
selectedBranchName = previouslySelectedName;
|
|
2258
|
+
} else {
|
|
2259
|
+
// Branch fell off the list, keep index at bottom or clamp
|
|
2260
|
+
selectedIndex = Math.min(selectedIndex, Math.max(0, branches.length - 1));
|
|
2261
|
+
selectedBranchName = branches[selectedIndex] ? branches[selectedIndex].name : null;
|
|
2262
|
+
}
|
|
2263
|
+
} else if (selectedIndex >= branches.length) {
|
|
2264
|
+
selectedIndex = Math.max(0, branches.length - 1);
|
|
2265
|
+
selectedBranchName = branches[selectedIndex] ? branches[selectedIndex].name : null;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
// AUTO-PULL: If current branch has remote updates, pull automatically (if enabled)
|
|
2269
|
+
const currentInfo = branches.find(b => b.name === currentBranch);
|
|
2270
|
+
if (AUTO_PULL && currentInfo && currentInfo.hasUpdates && !hasMergeConflict) {
|
|
2271
|
+
addLog(`Auto-pulling changes for ${currentBranch}...`, 'update');
|
|
2272
|
+
render();
|
|
2273
|
+
|
|
2274
|
+
try {
|
|
2275
|
+
await execAsync(`git pull "${REMOTE_NAME}" "${currentBranch}"`);
|
|
2276
|
+
addLog(`Pulled successfully from ${currentBranch}`, 'success');
|
|
2277
|
+
currentInfo.hasUpdates = false;
|
|
2278
|
+
hasMergeConflict = false;
|
|
2279
|
+
// Update the stored commit to the new one
|
|
2280
|
+
const newCommit = await execAsync('git rev-parse --short HEAD');
|
|
2281
|
+
currentInfo.commit = newCommit.stdout.trim();
|
|
2282
|
+
previousBranchStates.set(currentBranch, newCommit.stdout.trim());
|
|
2283
|
+
// Reload browsers
|
|
2284
|
+
notifyClients();
|
|
2285
|
+
} catch (e) {
|
|
2286
|
+
const errMsg = e.stderr || e.stdout || e.message || String(e);
|
|
2287
|
+
if (isMergeConflict(errMsg)) {
|
|
2288
|
+
hasMergeConflict = true;
|
|
2289
|
+
addLog(`MERGE CONFLICT detected!`, 'error');
|
|
2290
|
+
addLog(`Resolve conflicts manually, then commit`, 'warning');
|
|
2291
|
+
showErrorToast(
|
|
2292
|
+
'Merge Conflict!',
|
|
2293
|
+
'Auto-pull resulted in merge conflicts that need manual resolution.',
|
|
2294
|
+
'Run: git status to see conflicts'
|
|
2295
|
+
);
|
|
2296
|
+
} else if (isAuthError(errMsg)) {
|
|
2297
|
+
addLog(`Authentication failed during pull`, 'error');
|
|
2298
|
+
addLog(`Check your Git credentials`, 'warning');
|
|
2299
|
+
showErrorToast(
|
|
2300
|
+
'Authentication Failed',
|
|
2301
|
+
'Could not authenticate with the remote during auto-pull.',
|
|
2302
|
+
'Check your Git credentials'
|
|
2303
|
+
);
|
|
2304
|
+
} else {
|
|
2305
|
+
addLog(`Auto-pull failed: ${errMsg}`, 'error');
|
|
2306
|
+
showErrorToast(
|
|
2307
|
+
'Auto-Pull Failed',
|
|
2308
|
+
truncate(errMsg, 100),
|
|
2309
|
+
'Try pulling manually with [p]'
|
|
2310
|
+
);
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
pollingStatus = 'idle';
|
|
2316
|
+
} catch (err) {
|
|
2317
|
+
const errMsg = err.stderr || err.message || String(err);
|
|
2318
|
+
|
|
2319
|
+
// Handle different error types
|
|
2320
|
+
if (isNetworkError(errMsg)) {
|
|
2321
|
+
consecutiveNetworkFailures++;
|
|
2322
|
+
if (consecutiveNetworkFailures >= 3 && !isOffline) {
|
|
2323
|
+
isOffline = true;
|
|
2324
|
+
addLog(`Network unavailable (${consecutiveNetworkFailures} failures)`, 'error');
|
|
2325
|
+
showErrorToast(
|
|
2326
|
+
'Network Unavailable',
|
|
2327
|
+
'Cannot connect to the remote repository. Git operations will fail until connection is restored.',
|
|
2328
|
+
'Check your internet connection'
|
|
2329
|
+
);
|
|
2330
|
+
}
|
|
2331
|
+
pollingStatus = 'error';
|
|
2332
|
+
} else if (isAuthError(errMsg)) {
|
|
2333
|
+
addLog(`Authentication error - check credentials`, 'error');
|
|
2334
|
+
addLog(`Try: git config credential.helper store`, 'warning');
|
|
2335
|
+
showErrorToast(
|
|
2336
|
+
'Git Authentication Error',
|
|
2337
|
+
'Failed to authenticate with the remote repository.',
|
|
2338
|
+
'Run: git config credential.helper store'
|
|
2339
|
+
);
|
|
2340
|
+
pollingStatus = 'error';
|
|
2341
|
+
} else {
|
|
2342
|
+
pollingStatus = 'error';
|
|
2343
|
+
addLog(`Polling error: ${errMsg}`, 'error');
|
|
2344
|
+
}
|
|
2345
|
+
} finally {
|
|
2346
|
+
isPolling = false;
|
|
2347
|
+
render();
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
function restartPolling() {
|
|
2352
|
+
if (pollIntervalId) {
|
|
2353
|
+
clearInterval(pollIntervalId);
|
|
2354
|
+
}
|
|
2355
|
+
pollIntervalId = setInterval(pollGitChanges, adaptivePollInterval);
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
// ============================================================================
|
|
2359
|
+
// HTTP Server
|
|
2360
|
+
// ============================================================================
|
|
2361
|
+
|
|
2362
|
+
function notifyClients() {
|
|
2363
|
+
if (NO_SERVER) return; // No clients in no-server mode
|
|
2364
|
+
clients.forEach(client => client.write('data: reload\n\n'));
|
|
2365
|
+
if (clients.size > 0) {
|
|
2366
|
+
addLog(`Reloading ${clients.size} browser(s)`, 'info');
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function handleLiveReload(req, res) {
|
|
2371
|
+
res.writeHead(200, {
|
|
2372
|
+
'Content-Type': 'text/event-stream',
|
|
2373
|
+
'Cache-Control': 'no-cache',
|
|
2374
|
+
Connection: 'keep-alive',
|
|
2375
|
+
'Access-Control-Allow-Origin': '*',
|
|
2376
|
+
});
|
|
2377
|
+
res.write('data: connected\n\n');
|
|
2378
|
+
clients.add(res);
|
|
2379
|
+
addServerLog(`Browser connected (${clients.size} active)`);
|
|
2380
|
+
render();
|
|
2381
|
+
req.on('close', () => {
|
|
2382
|
+
clients.delete(res);
|
|
2383
|
+
addServerLog(`Browser disconnected (${clients.size} active)`);
|
|
2384
|
+
render();
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
|
|
2388
|
+
function serveFile(res, filePath, logPath) {
|
|
2389
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
2390
|
+
const mimeType = MIME_TYPES[ext] || 'application/octet-stream';
|
|
2391
|
+
|
|
2392
|
+
fs.readFile(filePath, (err, data) => {
|
|
2393
|
+
if (err) {
|
|
2394
|
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
2395
|
+
res.end('<h1>404 Not Found</h1>');
|
|
2396
|
+
addServerLog(`GET ${logPath} → 404`, true);
|
|
2397
|
+
return;
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
if (mimeType === 'text/html') {
|
|
2401
|
+
let html = data.toString();
|
|
2402
|
+
if (html.includes('</body>')) {
|
|
2403
|
+
html = html.replace('</body>', LIVE_RELOAD_SCRIPT);
|
|
2404
|
+
}
|
|
2405
|
+
res.writeHead(200, { 'Content-Type': mimeType });
|
|
2406
|
+
res.end(html);
|
|
2407
|
+
} else {
|
|
2408
|
+
res.writeHead(200, { 'Content-Type': mimeType });
|
|
2409
|
+
res.end(data);
|
|
2410
|
+
}
|
|
2411
|
+
addServerLog(`GET ${logPath} → 200`);
|
|
2412
|
+
});
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
const server = http.createServer((req, res) => {
|
|
2416
|
+
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
2417
|
+
let pathname = url.pathname;
|
|
2418
|
+
const logPath = pathname; // Keep original for logging
|
|
2419
|
+
|
|
2420
|
+
if (pathname === '/livereload') {
|
|
2421
|
+
handleLiveReload(req, res);
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
|
|
2425
|
+
pathname = path.normalize(pathname).replace(/^(\.\.[\/\\])+/, '');
|
|
2426
|
+
let filePath = path.join(STATIC_DIR, pathname);
|
|
2427
|
+
|
|
2428
|
+
if (fs.existsSync(filePath) && fs.statSync(filePath).isDirectory()) {
|
|
2429
|
+
filePath = path.join(filePath, 'index.html');
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
if (!fs.existsSync(filePath)) {
|
|
2433
|
+
if (fs.existsSync(filePath + '.html')) {
|
|
2434
|
+
filePath = filePath + '.html';
|
|
2435
|
+
} else {
|
|
2436
|
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
|
2437
|
+
res.end('<h1>404 Not Found</h1>');
|
|
2438
|
+
addServerLog(`GET ${logPath} → 404`, true);
|
|
2439
|
+
return;
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
serveFile(res, filePath, logPath);
|
|
2444
|
+
});
|
|
2445
|
+
|
|
2446
|
+
// ============================================================================
|
|
2447
|
+
// File Watcher
|
|
2448
|
+
// ============================================================================
|
|
2449
|
+
|
|
2450
|
+
let fileWatcher = null;
|
|
2451
|
+
let debounceTimer = null;
|
|
2452
|
+
|
|
2453
|
+
function setupFileWatcher() {
|
|
2454
|
+
if (fileWatcher) fileWatcher.close();
|
|
2455
|
+
|
|
2456
|
+
try {
|
|
2457
|
+
fileWatcher = fs.watch(STATIC_DIR, { recursive: true }, (eventType, filename) => {
|
|
2458
|
+
if (!filename) return;
|
|
2459
|
+
clearTimeout(debounceTimer);
|
|
2460
|
+
debounceTimer = setTimeout(() => {
|
|
2461
|
+
addLog(`File changed: ${filename}`, 'info');
|
|
2462
|
+
notifyClients();
|
|
2463
|
+
render();
|
|
2464
|
+
}, 100);
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
fileWatcher.on('error', (err) => {
|
|
2468
|
+
addLog(`File watcher error: ${err.message}`, 'error');
|
|
2469
|
+
});
|
|
2470
|
+
} catch (err) {
|
|
2471
|
+
addLog(`Could not set up file watcher: ${err.message}`, 'error');
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
// ============================================================================
|
|
2476
|
+
// Keyboard Input
|
|
2477
|
+
// ============================================================================
|
|
2478
|
+
|
|
2479
|
+
function applySearchFilter() {
|
|
2480
|
+
if (!searchQuery) {
|
|
2481
|
+
filteredBranches = null;
|
|
2482
|
+
return;
|
|
2483
|
+
}
|
|
2484
|
+
const query = searchQuery.toLowerCase();
|
|
2485
|
+
filteredBranches = branches.filter(b => b.name.toLowerCase().includes(query));
|
|
2486
|
+
// Reset selection if out of bounds
|
|
2487
|
+
if (selectedIndex >= filteredBranches.length) {
|
|
2488
|
+
selectedIndex = Math.max(0, filteredBranches.length - 1);
|
|
2489
|
+
}
|
|
2490
|
+
}
|
|
2491
|
+
|
|
2492
|
+
function setupKeyboardInput() {
|
|
2493
|
+
if (process.stdin.isTTY) {
|
|
2494
|
+
process.stdin.setRawMode(true);
|
|
2495
|
+
}
|
|
2496
|
+
process.stdin.resume();
|
|
2497
|
+
process.stdin.setEncoding('utf8');
|
|
2498
|
+
|
|
2499
|
+
process.stdin.on('data', async (key) => {
|
|
2500
|
+
// Handle search mode input
|
|
2501
|
+
if (searchMode) {
|
|
2502
|
+
if (key === '\u001b' || key === '\r' || key === '\n') { // Escape or Enter exits search
|
|
2503
|
+
searchMode = false;
|
|
2504
|
+
if (key === '\u001b') {
|
|
2505
|
+
// Escape clears search
|
|
2506
|
+
searchQuery = '';
|
|
2507
|
+
filteredBranches = null;
|
|
2508
|
+
}
|
|
2509
|
+
render();
|
|
2510
|
+
return;
|
|
2511
|
+
} else if (key === '\u007f' || key === '\b') { // Backspace
|
|
2512
|
+
searchQuery = searchQuery.slice(0, -1);
|
|
2513
|
+
applySearchFilter();
|
|
2514
|
+
render();
|
|
2515
|
+
return;
|
|
2516
|
+
} else if (key.length === 1 && key >= ' ' && key <= '~') { // Printable chars
|
|
2517
|
+
searchQuery += key;
|
|
2518
|
+
applySearchFilter();
|
|
2519
|
+
render();
|
|
2520
|
+
return;
|
|
2521
|
+
}
|
|
2522
|
+
// Allow nav keys in search mode
|
|
2523
|
+
if (key !== '\u001b[A' && key !== '\u001b[B') {
|
|
2524
|
+
return;
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
// Handle modal modes
|
|
2529
|
+
if (previewMode) {
|
|
2530
|
+
if (key === 'v' || key === '\u001b' || key === '\r' || key === '\n') {
|
|
2531
|
+
previewMode = false;
|
|
2532
|
+
previewData = null;
|
|
2533
|
+
render();
|
|
2534
|
+
return;
|
|
2535
|
+
}
|
|
2536
|
+
return; // Ignore other keys in preview mode
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
if (historyMode) {
|
|
2540
|
+
if (key === 'h' || key === '\u001b') {
|
|
2541
|
+
historyMode = false;
|
|
2542
|
+
render();
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
if (key === 'u') {
|
|
2546
|
+
historyMode = false;
|
|
2547
|
+
await undoLastSwitch();
|
|
2548
|
+
await pollGitChanges();
|
|
2549
|
+
return;
|
|
2550
|
+
}
|
|
2551
|
+
return; // Ignore other keys in history mode
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2554
|
+
if (infoMode) {
|
|
2555
|
+
if (key === 'i' || key === '\u001b') {
|
|
2556
|
+
infoMode = false;
|
|
2557
|
+
render();
|
|
2558
|
+
return;
|
|
2559
|
+
}
|
|
2560
|
+
return; // Ignore other keys in info mode
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
if (logViewMode) {
|
|
2564
|
+
if (key === 'l' || key === '\u001b') {
|
|
2565
|
+
logViewMode = false;
|
|
2566
|
+
logScrollOffset = 0;
|
|
2567
|
+
render();
|
|
2568
|
+
return;
|
|
2569
|
+
}
|
|
2570
|
+
if (key === '1') { // Switch to activity tab
|
|
2571
|
+
logViewTab = 'activity';
|
|
2572
|
+
logScrollOffset = 0;
|
|
2573
|
+
render();
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
if (key === '2') { // Switch to server tab
|
|
2577
|
+
logViewTab = 'server';
|
|
2578
|
+
logScrollOffset = 0;
|
|
2579
|
+
render();
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
// Get current log data for scroll bounds
|
|
2583
|
+
const currentLogData = logViewTab === 'server' ? serverLogBuffer : activityLog;
|
|
2584
|
+
const maxScroll = Math.max(0, currentLogData.length - 10);
|
|
2585
|
+
if (key === '\u001b[A' || key === 'k') { // Up - scroll
|
|
2586
|
+
logScrollOffset = Math.min(logScrollOffset + 1, maxScroll);
|
|
2587
|
+
render();
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
if (key === '\u001b[B' || key === 'j') { // Down - scroll
|
|
2591
|
+
logScrollOffset = Math.max(0, logScrollOffset - 1);
|
|
2592
|
+
render();
|
|
2593
|
+
return;
|
|
2594
|
+
}
|
|
2595
|
+
if (key === 'R' && SERVER_MODE === 'command') { // Restart server from log view
|
|
2596
|
+
restartServerProcess();
|
|
2597
|
+
render();
|
|
2598
|
+
return;
|
|
2599
|
+
}
|
|
2600
|
+
return; // Ignore other keys in log view mode
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
// Dismiss flash on any key
|
|
2604
|
+
if (flashMessage) {
|
|
2605
|
+
hideFlash();
|
|
2606
|
+
if (key !== '\u001b[A' && key !== '\u001b[B' && key !== '\r' && key !== 'q') {
|
|
2607
|
+
return;
|
|
2608
|
+
}
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// Dismiss error toast on any key
|
|
2612
|
+
if (errorToast) {
|
|
2613
|
+
hideErrorToast();
|
|
2614
|
+
if (key !== '\u001b[A' && key !== '\u001b[B' && key !== '\r' && key !== 'q') {
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
|
|
2619
|
+
const displayBranches = filteredBranches !== null ? filteredBranches : branches;
|
|
2620
|
+
|
|
2621
|
+
switch (key) {
|
|
2622
|
+
case '\u001b[A': // Up arrow
|
|
2623
|
+
case 'k':
|
|
2624
|
+
if (selectedIndex > 0) {
|
|
2625
|
+
selectedIndex--;
|
|
2626
|
+
selectedBranchName = displayBranches[selectedIndex] ? displayBranches[selectedIndex].name : null;
|
|
2627
|
+
render();
|
|
2628
|
+
}
|
|
2629
|
+
break;
|
|
2630
|
+
|
|
2631
|
+
case '\u001b[B': // Down arrow
|
|
2632
|
+
case 'j':
|
|
2633
|
+
if (selectedIndex < displayBranches.length - 1) {
|
|
2634
|
+
selectedIndex++;
|
|
2635
|
+
selectedBranchName = displayBranches[selectedIndex] ? displayBranches[selectedIndex].name : null;
|
|
2636
|
+
render();
|
|
2637
|
+
}
|
|
2638
|
+
break;
|
|
2639
|
+
|
|
2640
|
+
case '\r': // Enter
|
|
2641
|
+
case '\n':
|
|
2642
|
+
if (displayBranches.length > 0 && selectedIndex < displayBranches.length) {
|
|
2643
|
+
const branch = displayBranches[selectedIndex];
|
|
2644
|
+
if (branch.isDeleted) {
|
|
2645
|
+
addLog(`Cannot switch to deleted branch: ${branch.name}`, 'error');
|
|
2646
|
+
render();
|
|
2647
|
+
} else if (branch.name !== currentBranch) {
|
|
2648
|
+
// Clear search when switching
|
|
2649
|
+
searchQuery = '';
|
|
2650
|
+
filteredBranches = null;
|
|
2651
|
+
searchMode = false;
|
|
2652
|
+
await switchToBranch(branch.name);
|
|
2653
|
+
await pollGitChanges();
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
break;
|
|
2657
|
+
|
|
2658
|
+
case 'v': // Preview pane
|
|
2659
|
+
if (displayBranches.length > 0 && selectedIndex < displayBranches.length) {
|
|
2660
|
+
const branch = displayBranches[selectedIndex];
|
|
2661
|
+
addLog(`Loading preview for ${branch.name}...`, 'info');
|
|
2662
|
+
render();
|
|
2663
|
+
previewData = await getPreviewData(branch.name);
|
|
2664
|
+
previewMode = true;
|
|
2665
|
+
render();
|
|
2666
|
+
}
|
|
2667
|
+
break;
|
|
2668
|
+
|
|
2669
|
+
case '/': // Search mode
|
|
2670
|
+
searchMode = true;
|
|
2671
|
+
searchQuery = '';
|
|
2672
|
+
selectedIndex = 0;
|
|
2673
|
+
render();
|
|
2674
|
+
break;
|
|
2675
|
+
|
|
2676
|
+
case 'h': // History
|
|
2677
|
+
historyMode = true;
|
|
2678
|
+
render();
|
|
2679
|
+
break;
|
|
2680
|
+
|
|
2681
|
+
case 'i': // Server info
|
|
2682
|
+
infoMode = true;
|
|
2683
|
+
render();
|
|
2684
|
+
break;
|
|
2685
|
+
|
|
2686
|
+
case 'u': // Undo last switch
|
|
2687
|
+
await undoLastSwitch();
|
|
2688
|
+
await pollGitChanges();
|
|
2689
|
+
break;
|
|
2690
|
+
|
|
2691
|
+
case 'p':
|
|
2692
|
+
await pullCurrentBranch();
|
|
2693
|
+
await pollGitChanges();
|
|
2694
|
+
break;
|
|
2695
|
+
|
|
2696
|
+
case 'r':
|
|
2697
|
+
if (SERVER_MODE === 'static') {
|
|
2698
|
+
addLog('Force reloading all browsers...', 'update');
|
|
2699
|
+
notifyClients();
|
|
2700
|
+
render();
|
|
2701
|
+
}
|
|
2702
|
+
break;
|
|
2703
|
+
|
|
2704
|
+
case 'R': // Restart server (command mode)
|
|
2705
|
+
if (SERVER_MODE === 'command') {
|
|
2706
|
+
restartServerProcess();
|
|
2707
|
+
render();
|
|
2708
|
+
}
|
|
2709
|
+
break;
|
|
2710
|
+
|
|
2711
|
+
case 'l': // View server logs
|
|
2712
|
+
if (!NO_SERVER) {
|
|
2713
|
+
logViewMode = true;
|
|
2714
|
+
logScrollOffset = 0;
|
|
2715
|
+
render();
|
|
2716
|
+
}
|
|
2717
|
+
break;
|
|
2718
|
+
|
|
2719
|
+
case 'f':
|
|
2720
|
+
addLog('Fetching all branches...', 'update');
|
|
2721
|
+
await pollGitChanges();
|
|
2722
|
+
// Refresh sparklines on manual fetch
|
|
2723
|
+
addLog('Refreshing activity sparklines...', 'info');
|
|
2724
|
+
lastSparklineUpdate = 0; // Force refresh
|
|
2725
|
+
await refreshAllSparklines();
|
|
2726
|
+
render();
|
|
2727
|
+
break;
|
|
2728
|
+
|
|
2729
|
+
case 's':
|
|
2730
|
+
soundEnabled = !soundEnabled;
|
|
2731
|
+
addLog(`Sound notifications ${soundEnabled ? 'enabled' : 'disabled'}`, 'info');
|
|
2732
|
+
if (soundEnabled) playSound();
|
|
2733
|
+
render();
|
|
2734
|
+
break;
|
|
2735
|
+
|
|
2736
|
+
// Number keys to set visible branch count
|
|
2737
|
+
case '1': case '2': case '3': case '4': case '5':
|
|
2738
|
+
case '6': case '7': case '8': case '9':
|
|
2739
|
+
visibleBranchCount = parseInt(key, 10);
|
|
2740
|
+
addLog(`Showing ${visibleBranchCount} branches`, 'info');
|
|
2741
|
+
render();
|
|
2742
|
+
break;
|
|
2743
|
+
|
|
2744
|
+
case '0': // 0 = 10 branches
|
|
2745
|
+
visibleBranchCount = 10;
|
|
2746
|
+
addLog(`Showing ${visibleBranchCount} branches`, 'info');
|
|
2747
|
+
render();
|
|
2748
|
+
break;
|
|
2749
|
+
|
|
2750
|
+
case '+':
|
|
2751
|
+
case '=': // = key (same key as + without shift)
|
|
2752
|
+
if (visibleBranchCount < getMaxBranchesForScreen()) {
|
|
2753
|
+
visibleBranchCount++;
|
|
2754
|
+
addLog(`Showing ${visibleBranchCount} branches`, 'info');
|
|
2755
|
+
render();
|
|
2756
|
+
}
|
|
2757
|
+
break;
|
|
2758
|
+
|
|
2759
|
+
case '-':
|
|
2760
|
+
case '_': // _ key (same key as - with shift)
|
|
2761
|
+
if (visibleBranchCount > 1) {
|
|
2762
|
+
visibleBranchCount--;
|
|
2763
|
+
addLog(`Showing ${visibleBranchCount} branches`, 'info');
|
|
2764
|
+
render();
|
|
2765
|
+
}
|
|
2766
|
+
break;
|
|
2767
|
+
|
|
2768
|
+
case 'q':
|
|
2769
|
+
case '\u0003': // Ctrl+C
|
|
2770
|
+
await shutdown();
|
|
2771
|
+
break;
|
|
2772
|
+
|
|
2773
|
+
case '\u001b': // Escape - clear search if active, otherwise quit
|
|
2774
|
+
if (searchQuery || filteredBranches) {
|
|
2775
|
+
searchQuery = '';
|
|
2776
|
+
filteredBranches = null;
|
|
2777
|
+
render();
|
|
2778
|
+
} else {
|
|
2779
|
+
await shutdown();
|
|
2780
|
+
}
|
|
2781
|
+
break;
|
|
2782
|
+
}
|
|
2783
|
+
});
|
|
2784
|
+
}
|
|
2785
|
+
|
|
2786
|
+
// ============================================================================
|
|
2787
|
+
// Shutdown
|
|
2788
|
+
// ============================================================================
|
|
2789
|
+
|
|
2790
|
+
let isShuttingDown = false;
|
|
2791
|
+
|
|
2792
|
+
async function shutdown() {
|
|
2793
|
+
if (isShuttingDown) return;
|
|
2794
|
+
isShuttingDown = true;
|
|
2795
|
+
|
|
2796
|
+
// Restore terminal
|
|
2797
|
+
write(ansi.showCursor);
|
|
2798
|
+
write(ansi.restoreScreen);
|
|
2799
|
+
restoreTerminalTitle();
|
|
2800
|
+
|
|
2801
|
+
if (process.stdin.isTTY) {
|
|
2802
|
+
process.stdin.setRawMode(false);
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
if (fileWatcher) fileWatcher.close();
|
|
2806
|
+
if (pollIntervalId) clearInterval(pollIntervalId);
|
|
2807
|
+
|
|
2808
|
+
// Stop server based on mode
|
|
2809
|
+
if (SERVER_MODE === 'command') {
|
|
2810
|
+
stopServerProcess();
|
|
2811
|
+
} else if (SERVER_MODE === 'static') {
|
|
2812
|
+
clients.forEach(client => client.end());
|
|
2813
|
+
clients.clear();
|
|
2814
|
+
|
|
2815
|
+
const serverClosePromise = new Promise(resolve => server.close(resolve));
|
|
2816
|
+
const timeoutPromise = new Promise(resolve => setTimeout(resolve, 2000));
|
|
2817
|
+
await Promise.race([serverClosePromise, timeoutPromise]);
|
|
2818
|
+
}
|
|
2819
|
+
|
|
2820
|
+
console.log('\n✓ Git Watchtower stopped\n');
|
|
2821
|
+
process.exit(0);
|
|
2822
|
+
}
|
|
2823
|
+
|
|
2824
|
+
process.on('SIGINT', shutdown);
|
|
2825
|
+
process.on('SIGTERM', shutdown);
|
|
2826
|
+
process.on('uncaughtException', (err) => {
|
|
2827
|
+
write(ansi.showCursor);
|
|
2828
|
+
write(ansi.restoreScreen);
|
|
2829
|
+
restoreTerminalTitle();
|
|
2830
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
2831
|
+
console.error('Uncaught exception:', err);
|
|
2832
|
+
process.exit(1);
|
|
2833
|
+
});
|
|
2834
|
+
|
|
2835
|
+
// ============================================================================
|
|
2836
|
+
// Startup
|
|
2837
|
+
// ============================================================================
|
|
2838
|
+
|
|
2839
|
+
async function start() {
|
|
2840
|
+
// Check if git is available
|
|
2841
|
+
const gitAvailable = await checkGitAvailable();
|
|
2842
|
+
if (!gitAvailable) {
|
|
2843
|
+
console.error('\n' + ansi.red + ansi.bold + '✗ Error: Git is not installed or not in PATH' + ansi.reset);
|
|
2844
|
+
console.error('\n Git Watchtower requires Git to be installed.');
|
|
2845
|
+
console.error(' Install Git from: https://git-scm.com/downloads\n');
|
|
2846
|
+
process.exit(1);
|
|
2847
|
+
}
|
|
2848
|
+
|
|
2849
|
+
// Load or create configuration
|
|
2850
|
+
const config = await ensureConfig(cliArgs);
|
|
2851
|
+
applyConfig(config);
|
|
2852
|
+
|
|
2853
|
+
// Check for remote before starting TUI
|
|
2854
|
+
const hasRemote = await checkRemoteExists();
|
|
2855
|
+
if (!hasRemote) {
|
|
2856
|
+
console.error('\n' + ansi.red + ansi.bold + '✗ Error: No Git remote configured' + ansi.reset);
|
|
2857
|
+
console.error('\n Git Watchtower requires a Git remote to watch for updates.');
|
|
2858
|
+
console.error(' Add a remote with:\n');
|
|
2859
|
+
console.error(` git remote add ${REMOTE_NAME} <repository-url>\n`);
|
|
2860
|
+
process.exit(1);
|
|
2861
|
+
}
|
|
2862
|
+
|
|
2863
|
+
// Save screen and hide cursor
|
|
2864
|
+
write(ansi.saveScreen);
|
|
2865
|
+
write(ansi.hideCursor);
|
|
2866
|
+
|
|
2867
|
+
// Set terminal tab title to show project name
|
|
2868
|
+
const projectName = path.basename(PROJECT_ROOT);
|
|
2869
|
+
setTerminalTitle(`Git Watchtower - ${projectName}`);
|
|
2870
|
+
|
|
2871
|
+
// Check static directory (only needed when static server is running)
|
|
2872
|
+
if (SERVER_MODE === 'static' && !fs.existsSync(STATIC_DIR)) {
|
|
2873
|
+
fs.mkdirSync(STATIC_DIR, { recursive: true });
|
|
2874
|
+
}
|
|
2875
|
+
|
|
2876
|
+
// Get initial state
|
|
2877
|
+
currentBranch = await getCurrentBranch();
|
|
2878
|
+
|
|
2879
|
+
// Warn if in detached HEAD state
|
|
2880
|
+
if (isDetachedHead) {
|
|
2881
|
+
addLog(`Warning: In detached HEAD state`, 'warning');
|
|
2882
|
+
}
|
|
2883
|
+
branches = await getAllBranches();
|
|
2884
|
+
|
|
2885
|
+
// Initialize previous states and known branches
|
|
2886
|
+
for (const branch of branches) {
|
|
2887
|
+
previousBranchStates.set(branch.name, branch.commit);
|
|
2888
|
+
knownBranchNames.add(branch.name);
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
// Find current branch in list and select it
|
|
2892
|
+
const currentIndex = branches.findIndex(b => b.name === currentBranch);
|
|
2893
|
+
if (currentIndex >= 0) {
|
|
2894
|
+
selectedIndex = currentIndex;
|
|
2895
|
+
selectedBranchName = currentBranch;
|
|
2896
|
+
} else if (branches.length > 0) {
|
|
2897
|
+
selectedBranchName = branches[0].name;
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
// Load sparklines in background
|
|
2901
|
+
refreshAllSparklines().catch(() => {});
|
|
2902
|
+
|
|
2903
|
+
// Start server based on mode
|
|
2904
|
+
if (SERVER_MODE === 'none') {
|
|
2905
|
+
addLog(`Running in no-server mode (branch monitoring only)`, 'info');
|
|
2906
|
+
addLog(`Current branch: ${currentBranch}`, 'info');
|
|
2907
|
+
render();
|
|
2908
|
+
} else if (SERVER_MODE === 'command') {
|
|
2909
|
+
addLog(`Command mode: ${SERVER_COMMAND}`, 'info');
|
|
2910
|
+
addLog(`Current branch: ${currentBranch}`, 'info');
|
|
2911
|
+
render();
|
|
2912
|
+
// Start the user's dev server
|
|
2913
|
+
startServerProcess();
|
|
2914
|
+
} else {
|
|
2915
|
+
// Static mode
|
|
2916
|
+
server.listen(PORT, () => {
|
|
2917
|
+
addLog(`Server started on http://localhost:${PORT}`, 'success');
|
|
2918
|
+
addLog(`Serving ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`, 'info');
|
|
2919
|
+
addLog(`Current branch: ${currentBranch}`, 'info');
|
|
2920
|
+
// Add server log entries for static server
|
|
2921
|
+
addServerLog(`Static server started on http://localhost:${PORT}`);
|
|
2922
|
+
addServerLog(`Serving files from: ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`);
|
|
2923
|
+
addServerLog(`Live reload enabled - waiting for browser connections...`);
|
|
2924
|
+
render();
|
|
2925
|
+
});
|
|
2926
|
+
|
|
2927
|
+
server.on('error', (err) => {
|
|
2928
|
+
if (err.code === 'EADDRINUSE') {
|
|
2929
|
+
addLog(`Port ${PORT} is already in use`, 'error');
|
|
2930
|
+
addLog(`Try a different port: git-watchtower -p ${PORT + 1}`, 'warning');
|
|
2931
|
+
addServerLog(`Error: Port ${PORT} is already in use`, true);
|
|
2932
|
+
} else {
|
|
2933
|
+
addLog(`Server error: ${err.message}`, 'error');
|
|
2934
|
+
addServerLog(`Error: ${err.message}`, true);
|
|
2935
|
+
}
|
|
2936
|
+
render();
|
|
2937
|
+
});
|
|
2938
|
+
|
|
2939
|
+
// Setup file watcher (only for static mode)
|
|
2940
|
+
setupFileWatcher();
|
|
2941
|
+
}
|
|
2942
|
+
|
|
2943
|
+
// Setup keyboard input
|
|
2944
|
+
setupKeyboardInput();
|
|
2945
|
+
|
|
2946
|
+
// Handle terminal resize
|
|
2947
|
+
process.stdout.on('resize', () => {
|
|
2948
|
+
updateTerminalSize();
|
|
2949
|
+
render();
|
|
2950
|
+
});
|
|
2951
|
+
|
|
2952
|
+
// Start polling with adaptive interval
|
|
2953
|
+
pollIntervalId = setInterval(pollGitChanges, adaptivePollInterval);
|
|
2954
|
+
|
|
2955
|
+
// Initial render
|
|
2956
|
+
render();
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
start().catch(err => {
|
|
2960
|
+
write(ansi.showCursor);
|
|
2961
|
+
write(ansi.restoreScreen);
|
|
2962
|
+
restoreTerminalTitle();
|
|
2963
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
2964
|
+
console.error('Failed to start:', err);
|
|
2965
|
+
process.exit(1);
|
|
2966
|
+
});
|