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.
@@ -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
+ });