git-watchtower 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -0
- package/bin/git-watchtower.js +829 -1521
- package/package.json +1 -1
package/bin/git-watchtower.js
CHANGED
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
* R - Restart dev server (command mode)
|
|
44
44
|
* l - View server logs (command mode)
|
|
45
45
|
* o - Open live server in browser
|
|
46
|
+
* b - Branch actions (open on GitHub, Claude session, create/approve/merge PR, CI)
|
|
46
47
|
* f - Fetch all branches + refresh sparklines
|
|
47
48
|
* s - Toggle sound notifications
|
|
48
49
|
* c - Toggle casino mode (Vegas-style feedback)
|
|
@@ -65,119 +66,38 @@ const casinoSounds = require('../src/casino/sounds');
|
|
|
65
66
|
// Gitignore utilities for file watcher
|
|
66
67
|
const { loadGitignorePatterns, shouldIgnoreFile } = require('../src/utils/gitignore');
|
|
67
68
|
|
|
68
|
-
//
|
|
69
|
-
const
|
|
69
|
+
// Extracted modules
|
|
70
|
+
const { formatTimeAgo } = require('../src/utils/time');
|
|
71
|
+
const { openInBrowser: openUrl } = require('../src/utils/browser');
|
|
72
|
+
const { playSound: playSoundEffect } = require('../src/utils/sound');
|
|
73
|
+
const { parseArgs: parseCliArgs, applyCliArgsToConfig: mergeCliArgs, getHelpText, PACKAGE_VERSION } = require('../src/cli/args');
|
|
74
|
+
const { parseRemoteUrl, buildBranchUrl, detectPlatform, buildWebUrl, extractSessionUrl } = require('../src/git/remote');
|
|
75
|
+
const { parseGitHubPr, parseGitLabMr, parseGitHubPrList, parseGitLabMrList, isBaseBranch } = require('../src/git/pr');
|
|
70
76
|
|
|
71
77
|
// ============================================================================
|
|
72
|
-
// Security & Validation
|
|
78
|
+
// Security & Validation (imported from src/git/branch.js and src/git/commands.js)
|
|
73
79
|
// ============================================================================
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const VALID_BRANCH_PATTERN = /^[a-zA-Z0-9_\-./]+$/;
|
|
77
|
-
|
|
78
|
-
function isValidBranchName(name) {
|
|
79
|
-
if (!name || typeof name !== 'string') return false;
|
|
80
|
-
if (name.length > 255) return false;
|
|
81
|
-
if (!VALID_BRANCH_PATTERN.test(name)) return false;
|
|
82
|
-
// Reject dangerous patterns
|
|
83
|
-
if (name.includes('..')) return false;
|
|
84
|
-
if (name.startsWith('-')) return false;
|
|
85
|
-
if (name.startsWith('/') || name.endsWith('/')) return false;
|
|
86
|
-
return true;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function sanitizeBranchName(name) {
|
|
90
|
-
if (!isValidBranchName(name)) {
|
|
91
|
-
throw new Error(`Invalid branch name: ${name}`);
|
|
92
|
-
}
|
|
93
|
-
return name;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
async function checkGitAvailable() {
|
|
97
|
-
return new Promise((resolve) => {
|
|
98
|
-
exec('git --version', (error) => {
|
|
99
|
-
resolve(!error);
|
|
100
|
-
});
|
|
101
|
-
});
|
|
102
|
-
}
|
|
80
|
+
const { isValidBranchName, sanitizeBranchName } = require('../src/git/branch');
|
|
81
|
+
const { isGitAvailable: checkGitAvailable } = require('../src/git/commands');
|
|
103
82
|
|
|
104
83
|
// ============================================================================
|
|
105
|
-
// Configuration
|
|
84
|
+
// Configuration (imports from src/config/, inline wizard kept here)
|
|
106
85
|
// ============================================================================
|
|
86
|
+
const { getDefaultConfig, migrateConfig } = require('../src/config/schema');
|
|
87
|
+
const { getConfigPath, loadConfig: loadConfigFile, saveConfig: saveConfigFile, CONFIG_FILE_NAME } = require('../src/config/loader');
|
|
107
88
|
|
|
108
|
-
|
|
109
|
-
const
|
|
89
|
+
// Centralized state store
|
|
90
|
+
const { Store } = require('../src/state/store');
|
|
91
|
+
const store = new Store();
|
|
110
92
|
|
|
111
|
-
|
|
112
|
-
return path.join(PROJECT_ROOT, CONFIG_FILE_NAME);
|
|
113
|
-
}
|
|
93
|
+
const PROJECT_ROOT = process.cwd();
|
|
114
94
|
|
|
115
95
|
function loadConfig() {
|
|
116
|
-
|
|
117
|
-
if (fs.existsSync(configPath)) {
|
|
118
|
-
try {
|
|
119
|
-
const content = fs.readFileSync(configPath, 'utf8');
|
|
120
|
-
return JSON.parse(content);
|
|
121
|
-
} catch (e) {
|
|
122
|
-
console.error(`Warning: Could not parse ${CONFIG_FILE_NAME}: ${e.message}`);
|
|
123
|
-
return null;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
return null;
|
|
96
|
+
return loadConfigFile(PROJECT_ROOT);
|
|
127
97
|
}
|
|
128
98
|
|
|
129
99
|
function saveConfig(config) {
|
|
130
|
-
|
|
131
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function getDefaultConfig() {
|
|
135
|
-
return {
|
|
136
|
-
// Server settings
|
|
137
|
-
server: {
|
|
138
|
-
mode: 'static', // 'static' | 'command' | 'none'
|
|
139
|
-
staticDir: 'public', // Directory for static mode
|
|
140
|
-
command: '', // Command for command mode (e.g., 'npm run dev')
|
|
141
|
-
port: 3000, // Port for static mode / display for command mode
|
|
142
|
-
restartOnSwitch: true, // Restart server on branch switch (command mode)
|
|
143
|
-
},
|
|
144
|
-
// Git settings
|
|
145
|
-
remoteName: 'origin', // Git remote name
|
|
146
|
-
autoPull: true, // Auto-pull when current branch has updates
|
|
147
|
-
gitPollInterval: 5000, // Polling interval in ms
|
|
148
|
-
// UI settings
|
|
149
|
-
soundEnabled: true,
|
|
150
|
-
visibleBranches: 7,
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// Migrate old config format to new format
|
|
155
|
-
function migrateConfig(config) {
|
|
156
|
-
if (config.server) return config; // Already new format
|
|
157
|
-
|
|
158
|
-
// Convert old format to new
|
|
159
|
-
const newConfig = getDefaultConfig();
|
|
160
|
-
|
|
161
|
-
if (config.noServer) {
|
|
162
|
-
newConfig.server.mode = 'none';
|
|
163
|
-
}
|
|
164
|
-
if (config.port) {
|
|
165
|
-
newConfig.server.port = config.port;
|
|
166
|
-
}
|
|
167
|
-
if (config.staticDir) {
|
|
168
|
-
newConfig.server.staticDir = config.staticDir;
|
|
169
|
-
}
|
|
170
|
-
if (config.gitPollInterval) {
|
|
171
|
-
newConfig.gitPollInterval = config.gitPollInterval;
|
|
172
|
-
}
|
|
173
|
-
if (typeof config.soundEnabled === 'boolean') {
|
|
174
|
-
newConfig.soundEnabled = config.soundEnabled;
|
|
175
|
-
}
|
|
176
|
-
if (config.visibleBranches) {
|
|
177
|
-
newConfig.visibleBranches = config.visibleBranches;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
return newConfig;
|
|
100
|
+
saveConfigFile(config, PROJECT_ROOT);
|
|
181
101
|
}
|
|
182
102
|
|
|
183
103
|
async function promptUser(question, defaultValue = '') {
|
|
@@ -401,7 +321,7 @@ async function ensureConfig(cliArgs) {
|
|
|
401
321
|
// Check if --init flag was passed (force reconfiguration)
|
|
402
322
|
if (cliArgs.init) {
|
|
403
323
|
const config = await runConfigurationWizard();
|
|
404
|
-
return
|
|
324
|
+
return mergeCliArgs(config, cliArgs);
|
|
405
325
|
}
|
|
406
326
|
|
|
407
327
|
// Load existing config
|
|
@@ -423,191 +343,16 @@ async function ensureConfig(cliArgs) {
|
|
|
423
343
|
}
|
|
424
344
|
|
|
425
345
|
// Merge CLI args over config (CLI takes precedence)
|
|
426
|
-
return
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function applyCliArgsToConfig(config, cliArgs) {
|
|
430
|
-
// Server settings
|
|
431
|
-
if (cliArgs.mode !== null) {
|
|
432
|
-
config.server.mode = cliArgs.mode;
|
|
433
|
-
}
|
|
434
|
-
if (cliArgs.noServer) {
|
|
435
|
-
config.server.mode = 'none';
|
|
436
|
-
}
|
|
437
|
-
if (cliArgs.port !== null) {
|
|
438
|
-
config.server.port = cliArgs.port;
|
|
439
|
-
}
|
|
440
|
-
if (cliArgs.staticDir !== null) {
|
|
441
|
-
config.server.staticDir = cliArgs.staticDir;
|
|
442
|
-
}
|
|
443
|
-
if (cliArgs.command !== null) {
|
|
444
|
-
config.server.command = cliArgs.command;
|
|
445
|
-
}
|
|
446
|
-
if (cliArgs.restartOnSwitch !== null) {
|
|
447
|
-
config.server.restartOnSwitch = cliArgs.restartOnSwitch;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
// Git settings
|
|
451
|
-
if (cliArgs.remote !== null) {
|
|
452
|
-
config.remoteName = cliArgs.remote;
|
|
453
|
-
}
|
|
454
|
-
if (cliArgs.autoPull !== null) {
|
|
455
|
-
config.autoPull = cliArgs.autoPull;
|
|
456
|
-
}
|
|
457
|
-
if (cliArgs.pollInterval !== null) {
|
|
458
|
-
config.gitPollInterval = cliArgs.pollInterval;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
// UI settings
|
|
462
|
-
if (cliArgs.sound !== null) {
|
|
463
|
-
config.soundEnabled = cliArgs.sound;
|
|
464
|
-
}
|
|
465
|
-
if (cliArgs.visibleBranches !== null) {
|
|
466
|
-
config.visibleBranches = cliArgs.visibleBranches;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
return config;
|
|
346
|
+
return mergeCliArgs(config, cliArgs);
|
|
470
347
|
}
|
|
471
348
|
|
|
472
|
-
//
|
|
473
|
-
function parseArgs() {
|
|
474
|
-
const args = process.argv.slice(2);
|
|
475
|
-
const result = {
|
|
476
|
-
// Server settings
|
|
477
|
-
mode: null,
|
|
478
|
-
noServer: false,
|
|
479
|
-
port: null,
|
|
480
|
-
staticDir: null,
|
|
481
|
-
command: null,
|
|
482
|
-
restartOnSwitch: null,
|
|
483
|
-
// Git settings
|
|
484
|
-
remote: null,
|
|
485
|
-
autoPull: null,
|
|
486
|
-
pollInterval: null,
|
|
487
|
-
// UI settings
|
|
488
|
-
sound: null,
|
|
489
|
-
visibleBranches: null,
|
|
490
|
-
// Actions
|
|
491
|
-
init: false,
|
|
492
|
-
};
|
|
493
|
-
|
|
494
|
-
for (let i = 0; i < args.length; i++) {
|
|
495
|
-
// Server settings
|
|
496
|
-
if (args[i] === '--mode' || args[i] === '-m') {
|
|
497
|
-
const mode = args[i + 1];
|
|
498
|
-
if (['static', 'command', 'none'].includes(mode)) {
|
|
499
|
-
result.mode = mode;
|
|
500
|
-
}
|
|
501
|
-
i++;
|
|
502
|
-
} else if (args[i] === '--port' || args[i] === '-p') {
|
|
503
|
-
const portValue = parseInt(args[i + 1], 10);
|
|
504
|
-
if (!isNaN(portValue) && portValue > 0 && portValue < 65536) {
|
|
505
|
-
result.port = portValue;
|
|
506
|
-
}
|
|
507
|
-
i++;
|
|
508
|
-
} else if (args[i] === '--no-server' || args[i] === '-n') {
|
|
509
|
-
result.noServer = true;
|
|
510
|
-
} else if (args[i] === '--static-dir') {
|
|
511
|
-
result.staticDir = args[i + 1];
|
|
512
|
-
i++;
|
|
513
|
-
} else if (args[i] === '--command' || args[i] === '-c') {
|
|
514
|
-
result.command = args[i + 1];
|
|
515
|
-
i++;
|
|
516
|
-
} else if (args[i] === '--restart-on-switch') {
|
|
517
|
-
result.restartOnSwitch = true;
|
|
518
|
-
} else if (args[i] === '--no-restart-on-switch') {
|
|
519
|
-
result.restartOnSwitch = false;
|
|
520
|
-
}
|
|
521
|
-
// Git settings
|
|
522
|
-
else if (args[i] === '--remote' || args[i] === '-r') {
|
|
523
|
-
result.remote = args[i + 1];
|
|
524
|
-
i++;
|
|
525
|
-
} else if (args[i] === '--auto-pull') {
|
|
526
|
-
result.autoPull = true;
|
|
527
|
-
} else if (args[i] === '--no-auto-pull') {
|
|
528
|
-
result.autoPull = false;
|
|
529
|
-
} else if (args[i] === '--poll-interval') {
|
|
530
|
-
const interval = parseInt(args[i + 1], 10);
|
|
531
|
-
if (!isNaN(interval) && interval > 0) {
|
|
532
|
-
result.pollInterval = interval;
|
|
533
|
-
}
|
|
534
|
-
i++;
|
|
535
|
-
}
|
|
536
|
-
// UI settings
|
|
537
|
-
else if (args[i] === '--sound') {
|
|
538
|
-
result.sound = true;
|
|
539
|
-
} else if (args[i] === '--no-sound') {
|
|
540
|
-
result.sound = false;
|
|
541
|
-
} else if (args[i] === '--visible-branches') {
|
|
542
|
-
const count = parseInt(args[i + 1], 10);
|
|
543
|
-
if (!isNaN(count) && count > 0) {
|
|
544
|
-
result.visibleBranches = count;
|
|
545
|
-
}
|
|
546
|
-
i++;
|
|
547
|
-
}
|
|
548
|
-
// Actions and info
|
|
549
|
-
else if (args[i] === '--init') {
|
|
550
|
-
result.init = true;
|
|
551
|
-
} else if (args[i] === '--version' || args[i] === '-v') {
|
|
552
|
-
console.log(`git-watchtower v${PACKAGE_VERSION}`);
|
|
553
|
-
process.exit(0);
|
|
554
|
-
} else if (args[i] === '--help' || args[i] === '-h') {
|
|
555
|
-
console.log(`
|
|
556
|
-
Git Watchtower v${PACKAGE_VERSION} - Branch Monitor & Dev Server
|
|
557
|
-
|
|
558
|
-
Usage:
|
|
559
|
-
git-watchtower [options]
|
|
560
|
-
|
|
561
|
-
Server Options:
|
|
562
|
-
-m, --mode <mode> Server mode: static, command, or none
|
|
563
|
-
-p, --port <port> Server port (default: 3000)
|
|
564
|
-
-n, --no-server Shorthand for --mode none
|
|
565
|
-
--static-dir <dir> Directory for static file serving (default: public)
|
|
566
|
-
-c, --command <cmd> Command to run in command mode (e.g., "npm run dev")
|
|
567
|
-
--restart-on-switch Restart server on branch switch (default)
|
|
568
|
-
--no-restart-on-switch Don't restart server on branch switch
|
|
569
|
-
|
|
570
|
-
Git Options:
|
|
571
|
-
-r, --remote <name> Git remote name (default: origin)
|
|
572
|
-
--auto-pull Auto-pull on branch switch (default)
|
|
573
|
-
--no-auto-pull Don't auto-pull on branch switch
|
|
574
|
-
--poll-interval <ms> Git polling interval in ms (default: 5000)
|
|
575
|
-
|
|
576
|
-
UI Options:
|
|
577
|
-
--sound Enable sound notifications (default)
|
|
578
|
-
--no-sound Disable sound notifications
|
|
579
|
-
--visible-branches <n> Number of branches to display (default: 7)
|
|
580
|
-
|
|
581
|
-
General:
|
|
582
|
-
--init Run the configuration wizard
|
|
583
|
-
-v, --version Show version number
|
|
584
|
-
-h, --help Show this help message
|
|
585
|
-
|
|
586
|
-
Server Modes:
|
|
587
|
-
static Serve static files with live reload (default)
|
|
588
|
-
command Run your own dev server (Next.js, Vite, Nuxt, etc.)
|
|
589
|
-
none Branch monitoring only
|
|
590
|
-
|
|
591
|
-
Configuration:
|
|
592
|
-
On first run, Git Watchtower will prompt you to configure settings.
|
|
593
|
-
Settings are saved to .watchtowerrc.json in your project directory.
|
|
594
|
-
CLI options override config file settings for the current session.
|
|
595
|
-
|
|
596
|
-
Examples:
|
|
597
|
-
git-watchtower # Start with config or defaults
|
|
598
|
-
git-watchtower --init # Re-run configuration wizard
|
|
599
|
-
git-watchtower --no-server # Branch monitoring only
|
|
600
|
-
git-watchtower -p 8080 # Override port
|
|
601
|
-
git-watchtower -m command -c "npm run dev" # Use custom dev server
|
|
602
|
-
git-watchtower --no-sound --poll-interval 10000
|
|
603
|
-
`);
|
|
604
|
-
process.exit(0);
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
return result;
|
|
608
|
-
}
|
|
349
|
+
// mergeCliArgs imported from src/cli/args.js as mergeCliArgs
|
|
609
350
|
|
|
610
|
-
|
|
351
|
+
// CLI argument parsing delegated to src/cli/args.js
|
|
352
|
+
const cliArgs = parseCliArgs(process.argv.slice(2), {
|
|
353
|
+
onVersion: (v) => { console.log(`git-watchtower v${v}`); process.exit(0); },
|
|
354
|
+
onHelp: (v) => { console.log(getHelpText(v)); process.exit(0); },
|
|
355
|
+
});
|
|
611
356
|
|
|
612
357
|
// Configuration - these will be set after config is loaded
|
|
613
358
|
let SERVER_MODE = 'static'; // 'static' | 'command' | 'none'
|
|
@@ -622,19 +367,8 @@ let AUTO_PULL = true;
|
|
|
622
367
|
const MAX_LOG_ENTRIES = 10;
|
|
623
368
|
const MAX_SERVER_LOG_LINES = 500;
|
|
624
369
|
|
|
625
|
-
// Dynamic settings
|
|
626
|
-
let visibleBranchCount = 7;
|
|
627
|
-
let soundEnabled = true;
|
|
628
|
-
let casinoModeEnabled = false;
|
|
629
|
-
|
|
630
370
|
// Server process management (for command mode)
|
|
631
371
|
let serverProcess = null;
|
|
632
|
-
let serverLogBuffer = []; // In-memory log buffer
|
|
633
|
-
let serverRunning = false;
|
|
634
|
-
let serverCrashed = false;
|
|
635
|
-
let logViewMode = false; // Viewing logs modal
|
|
636
|
-
let logViewTab = 'server'; // 'activity' or 'server'
|
|
637
|
-
let logScrollOffset = 0; // Scroll position in log view
|
|
638
372
|
|
|
639
373
|
function applyConfig(config) {
|
|
640
374
|
// Server settings
|
|
@@ -650,49 +384,221 @@ function applyConfig(config) {
|
|
|
650
384
|
AUTO_PULL = config.autoPull !== false;
|
|
651
385
|
GIT_POLL_INTERVAL = config.gitPollInterval || parseInt(process.env.GIT_POLL_INTERVAL, 10) || 5000;
|
|
652
386
|
|
|
653
|
-
// UI settings
|
|
654
|
-
|
|
655
|
-
|
|
387
|
+
// UI settings via store
|
|
388
|
+
const casinoEnabled = config.casinoMode === true;
|
|
389
|
+
store.setState({
|
|
390
|
+
visibleBranchCount: config.visibleBranches || 7,
|
|
391
|
+
soundEnabled: config.soundEnabled !== false,
|
|
392
|
+
casinoModeEnabled: casinoEnabled,
|
|
393
|
+
serverMode: SERVER_MODE,
|
|
394
|
+
noServer: NO_SERVER,
|
|
395
|
+
port: PORT,
|
|
396
|
+
maxLogEntries: MAX_LOG_ENTRIES,
|
|
397
|
+
projectName: path.basename(PROJECT_ROOT),
|
|
398
|
+
adaptivePollInterval: GIT_POLL_INTERVAL,
|
|
399
|
+
});
|
|
656
400
|
|
|
657
401
|
// Casino mode
|
|
658
|
-
|
|
659
|
-
if (casinoModeEnabled) {
|
|
402
|
+
if (casinoEnabled) {
|
|
660
403
|
casino.enable();
|
|
661
404
|
}
|
|
662
405
|
}
|
|
663
406
|
|
|
664
407
|
// Server log management
|
|
665
408
|
function addServerLog(line, isError = false) {
|
|
666
|
-
const
|
|
667
|
-
serverLogBuffer.
|
|
668
|
-
|
|
669
|
-
serverLogBuffer.shift();
|
|
670
|
-
}
|
|
409
|
+
const entry = { timestamp: new Date().toLocaleTimeString(), line, isError };
|
|
410
|
+
const serverLogBuffer = [...store.get('serverLogBuffer'), entry].slice(-MAX_SERVER_LOG_LINES);
|
|
411
|
+
store.setState({ serverLogBuffer });
|
|
671
412
|
}
|
|
672
413
|
|
|
673
414
|
function clearServerLog() {
|
|
674
|
-
serverLogBuffer
|
|
415
|
+
store.setState({ serverLogBuffer: [] });
|
|
675
416
|
}
|
|
676
417
|
|
|
677
|
-
//
|
|
418
|
+
// openInBrowser imported from src/utils/browser.js
|
|
678
419
|
function openInBrowser(url) {
|
|
679
|
-
|
|
680
|
-
|
|
420
|
+
openUrl(url, (error) => {
|
|
421
|
+
addLog(`Failed to open browser: ${error.message}`, 'error');
|
|
422
|
+
});
|
|
423
|
+
}
|
|
681
424
|
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
425
|
+
// parseRemoteUrl, buildBranchUrl, detectPlatform, buildWebUrl, extractSessionUrl
|
|
426
|
+
// imported from src/git/remote.js
|
|
427
|
+
|
|
428
|
+
async function getRemoteWebUrl(branchName) {
|
|
429
|
+
try {
|
|
430
|
+
const { stdout } = await execAsync(`git remote get-url "${REMOTE_NAME}"`);
|
|
431
|
+
const parsed = parseRemoteUrl(stdout);
|
|
432
|
+
return buildWebUrl(parsed, branchName);
|
|
433
|
+
} catch (e) {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Extract Claude Code session URL from the most recent commit on a branch
|
|
439
|
+
async function getSessionUrl(branchName) {
|
|
440
|
+
try {
|
|
441
|
+
const { stdout } = await execAsync(
|
|
442
|
+
`git log "${REMOTE_NAME}/${branchName}" -1 --format=%B 2>/dev/null || git log "${branchName}" -1 --format=%B 2>/dev/null`
|
|
443
|
+
);
|
|
444
|
+
return extractSessionUrl(stdout);
|
|
445
|
+
} catch (e) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// Check if a CLI tool is available
|
|
451
|
+
async function hasCommand(cmd) {
|
|
452
|
+
try {
|
|
453
|
+
await execAsync(`which ${cmd} 2>/dev/null`);
|
|
454
|
+
return true;
|
|
455
|
+
} catch (e) {
|
|
456
|
+
return false;
|
|
689
457
|
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// detectPlatform imported from src/git/remote.js
|
|
461
|
+
|
|
462
|
+
// Get PR info for a branch using gh or glab CLI (parsing delegated to src/git/pr.js)
|
|
463
|
+
async function getPrInfo(branchName, platform, hasGh, hasGlab) {
|
|
464
|
+
if (platform === 'github' && hasGh) {
|
|
465
|
+
try {
|
|
466
|
+
const { stdout } = await execAsync(
|
|
467
|
+
`gh pr list --head "${branchName}" --state all --json number,title,state,reviewDecision,statusCheckRollup --limit 1`
|
|
468
|
+
);
|
|
469
|
+
return parseGitHubPr(JSON.parse(stdout));
|
|
470
|
+
} catch (e) { /* gh not authed or other error */ }
|
|
471
|
+
}
|
|
472
|
+
if (platform === 'gitlab' && hasGlab) {
|
|
473
|
+
try {
|
|
474
|
+
const { stdout } = await execAsync(
|
|
475
|
+
`glab mr list --source-branch="${branchName}" --state all --output json 2>/dev/null`
|
|
476
|
+
);
|
|
477
|
+
return parseGitLabMr(JSON.parse(stdout));
|
|
478
|
+
} catch (e) { /* glab not authed or other error */ }
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
690
482
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
483
|
+
// Check if gh/glab CLI is authenticated
|
|
484
|
+
async function checkCliAuth(cmd) {
|
|
485
|
+
try {
|
|
486
|
+
if (cmd === 'gh') {
|
|
487
|
+
await execAsync('gh auth status 2>&1');
|
|
488
|
+
} else if (cmd === 'glab') {
|
|
489
|
+
await execAsync('glab auth status 2>&1');
|
|
694
490
|
}
|
|
695
|
-
|
|
491
|
+
return true;
|
|
492
|
+
} catch (e) {
|
|
493
|
+
return false;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Bulk-fetch PR statuses for all branches (parsing delegated to src/git/pr.js)
|
|
498
|
+
async function fetchAllPrStatuses() {
|
|
499
|
+
if (!cachedEnv) return null;
|
|
500
|
+
const { platform, hasGh, ghAuthed, hasGlab, glabAuthed } = cachedEnv;
|
|
501
|
+
|
|
502
|
+
if (platform === 'github' && hasGh && ghAuthed) {
|
|
503
|
+
try {
|
|
504
|
+
const { stdout } = await execAsync(
|
|
505
|
+
'gh pr list --state all --json headRefName,number,title,state --limit 200'
|
|
506
|
+
);
|
|
507
|
+
return parseGitHubPrList(JSON.parse(stdout));
|
|
508
|
+
} catch (e) { /* gh error */ }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (platform === 'gitlab' && hasGlab && glabAuthed) {
|
|
512
|
+
try {
|
|
513
|
+
const { stdout } = await execAsync(
|
|
514
|
+
'glab mr list --state all --output json 2>/dev/null'
|
|
515
|
+
);
|
|
516
|
+
return parseGitLabMrList(JSON.parse(stdout));
|
|
517
|
+
} catch (e) { /* glab error */ }
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// One-time environment detection (called at startup)
|
|
524
|
+
async function initActionCache() {
|
|
525
|
+
const [hasGh, hasGlab, webUrlBase] = await Promise.all([
|
|
526
|
+
hasCommand('gh'),
|
|
527
|
+
hasCommand('glab'),
|
|
528
|
+
getRemoteWebUrl(null), // base URL without branch
|
|
529
|
+
]);
|
|
530
|
+
|
|
531
|
+
let ghAuthed = false;
|
|
532
|
+
let glabAuthed = false;
|
|
533
|
+
if (hasGh) ghAuthed = await checkCliAuth('gh');
|
|
534
|
+
if (hasGlab) glabAuthed = await checkCliAuth('glab');
|
|
535
|
+
|
|
536
|
+
const platform = detectPlatform(webUrlBase);
|
|
537
|
+
|
|
538
|
+
cachedEnv = { hasGh, hasGlab, ghAuthed, glabAuthed, webUrlBase, platform };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Phase 1: Instant local data for the modal (no network calls)
|
|
542
|
+
function gatherLocalActionData(branch) {
|
|
543
|
+
const isClaudeBranch = /^claude\//.test(branch.name);
|
|
544
|
+
const env = cachedEnv || { hasGh: false, hasGlab: false, ghAuthed: false, glabAuthed: false, webUrlBase: null, platform: 'github' };
|
|
545
|
+
|
|
546
|
+
// Build branch-specific web URL from cached base
|
|
547
|
+
let webUrl = null;
|
|
548
|
+
if (env.webUrlBase) {
|
|
549
|
+
try {
|
|
550
|
+
const host = new URL(env.webUrlBase).hostname;
|
|
551
|
+
webUrl = buildBranchUrl(env.webUrlBase, host, branch.name);
|
|
552
|
+
} catch (e) { /* invalid URL, will be resolved in async phase */ }
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Check PR cache (instant if we've seen this branch+commit before)
|
|
556
|
+
const cached = prInfoCache.get(branch.name);
|
|
557
|
+
const prInfo = (cached && cached.commit === branch.commit) ? cached.prInfo : null;
|
|
558
|
+
const prLoaded = !!(cached && cached.commit === branch.commit);
|
|
559
|
+
|
|
560
|
+
return {
|
|
561
|
+
branch, sessionUrl: null, prInfo, webUrl, isClaudeBranch,
|
|
562
|
+
...env,
|
|
563
|
+
prLoaded, // false means PR info still needs to be fetched
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// Phase 2: Async data that requires network/git calls
|
|
568
|
+
async function loadAsyncActionData(branch, currentData) {
|
|
569
|
+
const isClaudeBranch = currentData.isClaudeBranch;
|
|
570
|
+
|
|
571
|
+
// Ensure env cache is populated (might have been in flight during Phase 1)
|
|
572
|
+
if (!cachedEnv) {
|
|
573
|
+
await initActionCache();
|
|
574
|
+
}
|
|
575
|
+
const env = cachedEnv || {};
|
|
576
|
+
|
|
577
|
+
// Resolve webUrl if it wasn't available synchronously
|
|
578
|
+
let webUrl = currentData.webUrl;
|
|
579
|
+
if (!webUrl && env.webUrlBase) {
|
|
580
|
+
try {
|
|
581
|
+
const host = new URL(env.webUrlBase).hostname;
|
|
582
|
+
webUrl = buildBranchUrl(env.webUrlBase, host, branch.name);
|
|
583
|
+
} catch (e) { /* ignore */ }
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// Fetch session URL (local git, fast but async)
|
|
587
|
+
const sessionUrl = isClaudeBranch ? await getSessionUrl(branch.name) : null;
|
|
588
|
+
|
|
589
|
+
// Fetch PR info if not cached
|
|
590
|
+
let prInfo = currentData.prInfo;
|
|
591
|
+
let prLoaded = currentData.prLoaded;
|
|
592
|
+
if (!prLoaded) {
|
|
593
|
+
const canQueryPr = (env.platform === 'github' && env.hasGh && env.ghAuthed) ||
|
|
594
|
+
(env.platform === 'gitlab' && env.hasGlab && env.glabAuthed);
|
|
595
|
+
prInfo = canQueryPr ? await getPrInfo(branch.name, env.platform, env.hasGh && env.ghAuthed, env.hasGlab && env.glabAuthed) : null;
|
|
596
|
+
// Cache the result, keyed by branch commit
|
|
597
|
+
prInfoCache.set(branch.name, { commit: branch.commit, prInfo });
|
|
598
|
+
prLoaded = true;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
return { ...currentData, ...env, webUrl, sessionUrl, prInfo, prLoaded };
|
|
696
602
|
}
|
|
697
603
|
|
|
698
604
|
// Command mode server management
|
|
@@ -703,8 +609,7 @@ function startServerProcess() {
|
|
|
703
609
|
}
|
|
704
610
|
|
|
705
611
|
clearServerLog();
|
|
706
|
-
serverCrashed
|
|
707
|
-
serverRunning = false;
|
|
612
|
+
store.setState({ serverCrashed: false, serverRunning: false });
|
|
708
613
|
|
|
709
614
|
addLog(`Starting: ${SERVER_COMMAND}`, 'update');
|
|
710
615
|
addServerLog(`$ ${SERVER_COMMAND}`);
|
|
@@ -725,7 +630,7 @@ function startServerProcess() {
|
|
|
725
630
|
|
|
726
631
|
try {
|
|
727
632
|
serverProcess = spawn(cmd, args, spawnOptions);
|
|
728
|
-
serverRunning
|
|
633
|
+
store.setState({ serverRunning: true });
|
|
729
634
|
|
|
730
635
|
serverProcess.stdout.on('data', (data) => {
|
|
731
636
|
const lines = data.toString().split('\n').filter(Boolean);
|
|
@@ -738,17 +643,16 @@ function startServerProcess() {
|
|
|
738
643
|
});
|
|
739
644
|
|
|
740
645
|
serverProcess.on('error', (err) => {
|
|
741
|
-
serverRunning
|
|
742
|
-
serverCrashed = true;
|
|
646
|
+
store.setState({ serverRunning: false, serverCrashed: true });
|
|
743
647
|
addServerLog(`Error: ${err.message}`, true);
|
|
744
648
|
addLog(`Server error: ${err.message}`, 'error');
|
|
745
649
|
render();
|
|
746
650
|
});
|
|
747
651
|
|
|
748
652
|
serverProcess.on('close', (code) => {
|
|
749
|
-
serverRunning
|
|
653
|
+
store.setState({ serverRunning: false });
|
|
750
654
|
if (code !== 0 && code !== null) {
|
|
751
|
-
serverCrashed
|
|
655
|
+
store.setState({ serverCrashed: true });
|
|
752
656
|
addServerLog(`Process exited with code ${code}`, true);
|
|
753
657
|
addLog(`Server exited with code ${code}`, 'error');
|
|
754
658
|
} else {
|
|
@@ -761,7 +665,7 @@ function startServerProcess() {
|
|
|
761
665
|
|
|
762
666
|
addLog(`Server started (pid: ${serverProcess.pid})`, 'success');
|
|
763
667
|
} catch (err) {
|
|
764
|
-
serverCrashed
|
|
668
|
+
store.setState({ serverCrashed: true });
|
|
765
669
|
addServerLog(`Failed to start: ${err.message}`, true);
|
|
766
670
|
addLog(`Failed to start server: ${err.message}`, 'error');
|
|
767
671
|
}
|
|
@@ -786,7 +690,7 @@ function stopServerProcess() {
|
|
|
786
690
|
}
|
|
787
691
|
|
|
788
692
|
serverProcess = null;
|
|
789
|
-
serverRunning
|
|
693
|
+
store.setState({ serverRunning: false });
|
|
790
694
|
}
|
|
791
695
|
|
|
792
696
|
function restartServerProcess() {
|
|
@@ -799,148 +703,58 @@ function restartServerProcess() {
|
|
|
799
703
|
}
|
|
800
704
|
|
|
801
705
|
// Network and polling state
|
|
802
|
-
let consecutiveNetworkFailures = 0;
|
|
803
|
-
let isOffline = false;
|
|
804
|
-
let lastFetchDuration = 0;
|
|
805
706
|
let slowFetchWarningShown = false;
|
|
806
707
|
let verySlowFetchWarningShown = false;
|
|
807
|
-
let adaptivePollInterval = GIT_POLL_INTERVAL;
|
|
808
708
|
let pollIntervalId = null;
|
|
809
709
|
|
|
810
|
-
//
|
|
811
|
-
|
|
812
|
-
let hasMergeConflict = false;
|
|
813
|
-
|
|
814
|
-
// ANSI escape codes
|
|
815
|
-
const ESC = '\x1b';
|
|
816
|
-
const CSI = `${ESC}[`;
|
|
817
|
-
|
|
818
|
-
const ansi = {
|
|
819
|
-
// Screen
|
|
820
|
-
clearScreen: `${CSI}2J`,
|
|
821
|
-
clearLine: `${CSI}2K`,
|
|
822
|
-
moveTo: (row, col) => `${CSI}${row};${col}H`,
|
|
823
|
-
moveToTop: `${CSI}H`,
|
|
824
|
-
hideCursor: `${CSI}?25l`,
|
|
825
|
-
showCursor: `${CSI}?25h`,
|
|
826
|
-
saveScreen: `${CSI}?1049h`,
|
|
827
|
-
restoreScreen: `${CSI}?1049l`,
|
|
828
|
-
|
|
829
|
-
// Colors
|
|
830
|
-
reset: `${CSI}0m`,
|
|
831
|
-
bold: `${CSI}1m`,
|
|
832
|
-
dim: `${CSI}2m`,
|
|
833
|
-
italic: `${CSI}3m`,
|
|
834
|
-
underline: `${CSI}4m`,
|
|
835
|
-
inverse: `${CSI}7m`,
|
|
836
|
-
blink: `${CSI}5m`,
|
|
837
|
-
|
|
838
|
-
// Foreground colors
|
|
839
|
-
black: `${CSI}30m`,
|
|
840
|
-
red: `${CSI}31m`,
|
|
841
|
-
green: `${CSI}32m`,
|
|
842
|
-
yellow: `${CSI}33m`,
|
|
843
|
-
blue: `${CSI}34m`,
|
|
844
|
-
magenta: `${CSI}35m`,
|
|
845
|
-
cyan: `${CSI}36m`,
|
|
846
|
-
white: `${CSI}37m`,
|
|
847
|
-
gray: `${CSI}90m`,
|
|
848
|
-
|
|
849
|
-
// Bright foreground colors
|
|
850
|
-
brightRed: `${CSI}91m`,
|
|
851
|
-
brightGreen: `${CSI}92m`,
|
|
852
|
-
brightYellow: `${CSI}93m`,
|
|
853
|
-
brightBlue: `${CSI}94m`,
|
|
854
|
-
brightMagenta: `${CSI}95m`,
|
|
855
|
-
brightCyan: `${CSI}96m`,
|
|
856
|
-
brightWhite: `${CSI}97m`,
|
|
857
|
-
|
|
858
|
-
// Background colors
|
|
859
|
-
bgBlack: `${CSI}40m`,
|
|
860
|
-
bgRed: `${CSI}41m`,
|
|
861
|
-
bgGreen: `${CSI}42m`,
|
|
862
|
-
bgYellow: `${CSI}43m`,
|
|
863
|
-
bgBlue: `${CSI}44m`,
|
|
864
|
-
bgMagenta: `${CSI}45m`,
|
|
865
|
-
bgCyan: `${CSI}46m`,
|
|
866
|
-
bgWhite: `${CSI}47m`,
|
|
867
|
-
|
|
868
|
-
// Bright background colors
|
|
869
|
-
bgBrightRed: `${CSI}101m`,
|
|
870
|
-
bgBrightGreen: `${CSI}102m`,
|
|
871
|
-
bgBrightYellow: `${CSI}103m`,
|
|
872
|
-
bgBrightBlue: `${CSI}104m`,
|
|
873
|
-
bgBrightMagenta: `${CSI}105m`,
|
|
874
|
-
bgBrightCyan: `${CSI}106m`,
|
|
875
|
-
bgBrightWhite: `${CSI}107m`,
|
|
876
|
-
|
|
877
|
-
// 256 colors
|
|
878
|
-
fg256: (n) => `${CSI}38;5;${n}m`,
|
|
879
|
-
bg256: (n) => `${CSI}48;5;${n}m`,
|
|
880
|
-
};
|
|
710
|
+
// ANSI escape codes and box drawing imported from src/ui/ansi.js
|
|
711
|
+
const { ansi, box, truncate, sparkline: uiSparkline, visibleLength, stripAnsi, padRight, padLeft, getMaxBranchesForScreen: calcMaxBranches, drawBox: renderBox, clearArea: renderClearArea } = require('../src/ui/ansi');
|
|
881
712
|
|
|
882
|
-
//
|
|
883
|
-
const
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
// Double line for flash
|
|
895
|
-
dTopLeft: '╔',
|
|
896
|
-
dTopRight: '╗',
|
|
897
|
-
dBottomLeft: '╚',
|
|
898
|
-
dBottomRight: '╝',
|
|
899
|
-
dHorizontal: '═',
|
|
900
|
-
dVertical: '║',
|
|
901
|
-
};
|
|
713
|
+
// Error detection utilities imported from src/utils/errors.js
|
|
714
|
+
const { ErrorHandler, isAuthError, isMergeConflict, isNetworkError } = require('../src/utils/errors');
|
|
715
|
+
|
|
716
|
+
// Keyboard handling utilities imported from src/ui/keybindings.js
|
|
717
|
+
const { filterBranches } = require('../src/ui/keybindings');
|
|
718
|
+
|
|
719
|
+
// Extracted renderer and action handlers
|
|
720
|
+
const renderer = require('../src/ui/renderer');
|
|
721
|
+
const actions = require('../src/ui/actions');
|
|
722
|
+
|
|
723
|
+
// Diff stats parsing and stash imported from src/git/commands.js
|
|
724
|
+
const { parseDiffStats, stash: gitStash, stashPop: gitStashPop } = require('../src/git/commands');
|
|
902
725
|
|
|
903
|
-
// State
|
|
904
|
-
let branches = [];
|
|
905
|
-
let selectedIndex = 0;
|
|
906
|
-
let selectedBranchName = null; // Track selection by name, not just index
|
|
907
|
-
let currentBranch = null;
|
|
726
|
+
// State (non-store globals)
|
|
908
727
|
let previousBranchStates = new Map(); // branch name -> commit hash
|
|
909
728
|
let knownBranchNames = new Set(); // Track known branches to detect NEW ones
|
|
910
|
-
let isPolling = false;
|
|
911
|
-
let pollingStatus = 'idle';
|
|
912
|
-
let terminalWidth = process.stdout.columns || 80;
|
|
913
|
-
let terminalHeight = process.stdout.rows || 24;
|
|
914
729
|
|
|
915
730
|
// SSE clients for live reload
|
|
916
731
|
const clients = new Set();
|
|
917
732
|
|
|
918
|
-
//
|
|
919
|
-
const activityLog = [];
|
|
920
|
-
|
|
921
|
-
// Flash state
|
|
922
|
-
let flashMessage = null;
|
|
733
|
+
// Flash/error toast timers
|
|
923
734
|
let flashTimeout = null;
|
|
924
|
-
|
|
925
|
-
// Error toast state (more prominent than activity log)
|
|
926
|
-
let errorToast = null;
|
|
927
735
|
let errorToastTimeout = null;
|
|
928
736
|
|
|
929
|
-
//
|
|
930
|
-
|
|
931
|
-
|
|
737
|
+
// Tracks the operation that failed due to dirty working directory.
|
|
738
|
+
// When set, pressing 'S' will stash changes and retry the operation.
|
|
739
|
+
// Shape: { type: 'switch', branch: string } | { type: 'pull' } | null
|
|
740
|
+
let pendingDirtyOperation = null;
|
|
741
|
+
|
|
742
|
+
// Cached environment info (populated once at startup, doesn't change during session)
|
|
743
|
+
let cachedEnv = null; // { hasGh, hasGlab, ghAuthed, glabAuthed, webUrlBase, platform }
|
|
744
|
+
|
|
745
|
+
// Per-branch PR info cache: Map<branchName, { commit, prInfo }>
|
|
746
|
+
// Invalidated when the branch's commit hash changes
|
|
747
|
+
const prInfoCache = new Map();
|
|
932
748
|
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
let
|
|
936
|
-
|
|
749
|
+
let lastPrStatusFetch = 0;
|
|
750
|
+
const PR_STATUS_POLL_INTERVAL = 60 * 1000; // 60 seconds
|
|
751
|
+
let prStatusFetchInFlight = false;
|
|
752
|
+
|
|
753
|
+
// BASE_BRANCH_RE and isBaseBranch imported from src/git/pr.js
|
|
937
754
|
|
|
938
|
-
// Session history for undo
|
|
939
|
-
const switchHistory = [];
|
|
940
755
|
const MAX_HISTORY = 20;
|
|
941
756
|
|
|
942
|
-
// Sparkline
|
|
943
|
-
const sparklineCache = new Map(); // branch name -> sparkline string
|
|
757
|
+
// Sparkline timing
|
|
944
758
|
let lastSparklineUpdate = 0;
|
|
945
759
|
const SPARKLINE_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
946
760
|
|
|
@@ -1003,60 +817,20 @@ function execAsync(command, options = {}) {
|
|
|
1003
817
|
async function getDiffStats(fromCommit, toCommit = 'HEAD') {
|
|
1004
818
|
try {
|
|
1005
819
|
const { stdout } = await execAsync(`git diff --stat ${fromCommit}..${toCommit}`);
|
|
1006
|
-
|
|
1007
|
-
const match = stdout.match(/(\d+) insertions?\(\+\).*?(\d+) deletions?\(-\)/);
|
|
1008
|
-
if (match) {
|
|
1009
|
-
return { added: parseInt(match[1], 10), deleted: parseInt(match[2], 10) };
|
|
1010
|
-
}
|
|
1011
|
-
// Try to match just insertions or just deletions
|
|
1012
|
-
const insertMatch = stdout.match(/(\d+) insertions?\(\+\)/);
|
|
1013
|
-
const deleteMatch = stdout.match(/(\d+) deletions?\(-\)/);
|
|
1014
|
-
return {
|
|
1015
|
-
added: insertMatch ? parseInt(insertMatch[1], 10) : 0,
|
|
1016
|
-
deleted: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
|
|
1017
|
-
};
|
|
820
|
+
return parseDiffStats(stdout);
|
|
1018
821
|
} catch (e) {
|
|
1019
822
|
return { added: 0, deleted: 0 };
|
|
1020
823
|
}
|
|
1021
824
|
}
|
|
1022
825
|
|
|
1023
|
-
|
|
1024
|
-
const now = new Date();
|
|
1025
|
-
const diffMs = now - date;
|
|
1026
|
-
const diffSec = Math.floor(diffMs / 1000);
|
|
1027
|
-
const diffMin = Math.floor(diffSec / 60);
|
|
1028
|
-
const diffHr = Math.floor(diffMin / 60);
|
|
1029
|
-
const diffDay = Math.floor(diffHr / 24);
|
|
826
|
+
// formatTimeAgo imported from src/utils/time.js
|
|
1030
827
|
|
|
1031
|
-
|
|
1032
|
-
if (diffSec < 60) return `${diffSec}s ago`;
|
|
1033
|
-
if (diffMin < 60) return `${diffMin}m ago`;
|
|
1034
|
-
if (diffHr < 24) return `${diffHr}h ago`;
|
|
1035
|
-
if (diffDay === 1) return '1 day ago';
|
|
1036
|
-
return `${diffDay} days ago`;
|
|
1037
|
-
}
|
|
828
|
+
// truncate imported from src/ui/ansi.js
|
|
1038
829
|
|
|
1039
|
-
|
|
1040
|
-
if (!str) return '';
|
|
1041
|
-
if (str.length <= maxLen) return str;
|
|
1042
|
-
return str.substring(0, maxLen - 3) + '...';
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
function padRight(str, len) {
|
|
1046
|
-
if (str.length >= len) return str.substring(0, len);
|
|
1047
|
-
return str + ' '.repeat(len - str.length);
|
|
1048
|
-
}
|
|
830
|
+
// padRight, padLeft imported from src/ui/ansi.js
|
|
1049
831
|
|
|
1050
832
|
function getMaxBranchesForScreen() {
|
|
1051
|
-
|
|
1052
|
-
// Each branch takes 2 rows, plus 4 for box borders
|
|
1053
|
-
const availableHeight = terminalHeight - 2 - MAX_LOG_ENTRIES - 5 - 2;
|
|
1054
|
-
return Math.max(1, Math.floor(availableHeight / 2));
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
|
-
function padLeft(str, len) {
|
|
1058
|
-
if (str.length >= len) return str.substring(0, len);
|
|
1059
|
-
return ' '.repeat(len - str.length) + str;
|
|
833
|
+
return calcMaxBranches(store.get('terminalHeight'), MAX_LOG_ENTRIES);
|
|
1060
834
|
}
|
|
1061
835
|
|
|
1062
836
|
// Casino mode funny messages
|
|
@@ -1101,29 +875,27 @@ function getCasinoMessage(type) {
|
|
|
1101
875
|
}
|
|
1102
876
|
|
|
1103
877
|
function addLog(message, type = 'info') {
|
|
1104
|
-
const timestamp = new Date().toLocaleTimeString();
|
|
1105
878
|
const icons = { info: '○', success: '✓', warning: '●', error: '✗', update: '⟳' };
|
|
1106
879
|
const colors = { info: 'white', success: 'green', warning: 'yellow', error: 'red', update: 'cyan' };
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
880
|
+
const entry = {
|
|
881
|
+
message, type,
|
|
882
|
+
timestamp: new Date().toLocaleTimeString(),
|
|
883
|
+
icon: icons[type] || '○',
|
|
884
|
+
color: colors[type] || 'white',
|
|
885
|
+
};
|
|
886
|
+
const activityLog = [entry, ...store.get('activityLog')].slice(0, MAX_LOG_ENTRIES);
|
|
887
|
+
store.setState({ activityLog });
|
|
1110
888
|
}
|
|
1111
889
|
|
|
1112
|
-
//
|
|
1113
|
-
const SPARKLINE_CHARS = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
|
|
1114
|
-
|
|
890
|
+
// generateSparkline uses uiSparkline from src/ui/ansi.js
|
|
1115
891
|
function generateSparkline(commitCounts) {
|
|
1116
892
|
if (!commitCounts || commitCounts.length === 0) return ' ';
|
|
1117
|
-
|
|
1118
|
-
return commitCounts.map(count => {
|
|
1119
|
-
const level = Math.floor((count / max) * 7);
|
|
1120
|
-
return SPARKLINE_CHARS[level];
|
|
1121
|
-
}).join('');
|
|
893
|
+
return uiSparkline(commitCounts);
|
|
1122
894
|
}
|
|
1123
895
|
|
|
1124
896
|
async function getBranchSparkline(branchName) {
|
|
1125
897
|
// Check cache first
|
|
1126
|
-
const cached = sparklineCache.get(branchName);
|
|
898
|
+
const cached = store.get('sparklineCache').get(branchName);
|
|
1127
899
|
if (cached && (Date.now() - lastSparklineUpdate) < SPARKLINE_CACHE_TTL) {
|
|
1128
900
|
return cached;
|
|
1129
901
|
}
|
|
@@ -1137,7 +909,8 @@ async function refreshAllSparklines() {
|
|
|
1137
909
|
}
|
|
1138
910
|
|
|
1139
911
|
try {
|
|
1140
|
-
|
|
912
|
+
const currentBranches = store.get('branches');
|
|
913
|
+
for (const branch of currentBranches.slice(0, 20)) { // Limit to top 20
|
|
1141
914
|
if (branch.isDeleted) continue;
|
|
1142
915
|
|
|
1143
916
|
// Get commit counts for last 7 days
|
|
@@ -1163,7 +936,7 @@ async function refreshAllSparklines() {
|
|
|
1163
936
|
}
|
|
1164
937
|
|
|
1165
938
|
const counts = Array.from(dayCounts.values());
|
|
1166
|
-
sparklineCache.set(branch.name, generateSparkline(counts));
|
|
939
|
+
store.get('sparklineCache').set(branch.name, generateSparkline(counts));
|
|
1167
940
|
}
|
|
1168
941
|
lastSparklineUpdate = now;
|
|
1169
942
|
} catch (e) {
|
|
@@ -1200,30 +973,10 @@ async function getPreviewData(branchName) {
|
|
|
1200
973
|
}
|
|
1201
974
|
}
|
|
1202
975
|
|
|
976
|
+
// playSound delegates to extracted src/utils/sound.js
|
|
1203
977
|
function playSound() {
|
|
1204
|
-
if (!soundEnabled) return;
|
|
1205
|
-
|
|
1206
|
-
// Try to play a friendly system sound (non-blocking)
|
|
1207
|
-
const { platform } = process;
|
|
1208
|
-
|
|
1209
|
-
if (platform === 'darwin') {
|
|
1210
|
-
// macOS: Use afplay with a gentle system sound
|
|
1211
|
-
// Options: Glass, Pop, Ping, Purr, Submarine, Tink, Blow, Bottle, Frog, Funk, Hero, Morse, Sosumi
|
|
1212
|
-
exec('afplay /System/Library/Sounds/Pop.aiff 2>/dev/null', { cwd: PROJECT_ROOT });
|
|
1213
|
-
} else if (platform === 'linux') {
|
|
1214
|
-
// Linux: Try paplay (PulseAudio) or aplay (ALSA) with a system sound
|
|
1215
|
-
// First try freedesktop sound theme, then fall back to terminal bell
|
|
1216
|
-
exec(
|
|
1217
|
-
'paplay /usr/share/sounds/freedesktop/stereo/message-new-instant.oga 2>/dev/null || ' +
|
|
1218
|
-
'paplay /usr/share/sounds/freedesktop/stereo/complete.oga 2>/dev/null || ' +
|
|
1219
|
-
'aplay /usr/share/sounds/sound-icons/prompt.wav 2>/dev/null || ' +
|
|
1220
|
-
'printf "\\a"',
|
|
1221
|
-
{ cwd: PROJECT_ROOT }
|
|
1222
|
-
);
|
|
1223
|
-
} else {
|
|
1224
|
-
// Windows or other: Terminal bell
|
|
1225
|
-
process.stdout.write('\x07');
|
|
1226
|
-
}
|
|
978
|
+
if (!store.get('soundEnabled')) return;
|
|
979
|
+
playSoundEffect({ cwd: PROJECT_ROOT });
|
|
1227
980
|
}
|
|
1228
981
|
|
|
1229
982
|
// ============================================================================
|
|
@@ -1247,293 +1000,32 @@ function restoreTerminalTitle() {
|
|
|
1247
1000
|
}
|
|
1248
1001
|
|
|
1249
1002
|
function updateTerminalSize() {
|
|
1250
|
-
|
|
1251
|
-
|
|
1003
|
+
store.setState({
|
|
1004
|
+
terminalWidth: process.stdout.columns || 80,
|
|
1005
|
+
terminalHeight: process.stdout.rows || 24,
|
|
1006
|
+
});
|
|
1252
1007
|
}
|
|
1253
1008
|
|
|
1254
1009
|
function drawBox(row, col, width, height, title = '', titleColor = ansi.cyan) {
|
|
1255
|
-
|
|
1256
|
-
write(ansi.moveTo(row, col));
|
|
1257
|
-
write(ansi.gray + box.topLeft + box.horizontal.repeat(width - 2) + box.topRight + ansi.reset);
|
|
1258
|
-
|
|
1259
|
-
// Title
|
|
1260
|
-
if (title) {
|
|
1261
|
-
write(ansi.moveTo(row, col + 2));
|
|
1262
|
-
write(ansi.gray + ' ' + titleColor + title + ansi.gray + ' ' + ansi.reset);
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
// Sides
|
|
1266
|
-
for (let i = 1; i < height - 1; i++) {
|
|
1267
|
-
write(ansi.moveTo(row + i, col));
|
|
1268
|
-
write(ansi.gray + box.vertical + ansi.reset);
|
|
1269
|
-
write(ansi.moveTo(row + i, col + width - 1));
|
|
1270
|
-
write(ansi.gray + box.vertical + ansi.reset);
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
// Bottom border
|
|
1274
|
-
write(ansi.moveTo(row + height - 1, col));
|
|
1275
|
-
write(ansi.gray + box.bottomLeft + box.horizontal.repeat(width - 2) + box.bottomRight + ansi.reset);
|
|
1010
|
+
write(renderBox(row, col, width, height, title, titleColor));
|
|
1276
1011
|
}
|
|
1277
1012
|
|
|
1278
1013
|
function clearArea(row, col, width, height) {
|
|
1279
|
-
|
|
1280
|
-
write(ansi.moveTo(row + i, col));
|
|
1281
|
-
write(' '.repeat(width));
|
|
1282
|
-
}
|
|
1014
|
+
write(renderClearArea(row, col, width, height));
|
|
1283
1015
|
}
|
|
1284
1016
|
|
|
1285
|
-
|
|
1286
|
-
const width = terminalWidth;
|
|
1287
|
-
// Header row: 1 normally, 2 when casino mode (row 1 is marquee)
|
|
1288
|
-
const headerRow = casinoModeEnabled ? 2 : 1;
|
|
1289
|
-
|
|
1290
|
-
let statusIcon = { idle: ansi.green + '●', fetching: ansi.yellow + '⟳', error: ansi.red + '●' }[pollingStatus];
|
|
1291
|
-
|
|
1292
|
-
// Override status for special states
|
|
1293
|
-
if (isOffline) {
|
|
1294
|
-
statusIcon = ansi.red + '⊘';
|
|
1295
|
-
}
|
|
1296
|
-
|
|
1297
|
-
const soundIcon = soundEnabled ? ansi.green + '🔔' : ansi.gray + '🔕';
|
|
1298
|
-
const projectName = path.basename(PROJECT_ROOT);
|
|
1299
|
-
|
|
1300
|
-
write(ansi.moveTo(headerRow, 1));
|
|
1301
|
-
write(ansi.bgBlue + ansi.white + ansi.bold);
|
|
1302
|
-
|
|
1303
|
-
// Left side: Title + separator + project name
|
|
1304
|
-
const leftContent = ` 🏰 Git Watchtower ${ansi.dim}│${ansi.bold} ${projectName}`;
|
|
1305
|
-
const leftVisibleLen = 21 + projectName.length; // " 🏰 Git Watchtower │ " + projectName
|
|
1306
|
-
|
|
1307
|
-
write(leftContent);
|
|
1308
|
-
|
|
1309
|
-
// Warning badges (center area)
|
|
1310
|
-
let badges = '';
|
|
1311
|
-
let badgesVisibleLen = 0;
|
|
1312
|
-
|
|
1313
|
-
// Casino mode slot display moved to its own row below header (row 3)
|
|
1314
|
-
|
|
1315
|
-
if (SERVER_MODE === 'command' && serverCrashed) {
|
|
1316
|
-
const label = ' CRASHED ';
|
|
1317
|
-
badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
|
|
1318
|
-
badgesVisibleLen += 1 + label.length;
|
|
1319
|
-
}
|
|
1320
|
-
if (isOffline) {
|
|
1321
|
-
const label = ' OFFLINE ';
|
|
1322
|
-
badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
|
|
1323
|
-
badgesVisibleLen += 1 + label.length;
|
|
1324
|
-
}
|
|
1325
|
-
if (isDetachedHead) {
|
|
1326
|
-
const label = ' DETACHED HEAD ';
|
|
1327
|
-
badges += ' ' + ansi.bgYellow + ansi.black + label + ansi.bgBlue + ansi.white;
|
|
1328
|
-
badgesVisibleLen += 1 + label.length;
|
|
1329
|
-
}
|
|
1330
|
-
if (hasMergeConflict) {
|
|
1331
|
-
const label = ' MERGE CONFLICT ';
|
|
1332
|
-
badges += ' ' + ansi.bgRed + ansi.white + label + ansi.bgBlue + ansi.white;
|
|
1333
|
-
badgesVisibleLen += 1 + label.length;
|
|
1334
|
-
}
|
|
1335
|
-
|
|
1336
|
-
write(badges);
|
|
1337
|
-
|
|
1338
|
-
// Right side: Server mode + URL + status icons
|
|
1339
|
-
let modeLabel = '';
|
|
1340
|
-
let modeBadge = '';
|
|
1341
|
-
if (SERVER_MODE === 'static') {
|
|
1342
|
-
modeLabel = ' STATIC ';
|
|
1343
|
-
modeBadge = ansi.bgCyan + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
|
|
1344
|
-
} else if (SERVER_MODE === 'command') {
|
|
1345
|
-
modeLabel = ' COMMAND ';
|
|
1346
|
-
modeBadge = ansi.bgGreen + ansi.black + modeLabel + ansi.bgBlue + ansi.white;
|
|
1347
|
-
} else {
|
|
1348
|
-
modeLabel = ' MONITOR ';
|
|
1349
|
-
modeBadge = ansi.bgMagenta + ansi.white + modeLabel + ansi.bgBlue + ansi.white;
|
|
1350
|
-
}
|
|
1351
|
-
|
|
1352
|
-
let serverInfo = '';
|
|
1353
|
-
let serverInfoVisible = '';
|
|
1354
|
-
if (SERVER_MODE === 'none') {
|
|
1355
|
-
serverInfoVisible = '';
|
|
1356
|
-
} else {
|
|
1357
|
-
const statusDot = serverRunning ? ansi.green + '●' : (serverCrashed ? ansi.red + '●' : ansi.gray + '○');
|
|
1358
|
-
serverInfoVisible = `localhost:${PORT} `;
|
|
1359
|
-
serverInfo = statusDot + ansi.white + ` localhost:${PORT} `;
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
const rightContent = `${modeBadge} ${serverInfo}${statusIcon}${ansi.bgBlue} ${soundIcon}${ansi.bgBlue} `;
|
|
1363
|
-
const rightVisibleLen = modeLabel.length + 1 + serverInfoVisible.length + 5; // mode + space + serverInfo + "● 🔔 "
|
|
1364
|
-
|
|
1365
|
-
// Calculate padding to fill full width
|
|
1366
|
-
const usedSpace = leftVisibleLen + badgesVisibleLen + rightVisibleLen;
|
|
1367
|
-
const padding = Math.max(1, width - usedSpace);
|
|
1368
|
-
write(' '.repeat(padding));
|
|
1369
|
-
write(rightContent);
|
|
1370
|
-
write(ansi.reset);
|
|
1371
|
-
}
|
|
1372
|
-
|
|
1373
|
-
function renderBranchList() {
|
|
1374
|
-
// Start row: 3 normally, 4 when casino mode (row 1 is marquee, row 2 is header)
|
|
1375
|
-
const startRow = casinoModeEnabled ? 4 : 3;
|
|
1376
|
-
const boxWidth = terminalWidth;
|
|
1377
|
-
const contentWidth = boxWidth - 4; // Space between borders
|
|
1378
|
-
const height = Math.min(visibleBranchCount * 2 + 4, Math.floor(terminalHeight * 0.5));
|
|
1379
|
-
|
|
1380
|
-
// Determine which branches to show (filtered or all)
|
|
1381
|
-
const displayBranches = filteredBranches !== null ? filteredBranches : branches;
|
|
1382
|
-
const boxTitle = searchMode
|
|
1383
|
-
? `BRANCHES (/${searchQuery}_)`
|
|
1384
|
-
: 'ACTIVE BRANCHES';
|
|
1385
|
-
|
|
1386
|
-
drawBox(startRow, 1, boxWidth, height, boxTitle, ansi.cyan);
|
|
1387
|
-
|
|
1388
|
-
// Clear content area first (fixes border gaps)
|
|
1389
|
-
for (let i = 1; i < height - 1; i++) {
|
|
1390
|
-
write(ansi.moveTo(startRow + i, 2));
|
|
1391
|
-
write(' '.repeat(contentWidth + 2));
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
// Header line
|
|
1395
|
-
write(ansi.moveTo(startRow + 1, 2));
|
|
1396
|
-
write(ansi.gray + '─'.repeat(contentWidth + 2) + ansi.reset);
|
|
1397
|
-
|
|
1398
|
-
if (displayBranches.length === 0) {
|
|
1399
|
-
write(ansi.moveTo(startRow + 3, 4));
|
|
1400
|
-
if (searchMode && searchQuery) {
|
|
1401
|
-
write(ansi.gray + `No branches matching "${searchQuery}"` + ansi.reset);
|
|
1402
|
-
} else {
|
|
1403
|
-
write(ansi.gray + "No branches found. Press 'f' to fetch." + ansi.reset);
|
|
1404
|
-
}
|
|
1405
|
-
return startRow + height;
|
|
1406
|
-
}
|
|
1407
|
-
|
|
1408
|
-
let row = startRow + 2;
|
|
1409
|
-
for (let i = 0; i < displayBranches.length && i < visibleBranchCount; i++) {
|
|
1410
|
-
const branch = displayBranches[i];
|
|
1411
|
-
const isSelected = i === selectedIndex;
|
|
1412
|
-
const isCurrent = branch.name === currentBranch;
|
|
1413
|
-
const timeAgo = formatTimeAgo(branch.date);
|
|
1414
|
-
const sparkline = sparklineCache.get(branch.name) || ' ';
|
|
1415
|
-
|
|
1416
|
-
// Branch name line
|
|
1417
|
-
write(ansi.moveTo(row, 2));
|
|
1418
|
-
|
|
1419
|
-
// Cursor indicator
|
|
1420
|
-
const cursor = isSelected ? ' ▶ ' : ' ';
|
|
1421
|
-
|
|
1422
|
-
// Branch name - adjust for sparkline
|
|
1423
|
-
const maxNameLen = contentWidth - 38; // Extra space for sparkline
|
|
1424
|
-
const displayName = truncate(branch.name, maxNameLen);
|
|
1425
|
-
|
|
1426
|
-
// Padding after name
|
|
1427
|
-
const namePadding = Math.max(1, maxNameLen - displayName.length + 2);
|
|
1428
|
-
|
|
1429
|
-
// Write the line
|
|
1430
|
-
if (isSelected) write(ansi.inverse);
|
|
1431
|
-
write(cursor);
|
|
1432
|
-
|
|
1433
|
-
if (branch.isDeleted) {
|
|
1434
|
-
write(ansi.gray + ansi.dim + displayName + ansi.reset);
|
|
1435
|
-
if (isSelected) write(ansi.inverse);
|
|
1436
|
-
} else if (isCurrent) {
|
|
1437
|
-
write(ansi.green + ansi.bold + displayName + ansi.reset);
|
|
1438
|
-
if (isSelected) write(ansi.inverse);
|
|
1439
|
-
} else if (branch.justUpdated) {
|
|
1440
|
-
write(ansi.yellow + displayName + ansi.reset);
|
|
1441
|
-
if (isSelected) write(ansi.inverse);
|
|
1442
|
-
branch.justUpdated = false;
|
|
1443
|
-
} else {
|
|
1444
|
-
write(displayName);
|
|
1445
|
-
}
|
|
1446
|
-
|
|
1447
|
-
write(' '.repeat(namePadding));
|
|
1448
|
-
|
|
1449
|
-
// Sparkline (7 chars)
|
|
1450
|
-
if (isSelected) write(ansi.reset);
|
|
1451
|
-
write(ansi.fg256(39) + sparkline + ansi.reset); // Nice blue color
|
|
1452
|
-
if (isSelected) write(ansi.inverse);
|
|
1453
|
-
write(' ');
|
|
1454
|
-
|
|
1455
|
-
// Status badge
|
|
1456
|
-
if (branch.isDeleted) {
|
|
1457
|
-
if (isSelected) write(ansi.reset);
|
|
1458
|
-
write(ansi.red + ansi.dim + '✗ DELETED' + ansi.reset);
|
|
1459
|
-
if (isSelected) write(ansi.inverse);
|
|
1460
|
-
} else if (isCurrent) {
|
|
1461
|
-
if (isSelected) write(ansi.reset);
|
|
1462
|
-
write(ansi.green + '★ CURRENT' + ansi.reset);
|
|
1463
|
-
if (isSelected) write(ansi.inverse);
|
|
1464
|
-
} else if (branch.isNew) {
|
|
1465
|
-
if (isSelected) write(ansi.reset);
|
|
1466
|
-
write(ansi.magenta + '✦ NEW ' + ansi.reset);
|
|
1467
|
-
if (isSelected) write(ansi.inverse);
|
|
1468
|
-
} else if (branch.hasUpdates) {
|
|
1469
|
-
if (isSelected) write(ansi.reset);
|
|
1470
|
-
write(ansi.yellow + '↓ UPDATES' + ansi.reset);
|
|
1471
|
-
if (isSelected) write(ansi.inverse);
|
|
1472
|
-
} else {
|
|
1473
|
-
write(' ');
|
|
1474
|
-
}
|
|
1475
|
-
|
|
1476
|
-
// Time ago
|
|
1477
|
-
write(' ');
|
|
1478
|
-
if (isSelected) write(ansi.reset);
|
|
1479
|
-
write(ansi.gray + padLeft(timeAgo, 10) + ansi.reset);
|
|
1480
|
-
|
|
1481
|
-
if (isSelected) write(ansi.reset);
|
|
1482
|
-
|
|
1483
|
-
row++;
|
|
1484
|
-
|
|
1485
|
-
// Commit info line
|
|
1486
|
-
write(ansi.moveTo(row, 2));
|
|
1487
|
-
write(' └─ ');
|
|
1488
|
-
write(ansi.cyan + (branch.commit || '???????') + ansi.reset);
|
|
1489
|
-
write(' • ');
|
|
1490
|
-
write(ansi.gray + truncate(branch.subject || 'No commit message', contentWidth - 22) + ansi.reset);
|
|
1491
|
-
|
|
1492
|
-
row++;
|
|
1493
|
-
}
|
|
1494
|
-
|
|
1495
|
-
return startRow + height;
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
function renderActivityLog(startRow) {
|
|
1499
|
-
const boxWidth = terminalWidth;
|
|
1500
|
-
const contentWidth = boxWidth - 4;
|
|
1501
|
-
const height = Math.min(MAX_LOG_ENTRIES + 3, terminalHeight - startRow - 4);
|
|
1502
|
-
|
|
1503
|
-
drawBox(startRow, 1, boxWidth, height, 'ACTIVITY LOG', ansi.gray);
|
|
1504
|
-
|
|
1505
|
-
// Clear content area first (fixes border gaps)
|
|
1506
|
-
for (let i = 1; i < height - 1; i++) {
|
|
1507
|
-
write(ansi.moveTo(startRow + i, 2));
|
|
1508
|
-
write(' '.repeat(contentWidth + 2));
|
|
1509
|
-
}
|
|
1510
|
-
|
|
1511
|
-
let row = startRow + 1;
|
|
1512
|
-
for (let i = 0; i < activityLog.length && i < height - 2; i++) {
|
|
1513
|
-
const entry = activityLog[i];
|
|
1514
|
-
write(ansi.moveTo(row, 3));
|
|
1515
|
-
write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
|
|
1516
|
-
write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
|
|
1517
|
-
write(truncate(entry.message, contentWidth - 16));
|
|
1518
|
-
row++;
|
|
1519
|
-
}
|
|
1520
|
-
|
|
1521
|
-
if (activityLog.length === 0) {
|
|
1522
|
-
write(ansi.moveTo(startRow + 1, 3));
|
|
1523
|
-
write(ansi.gray + 'No activity yet...' + ansi.reset);
|
|
1524
|
-
}
|
|
1017
|
+
// renderHeader - now delegated to renderer.renderHeader()
|
|
1525
1018
|
|
|
1526
|
-
|
|
1527
|
-
}
|
|
1019
|
+
// renderBranchList, renderActivityLog — now delegated to renderer module (src/ui/renderer.js)
|
|
1528
1020
|
|
|
1529
1021
|
function renderCasinoStats(startRow) {
|
|
1530
|
-
if (!casinoModeEnabled) return startRow;
|
|
1022
|
+
if (!store.get('casinoModeEnabled')) return startRow;
|
|
1531
1023
|
|
|
1532
|
-
const boxWidth = terminalWidth;
|
|
1024
|
+
const boxWidth = store.get('terminalWidth');
|
|
1533
1025
|
const height = 6; // Box with two content lines
|
|
1534
1026
|
|
|
1535
1027
|
// Don't draw if not enough space
|
|
1536
|
-
if (startRow + height > terminalHeight - 3) return startRow;
|
|
1028
|
+
if (startRow + height > store.get('terminalHeight') - 3) return startRow;
|
|
1537
1029
|
|
|
1538
1030
|
drawBox(startRow, 1, boxWidth, height, '🎰 CASINO WINNINGS 🎰', ansi.brightMagenta);
|
|
1539
1031
|
|
|
@@ -1569,450 +1061,17 @@ function renderCasinoStats(startRow) {
|
|
|
1569
1061
|
return startRow + height;
|
|
1570
1062
|
}
|
|
1571
1063
|
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
write(ansi.moveTo(row, 1));
|
|
1576
|
-
write(ansi.bgBlack + ansi.white);
|
|
1577
|
-
write(' ');
|
|
1578
|
-
write(ansi.gray + '[↑↓]' + ansi.reset + ansi.bgBlack + ' Nav ');
|
|
1579
|
-
write(ansi.gray + '[/]' + ansi.reset + ansi.bgBlack + ' Search ');
|
|
1580
|
-
write(ansi.gray + '[v]' + ansi.reset + ansi.bgBlack + ' Preview ');
|
|
1581
|
-
write(ansi.gray + '[Enter]' + ansi.reset + ansi.bgBlack + ' Switch ');
|
|
1582
|
-
write(ansi.gray + '[h]' + ansi.reset + ansi.bgBlack + ' History ');
|
|
1583
|
-
write(ansi.gray + '[i]' + ansi.reset + ansi.bgBlack + ' Info ');
|
|
1584
|
-
|
|
1585
|
-
// Mode-specific keys
|
|
1586
|
-
if (!NO_SERVER) {
|
|
1587
|
-
write(ansi.gray + '[l]' + ansi.reset + ansi.bgBlack + ' Logs ');
|
|
1588
|
-
write(ansi.gray + '[o]' + ansi.reset + ansi.bgBlack + ' Open ');
|
|
1589
|
-
}
|
|
1590
|
-
if (SERVER_MODE === 'static') {
|
|
1591
|
-
write(ansi.gray + '[r]' + ansi.reset + ansi.bgBlack + ' Reload ');
|
|
1592
|
-
} else if (SERVER_MODE === 'command') {
|
|
1593
|
-
write(ansi.gray + '[R]' + ansi.reset + ansi.bgBlack + ' Restart ');
|
|
1594
|
-
}
|
|
1595
|
-
|
|
1596
|
-
write(ansi.gray + '[±]' + ansi.reset + ansi.bgBlack + ' List:' + ansi.cyan + visibleBranchCount + ansi.reset + ansi.bgBlack + ' ');
|
|
1597
|
-
|
|
1598
|
-
// Casino mode toggle indicator
|
|
1599
|
-
if (casinoModeEnabled) {
|
|
1600
|
-
write(ansi.brightMagenta + '[c]' + ansi.reset + ansi.bgBlack + ' 🎰 ');
|
|
1601
|
-
} else {
|
|
1602
|
-
write(ansi.gray + '[c]' + ansi.reset + ansi.bgBlack + ' Casino ');
|
|
1603
|
-
}
|
|
1604
|
-
|
|
1605
|
-
write(ansi.gray + '[q]' + ansi.reset + ansi.bgBlack + ' Quit ');
|
|
1606
|
-
write(ansi.reset);
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
function renderFlash() {
|
|
1610
|
-
if (!flashMessage) return;
|
|
1611
|
-
|
|
1612
|
-
const width = 50;
|
|
1613
|
-
const height = 5;
|
|
1614
|
-
const col = Math.floor((terminalWidth - width) / 2);
|
|
1615
|
-
const row = Math.floor((terminalHeight - height) / 2);
|
|
1616
|
-
|
|
1617
|
-
// Draw double-line box
|
|
1618
|
-
write(ansi.moveTo(row, col));
|
|
1619
|
-
write(ansi.yellow + ansi.bold);
|
|
1620
|
-
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1621
|
-
|
|
1622
|
-
for (let i = 1; i < height - 1; i++) {
|
|
1623
|
-
write(ansi.moveTo(row + i, col));
|
|
1624
|
-
write(box.dVertical + ' '.repeat(width - 2) + box.dVertical);
|
|
1625
|
-
}
|
|
1626
|
-
|
|
1627
|
-
write(ansi.moveTo(row + height - 1, col));
|
|
1628
|
-
write(box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1629
|
-
write(ansi.reset);
|
|
1630
|
-
|
|
1631
|
-
// Content
|
|
1632
|
-
write(ansi.moveTo(row + 1, col + Math.floor((width - 16) / 2)));
|
|
1633
|
-
write(ansi.yellow + ansi.bold + '⚡ NEW UPDATE ⚡' + ansi.reset);
|
|
1634
|
-
|
|
1635
|
-
write(ansi.moveTo(row + 2, col + 2));
|
|
1636
|
-
const truncMsg = truncate(flashMessage, width - 4);
|
|
1637
|
-
write(ansi.white + truncMsg + ansi.reset);
|
|
1638
|
-
|
|
1639
|
-
write(ansi.moveTo(row + 3, col + Math.floor((width - 22) / 2)));
|
|
1640
|
-
write(ansi.gray + 'Press any key to dismiss' + ansi.reset);
|
|
1641
|
-
}
|
|
1642
|
-
|
|
1643
|
-
function renderErrorToast() {
|
|
1644
|
-
if (!errorToast) return;
|
|
1645
|
-
|
|
1646
|
-
const width = Math.min(60, terminalWidth - 4);
|
|
1647
|
-
const col = Math.floor((terminalWidth - width) / 2);
|
|
1648
|
-
const row = 2; // Near the top, below header
|
|
1649
|
-
|
|
1650
|
-
// Calculate height based on content
|
|
1651
|
-
const lines = [];
|
|
1652
|
-
lines.push(errorToast.title || 'Git Error');
|
|
1653
|
-
lines.push('');
|
|
1654
|
-
|
|
1655
|
-
// Word wrap the message
|
|
1656
|
-
const msgWords = errorToast.message.split(' ');
|
|
1657
|
-
let currentLine = '';
|
|
1658
|
-
for (const word of msgWords) {
|
|
1659
|
-
if ((currentLine + ' ' + word).length > width - 6) {
|
|
1660
|
-
lines.push(currentLine.trim());
|
|
1661
|
-
currentLine = word;
|
|
1662
|
-
} else {
|
|
1663
|
-
currentLine += (currentLine ? ' ' : '') + word;
|
|
1664
|
-
}
|
|
1665
|
-
}
|
|
1666
|
-
if (currentLine) lines.push(currentLine.trim());
|
|
1667
|
-
|
|
1668
|
-
if (errorToast.hint) {
|
|
1669
|
-
lines.push('');
|
|
1670
|
-
lines.push(errorToast.hint);
|
|
1671
|
-
}
|
|
1672
|
-
lines.push('');
|
|
1673
|
-
lines.push('Press any key to dismiss');
|
|
1674
|
-
|
|
1675
|
-
const height = lines.length + 2;
|
|
1676
|
-
|
|
1677
|
-
// Draw red error box
|
|
1678
|
-
write(ansi.moveTo(row, col));
|
|
1679
|
-
write(ansi.red + ansi.bold);
|
|
1680
|
-
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1681
|
-
|
|
1682
|
-
for (let i = 1; i < height - 1; i++) {
|
|
1683
|
-
write(ansi.moveTo(row + i, col));
|
|
1684
|
-
write(ansi.red + box.dVertical + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 2) + ansi.reset + ansi.red + box.dVertical + ansi.reset);
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
write(ansi.moveTo(row + height - 1, col));
|
|
1688
|
-
write(ansi.red + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1689
|
-
write(ansi.reset);
|
|
1690
|
-
|
|
1691
|
-
// Render content
|
|
1692
|
-
let contentRow = row + 1;
|
|
1693
|
-
for (let i = 0; i < lines.length; i++) {
|
|
1694
|
-
const line = lines[i];
|
|
1695
|
-
write(ansi.moveTo(contentRow, col + 2));
|
|
1696
|
-
write(ansi.bgRed + ansi.white);
|
|
1697
|
-
|
|
1698
|
-
if (i === 0) {
|
|
1699
|
-
// Title line - centered and bold
|
|
1700
|
-
const titlePadding = Math.floor((width - 4 - line.length) / 2);
|
|
1701
|
-
write(' '.repeat(titlePadding) + ansi.bold + line + ansi.reset + ansi.bgRed + ansi.white + ' '.repeat(width - 4 - titlePadding - line.length));
|
|
1702
|
-
} else if (line === 'Press any key to dismiss') {
|
|
1703
|
-
// Instruction line - centered and dimmer
|
|
1704
|
-
const padding = Math.floor((width - 4 - line.length) / 2);
|
|
1705
|
-
write(ansi.reset + ansi.bgRed + ansi.gray + ' '.repeat(padding) + line + ' '.repeat(width - 4 - padding - line.length));
|
|
1706
|
-
} else if (errorToast.hint && line === errorToast.hint) {
|
|
1707
|
-
// Hint line - yellow on red
|
|
1708
|
-
const padding = Math.floor((width - 4 - line.length) / 2);
|
|
1709
|
-
write(ansi.reset + ansi.bgRed + ansi.yellow + ' '.repeat(padding) + line + ' '.repeat(width - 4 - padding - line.length));
|
|
1710
|
-
} else {
|
|
1711
|
-
// Regular content
|
|
1712
|
-
write(padRight(line, width - 4));
|
|
1713
|
-
}
|
|
1714
|
-
write(ansi.reset);
|
|
1715
|
-
contentRow++;
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
function renderPreview() {
|
|
1720
|
-
if (!previewMode || !previewData) return;
|
|
1721
|
-
|
|
1722
|
-
const width = Math.min(60, terminalWidth - 4);
|
|
1723
|
-
const height = 16;
|
|
1724
|
-
const col = Math.floor((terminalWidth - width) / 2);
|
|
1725
|
-
const row = Math.floor((terminalHeight - height) / 2);
|
|
1726
|
-
|
|
1727
|
-
const displayBranches = filteredBranches !== null ? filteredBranches : branches;
|
|
1728
|
-
const branch = displayBranches[selectedIndex];
|
|
1729
|
-
if (!branch) return;
|
|
1730
|
-
|
|
1731
|
-
// Draw box
|
|
1732
|
-
write(ansi.moveTo(row, col));
|
|
1733
|
-
write(ansi.cyan + ansi.bold);
|
|
1734
|
-
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1735
|
-
|
|
1736
|
-
for (let i = 1; i < height - 1; i++) {
|
|
1737
|
-
write(ansi.moveTo(row + i, col));
|
|
1738
|
-
write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
write(ansi.moveTo(row + height - 1, col));
|
|
1742
|
-
write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1743
|
-
write(ansi.reset);
|
|
1744
|
-
|
|
1745
|
-
// Title
|
|
1746
|
-
const title = ` Preview: ${truncate(branch.name, width - 14)} `;
|
|
1747
|
-
write(ansi.moveTo(row, col + 2));
|
|
1748
|
-
write(ansi.cyan + ansi.bold + title + ansi.reset);
|
|
1749
|
-
|
|
1750
|
-
// Commits section
|
|
1751
|
-
write(ansi.moveTo(row + 2, col + 2));
|
|
1752
|
-
write(ansi.white + ansi.bold + 'Recent Commits:' + ansi.reset);
|
|
1753
|
-
|
|
1754
|
-
let contentRow = row + 3;
|
|
1755
|
-
if (previewData.commits.length === 0) {
|
|
1756
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1757
|
-
write(ansi.gray + '(no commits)' + ansi.reset);
|
|
1758
|
-
contentRow++;
|
|
1759
|
-
} else {
|
|
1760
|
-
for (const commit of previewData.commits.slice(0, 5)) {
|
|
1761
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1762
|
-
write(ansi.yellow + commit.hash + ansi.reset + ' ');
|
|
1763
|
-
write(ansi.gray + truncate(commit.message, width - 14) + ansi.reset);
|
|
1764
|
-
contentRow++;
|
|
1765
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
// Files section
|
|
1769
|
-
contentRow++;
|
|
1770
|
-
write(ansi.moveTo(contentRow, col + 2));
|
|
1771
|
-
write(ansi.white + ansi.bold + 'Files Changed vs HEAD:' + ansi.reset);
|
|
1772
|
-
contentRow++;
|
|
1773
|
-
|
|
1774
|
-
if (previewData.filesChanged.length === 0) {
|
|
1775
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1776
|
-
write(ansi.gray + '(no changes or same as current)' + ansi.reset);
|
|
1777
|
-
} else {
|
|
1778
|
-
for (const file of previewData.filesChanged.slice(0, 5)) {
|
|
1779
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1780
|
-
write(ansi.green + '• ' + ansi.reset + truncate(file, width - 8));
|
|
1781
|
-
contentRow++;
|
|
1782
|
-
}
|
|
1783
|
-
if (previewData.filesChanged.length > 5) {
|
|
1784
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1785
|
-
write(ansi.gray + `... and ${previewData.filesChanged.length - 5} more` + ansi.reset);
|
|
1786
|
-
}
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
// Instructions
|
|
1790
|
-
write(ansi.moveTo(row + height - 2, col + Math.floor((width - 26) / 2)));
|
|
1791
|
-
write(ansi.gray + 'Press [v] or [Esc] to close' + ansi.reset);
|
|
1792
|
-
}
|
|
1793
|
-
|
|
1794
|
-
function renderHistory() {
|
|
1795
|
-
const width = Math.min(50, terminalWidth - 4);
|
|
1796
|
-
const height = Math.min(switchHistory.length + 5, 15);
|
|
1797
|
-
const col = Math.floor((terminalWidth - width) / 2);
|
|
1798
|
-
const row = Math.floor((terminalHeight - height) / 2);
|
|
1799
|
-
|
|
1800
|
-
// Draw box
|
|
1801
|
-
write(ansi.moveTo(row, col));
|
|
1802
|
-
write(ansi.magenta + ansi.bold);
|
|
1803
|
-
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1804
|
-
|
|
1805
|
-
for (let i = 1; i < height - 1; i++) {
|
|
1806
|
-
write(ansi.moveTo(row + i, col));
|
|
1807
|
-
write(ansi.magenta + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.magenta + box.dVertical + ansi.reset);
|
|
1808
|
-
}
|
|
1809
|
-
|
|
1810
|
-
write(ansi.moveTo(row + height - 1, col));
|
|
1811
|
-
write(ansi.magenta + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1812
|
-
write(ansi.reset);
|
|
1813
|
-
|
|
1814
|
-
// Title
|
|
1815
|
-
write(ansi.moveTo(row, col + 2));
|
|
1816
|
-
write(ansi.magenta + ansi.bold + ' Switch History ' + ansi.reset);
|
|
1817
|
-
|
|
1818
|
-
// Content
|
|
1819
|
-
if (switchHistory.length === 0) {
|
|
1820
|
-
write(ansi.moveTo(row + 2, col + 3));
|
|
1821
|
-
write(ansi.gray + 'No branch switches yet' + ansi.reset);
|
|
1822
|
-
} else {
|
|
1823
|
-
let contentRow = row + 2;
|
|
1824
|
-
for (let i = 0; i < Math.min(switchHistory.length, height - 4); i++) {
|
|
1825
|
-
const entry = switchHistory[i];
|
|
1826
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1827
|
-
if (i === 0) {
|
|
1828
|
-
write(ansi.yellow + '→ ' + ansi.reset); // Most recent
|
|
1829
|
-
} else {
|
|
1830
|
-
write(ansi.gray + ' ' + ansi.reset);
|
|
1831
|
-
}
|
|
1832
|
-
write(truncate(entry.from, 15) + ansi.gray + ' → ' + ansi.reset);
|
|
1833
|
-
write(ansi.cyan + truncate(entry.to, 15) + ansi.reset);
|
|
1834
|
-
contentRow++;
|
|
1835
|
-
}
|
|
1836
|
-
}
|
|
1837
|
-
|
|
1838
|
-
// Instructions
|
|
1839
|
-
write(ansi.moveTo(row + height - 2, col + 2));
|
|
1840
|
-
write(ansi.gray + '[u] Undo last [h]/[Esc] Close' + ansi.reset);
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
let historyMode = false;
|
|
1844
|
-
let infoMode = false;
|
|
1845
|
-
|
|
1846
|
-
function renderLogView() {
|
|
1847
|
-
if (!logViewMode) return;
|
|
1848
|
-
|
|
1849
|
-
const width = Math.min(terminalWidth - 4, 100);
|
|
1850
|
-
const height = Math.min(terminalHeight - 4, 30);
|
|
1851
|
-
const col = Math.floor((terminalWidth - width) / 2);
|
|
1852
|
-
const row = Math.floor((terminalHeight - height) / 2);
|
|
1853
|
-
|
|
1854
|
-
// Determine which log to display
|
|
1855
|
-
const isServerTab = logViewTab === 'server';
|
|
1856
|
-
const logData = isServerTab ? serverLogBuffer : activityLog;
|
|
1857
|
-
|
|
1858
|
-
// Draw box
|
|
1859
|
-
write(ansi.moveTo(row, col));
|
|
1860
|
-
write(ansi.yellow + ansi.bold);
|
|
1861
|
-
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1862
|
-
|
|
1863
|
-
for (let i = 1; i < height - 1; i++) {
|
|
1864
|
-
write(ansi.moveTo(row + i, col));
|
|
1865
|
-
write(ansi.yellow + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.yellow + box.dVertical + ansi.reset);
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
write(ansi.moveTo(row + height - 1, col));
|
|
1869
|
-
write(ansi.yellow + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1870
|
-
write(ansi.reset);
|
|
1871
|
-
|
|
1872
|
-
// Title with tabs
|
|
1873
|
-
const activityTab = logViewTab === 'activity'
|
|
1874
|
-
? ansi.bgWhite + ansi.black + ' 1:Activity ' + ansi.reset + ansi.yellow
|
|
1875
|
-
: ansi.gray + ' 1:Activity ' + ansi.yellow;
|
|
1876
|
-
const serverTab = logViewTab === 'server'
|
|
1877
|
-
? ansi.bgWhite + ansi.black + ' 2:Server ' + ansi.reset + ansi.yellow
|
|
1878
|
-
: ansi.gray + ' 2:Server ' + ansi.yellow;
|
|
1879
|
-
|
|
1880
|
-
// Server status (only show on server tab)
|
|
1881
|
-
let statusIndicator = '';
|
|
1882
|
-
if (isServerTab && SERVER_MODE === 'command') {
|
|
1883
|
-
const statusText = serverRunning ? ansi.green + 'RUNNING' : (serverCrashed ? ansi.red + 'CRASHED' : ansi.gray + 'STOPPED');
|
|
1884
|
-
statusIndicator = ` [${statusText}${ansi.yellow}]`;
|
|
1885
|
-
} else if (isServerTab && SERVER_MODE === 'static') {
|
|
1886
|
-
statusIndicator = ansi.green + ' [STATIC]' + ansi.yellow;
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
write(ansi.moveTo(row, col + 2));
|
|
1890
|
-
write(ansi.yellow + ansi.bold + ' ' + activityTab + ' ' + serverTab + statusIndicator + ' ' + ansi.reset);
|
|
1891
|
-
|
|
1892
|
-
// Content
|
|
1893
|
-
const contentHeight = height - 4;
|
|
1894
|
-
const maxScroll = Math.max(0, logData.length - contentHeight);
|
|
1895
|
-
logScrollOffset = Math.min(logScrollOffset, maxScroll);
|
|
1896
|
-
logScrollOffset = Math.max(0, logScrollOffset);
|
|
1897
|
-
|
|
1898
|
-
let contentRow = row + 2;
|
|
1899
|
-
|
|
1900
|
-
if (logData.length === 0) {
|
|
1901
|
-
write(ansi.moveTo(contentRow, col + 2));
|
|
1902
|
-
write(ansi.gray + (isServerTab ? 'No server output yet...' : 'No activity yet...') + ansi.reset);
|
|
1903
|
-
} else if (isServerTab) {
|
|
1904
|
-
// Server log: newest at bottom, scroll from bottom
|
|
1905
|
-
const startIndex = Math.max(0, serverLogBuffer.length - contentHeight - logScrollOffset);
|
|
1906
|
-
const endIndex = Math.min(serverLogBuffer.length, startIndex + contentHeight);
|
|
1907
|
-
|
|
1908
|
-
for (let i = startIndex; i < endIndex; i++) {
|
|
1909
|
-
const entry = serverLogBuffer[i];
|
|
1910
|
-
write(ansi.moveTo(contentRow, col + 2));
|
|
1911
|
-
const lineText = truncate(entry.line, width - 4);
|
|
1912
|
-
if (entry.isError) {
|
|
1913
|
-
write(ansi.red + lineText + ansi.reset);
|
|
1914
|
-
} else {
|
|
1915
|
-
write(lineText);
|
|
1916
|
-
}
|
|
1917
|
-
contentRow++;
|
|
1918
|
-
}
|
|
1919
|
-
} else {
|
|
1920
|
-
// Activity log: newest first, scroll from top
|
|
1921
|
-
const startIndex = logScrollOffset;
|
|
1922
|
-
const endIndex = Math.min(activityLog.length, startIndex + contentHeight);
|
|
1923
|
-
|
|
1924
|
-
for (let i = startIndex; i < endIndex; i++) {
|
|
1925
|
-
const entry = activityLog[i];
|
|
1926
|
-
write(ansi.moveTo(contentRow, col + 2));
|
|
1927
|
-
write(ansi.gray + `[${entry.timestamp}]` + ansi.reset + ' ');
|
|
1928
|
-
write(ansi[entry.color] + entry.icon + ansi.reset + ' ');
|
|
1929
|
-
write(truncate(entry.message, width - 18));
|
|
1930
|
-
contentRow++;
|
|
1931
|
-
}
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
// Scroll indicator
|
|
1935
|
-
if (logData.length > contentHeight) {
|
|
1936
|
-
const scrollPercent = isServerTab
|
|
1937
|
-
? Math.round((1 - logScrollOffset / maxScroll) * 100)
|
|
1938
|
-
: Math.round((logScrollOffset / maxScroll) * 100);
|
|
1939
|
-
write(ansi.moveTo(row, col + width - 10));
|
|
1940
|
-
write(ansi.gray + ` ${scrollPercent}% ` + ansi.reset);
|
|
1941
|
-
}
|
|
1942
|
-
|
|
1943
|
-
// Instructions
|
|
1944
|
-
write(ansi.moveTo(row + height - 2, col + 2));
|
|
1945
|
-
const restartHint = SERVER_MODE === 'command' ? '[R] Restart ' : '';
|
|
1946
|
-
write(ansi.gray + '[1/2] Switch Tab [↑↓] Scroll ' + restartHint + '[l]/[Esc] Close' + ansi.reset);
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
function renderInfo() {
|
|
1950
|
-
const width = Math.min(50, terminalWidth - 4);
|
|
1951
|
-
const height = NO_SERVER ? 9 : 12;
|
|
1952
|
-
const col = Math.floor((terminalWidth - width) / 2);
|
|
1953
|
-
const row = Math.floor((terminalHeight - height) / 2);
|
|
1954
|
-
|
|
1955
|
-
// Draw box
|
|
1956
|
-
write(ansi.moveTo(row, col));
|
|
1957
|
-
write(ansi.cyan + ansi.bold);
|
|
1958
|
-
write(box.dTopLeft + box.dHorizontal.repeat(width - 2) + box.dTopRight);
|
|
1959
|
-
|
|
1960
|
-
for (let i = 1; i < height - 1; i++) {
|
|
1961
|
-
write(ansi.moveTo(row + i, col));
|
|
1962
|
-
write(ansi.cyan + box.dVertical + ansi.reset + ' '.repeat(width - 2) + ansi.cyan + box.dVertical + ansi.reset);
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
write(ansi.moveTo(row + height - 1, col));
|
|
1966
|
-
write(ansi.cyan + box.dBottomLeft + box.dHorizontal.repeat(width - 2) + box.dBottomRight);
|
|
1967
|
-
write(ansi.reset);
|
|
1968
|
-
|
|
1969
|
-
// Title
|
|
1970
|
-
write(ansi.moveTo(row, col + 2));
|
|
1971
|
-
write(ansi.cyan + ansi.bold + (NO_SERVER ? ' Status Info ' : ' Server Info ') + ansi.reset);
|
|
1064
|
+
// renderFooter, renderFlash, renderErrorToast, renderPreview, renderHistory
|
|
1065
|
+
// — now delegated to renderer module (src/ui/renderer.js)
|
|
1972
1066
|
|
|
1973
|
-
|
|
1974
|
-
|
|
1067
|
+
// renderLogView, renderInfo, renderActionModal
|
|
1068
|
+
// — now delegated to renderer module (src/ui/renderer.js)
|
|
1975
1069
|
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1982
|
-
write(ansi.gray + 'URL: ' + ansi.reset + ansi.green + `http://localhost:${PORT}` + ansi.reset);
|
|
1983
|
-
contentRow++;
|
|
1984
|
-
|
|
1985
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1986
|
-
write(ansi.gray + 'Port: ' + ansi.reset + ansi.yellow + PORT + ansi.reset);
|
|
1987
|
-
contentRow++;
|
|
1988
|
-
|
|
1989
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1990
|
-
write(ansi.gray + 'Connected browsers: ' + ansi.reset + ansi.cyan + clients.size + ansi.reset);
|
|
1991
|
-
contentRow++;
|
|
1992
|
-
|
|
1993
|
-
contentRow++;
|
|
1994
|
-
}
|
|
1995
|
-
|
|
1996
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
1997
|
-
write(ansi.white + ansi.bold + 'Git Polling' + ansi.reset);
|
|
1998
|
-
contentRow++;
|
|
1999
|
-
|
|
2000
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
2001
|
-
write(ansi.gray + 'Interval: ' + ansi.reset + `${adaptivePollInterval / 1000}s`);
|
|
2002
|
-
contentRow++;
|
|
2003
|
-
|
|
2004
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
2005
|
-
write(ansi.gray + 'Status: ' + ansi.reset + (isOffline ? ansi.red + 'Offline' : ansi.green + 'Online') + ansi.reset);
|
|
2006
|
-
contentRow++;
|
|
2007
|
-
|
|
2008
|
-
if (NO_SERVER) {
|
|
2009
|
-
write(ansi.moveTo(contentRow, col + 3));
|
|
2010
|
-
write(ansi.gray + 'Mode: ' + ansi.reset + ansi.magenta + 'No-Server (branch monitor only)' + ansi.reset);
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
|
-
// Instructions
|
|
2014
|
-
write(ansi.moveTo(row + height - 2, col + Math.floor((width - 20) / 2)));
|
|
2015
|
-
write(ansi.gray + 'Press [i] or [Esc] to close' + ansi.reset);
|
|
1070
|
+
// Build a state snapshot from the current globals for the renderer
|
|
1071
|
+
function getRenderState() {
|
|
1072
|
+
const s = store.getState();
|
|
1073
|
+
s.clientCount = clients.size;
|
|
1074
|
+
return s;
|
|
2016
1075
|
}
|
|
2017
1076
|
|
|
2018
1077
|
function render() {
|
|
@@ -2022,17 +1081,21 @@ function render() {
|
|
|
2022
1081
|
write(ansi.moveToTop);
|
|
2023
1082
|
write(ansi.clearScreen);
|
|
2024
1083
|
|
|
1084
|
+
const state = getRenderState();
|
|
1085
|
+
const { casinoModeEnabled, terminalWidth, terminalHeight } = state;
|
|
1086
|
+
|
|
2025
1087
|
// Casino mode: top marquee border
|
|
2026
1088
|
if (casinoModeEnabled) {
|
|
2027
1089
|
write(ansi.moveTo(1, 1));
|
|
2028
1090
|
write(casino.renderMarqueeLine(terminalWidth, 'top'));
|
|
2029
1091
|
}
|
|
2030
1092
|
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
const
|
|
1093
|
+
// Delegate to extracted renderer module
|
|
1094
|
+
renderer.renderHeader(state, write);
|
|
1095
|
+
const logStart = renderer.renderBranchList(state, write);
|
|
1096
|
+
const statsStart = renderer.renderActivityLog(state, write, logStart);
|
|
2034
1097
|
renderCasinoStats(statsStart);
|
|
2035
|
-
renderFooter();
|
|
1098
|
+
renderer.renderFooter(state, write);
|
|
2036
1099
|
|
|
2037
1100
|
// Casino mode: full border (top, bottom, left, right)
|
|
2038
1101
|
if (casinoModeEnabled) {
|
|
@@ -2055,7 +1118,6 @@ function render() {
|
|
|
2055
1118
|
if (casinoModeEnabled && casino.isSlotsActive()) {
|
|
2056
1119
|
const slotDisplay = casino.getSlotReelDisplay();
|
|
2057
1120
|
if (slotDisplay) {
|
|
2058
|
-
// Row 3: below header (row 1 is marquee, row 2 is header)
|
|
2059
1121
|
const resultLabel = casino.getSlotResultLabel();
|
|
2060
1122
|
let leftLabel, rightLabel;
|
|
2061
1123
|
|
|
@@ -2064,7 +1126,6 @@ function render() {
|
|
|
2064
1126
|
rightLabel = '';
|
|
2065
1127
|
} else if (resultLabel) {
|
|
2066
1128
|
leftLabel = ansi.bgBrightGreen + ansi.black + ansi.bold + ' RESULT ' + ansi.reset;
|
|
2067
|
-
// Flash effect for jackpots, use result color for text
|
|
2068
1129
|
const flash = resultLabel.isJackpot && (Math.floor(Date.now() / 150) % 2 === 0);
|
|
2069
1130
|
const bgColor = flash ? ansi.bgBrightYellow : ansi.bgWhite;
|
|
2070
1131
|
rightLabel = ' ' + bgColor + resultLabel.color + ansi.bold + ' ' + resultLabel.text + ' ' + ansi.reset;
|
|
@@ -2074,7 +1135,7 @@ function render() {
|
|
|
2074
1135
|
}
|
|
2075
1136
|
|
|
2076
1137
|
const fullDisplay = leftLabel + ' ' + slotDisplay + rightLabel;
|
|
2077
|
-
const col = Math.floor((terminalWidth - 70) / 2);
|
|
1138
|
+
const col = Math.floor((terminalWidth - 70) / 2);
|
|
2078
1139
|
write(ansi.moveTo(3, Math.max(2, col)));
|
|
2079
1140
|
write(fullDisplay);
|
|
2080
1141
|
}
|
|
@@ -2100,40 +1161,45 @@ function render() {
|
|
|
2100
1161
|
}
|
|
2101
1162
|
}
|
|
2102
1163
|
|
|
2103
|
-
|
|
2104
|
-
|
|
1164
|
+
// Delegate modal/overlay rendering to extracted renderer
|
|
1165
|
+
if (state.flashMessage) {
|
|
1166
|
+
renderer.renderFlash(state, write);
|
|
2105
1167
|
}
|
|
2106
1168
|
|
|
2107
|
-
if (previewMode && previewData) {
|
|
2108
|
-
renderPreview();
|
|
1169
|
+
if (state.previewMode && state.previewData) {
|
|
1170
|
+
renderer.renderPreview(state, write);
|
|
2109
1171
|
}
|
|
2110
1172
|
|
|
2111
|
-
if (historyMode) {
|
|
2112
|
-
renderHistory();
|
|
1173
|
+
if (state.historyMode) {
|
|
1174
|
+
renderer.renderHistory(state, write);
|
|
2113
1175
|
}
|
|
2114
1176
|
|
|
2115
|
-
if (infoMode) {
|
|
2116
|
-
renderInfo();
|
|
1177
|
+
if (state.infoMode) {
|
|
1178
|
+
renderer.renderInfo(state, write);
|
|
2117
1179
|
}
|
|
2118
1180
|
|
|
2119
|
-
if (logViewMode) {
|
|
2120
|
-
renderLogView();
|
|
1181
|
+
if (state.logViewMode) {
|
|
1182
|
+
renderer.renderLogView(state, write);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
if (state.actionMode) {
|
|
1186
|
+
renderer.renderActionModal(state, write);
|
|
2121
1187
|
}
|
|
2122
1188
|
|
|
2123
1189
|
// Error toast renders on top of everything for maximum visibility
|
|
2124
|
-
if (errorToast) {
|
|
2125
|
-
renderErrorToast();
|
|
1190
|
+
if (state.errorToast) {
|
|
1191
|
+
renderer.renderErrorToast(state, write);
|
|
2126
1192
|
}
|
|
2127
1193
|
}
|
|
2128
1194
|
|
|
2129
1195
|
function showFlash(message) {
|
|
2130
1196
|
if (flashTimeout) clearTimeout(flashTimeout);
|
|
2131
1197
|
|
|
2132
|
-
flashMessage
|
|
1198
|
+
store.setState({ flashMessage: message });
|
|
2133
1199
|
render();
|
|
2134
1200
|
|
|
2135
1201
|
flashTimeout = setTimeout(() => {
|
|
2136
|
-
flashMessage
|
|
1202
|
+
store.setState({ flashMessage: null });
|
|
2137
1203
|
render();
|
|
2138
1204
|
}, 3000);
|
|
2139
1205
|
}
|
|
@@ -2143,8 +1209,8 @@ function hideFlash() {
|
|
|
2143
1209
|
clearTimeout(flashTimeout);
|
|
2144
1210
|
flashTimeout = null;
|
|
2145
1211
|
}
|
|
2146
|
-
if (flashMessage) {
|
|
2147
|
-
flashMessage
|
|
1212
|
+
if (store.get('flashMessage')) {
|
|
1213
|
+
store.setState({ flashMessage: null });
|
|
2148
1214
|
render();
|
|
2149
1215
|
}
|
|
2150
1216
|
}
|
|
@@ -2152,12 +1218,13 @@ function hideFlash() {
|
|
|
2152
1218
|
function showErrorToast(title, message, hint = null, duration = 8000) {
|
|
2153
1219
|
if (errorToastTimeout) clearTimeout(errorToastTimeout);
|
|
2154
1220
|
|
|
2155
|
-
errorToast
|
|
1221
|
+
store.setState({ errorToast: { title, message, hint } });
|
|
2156
1222
|
playSound(); // Alert sound for errors
|
|
2157
1223
|
render();
|
|
2158
1224
|
|
|
2159
1225
|
errorToastTimeout = setTimeout(() => {
|
|
2160
|
-
errorToast
|
|
1226
|
+
store.setState({ errorToast: null });
|
|
1227
|
+
pendingDirtyOperation = null;
|
|
2161
1228
|
render();
|
|
2162
1229
|
}, duration);
|
|
2163
1230
|
}
|
|
@@ -2167,8 +1234,8 @@ function hideErrorToast() {
|
|
|
2167
1234
|
clearTimeout(errorToastTimeout);
|
|
2168
1235
|
errorToastTimeout = null;
|
|
2169
1236
|
}
|
|
2170
|
-
if (errorToast) {
|
|
2171
|
-
errorToast
|
|
1237
|
+
if (store.get('errorToast')) {
|
|
1238
|
+
store.setState({ errorToast: null });
|
|
2172
1239
|
render();
|
|
2173
1240
|
}
|
|
2174
1241
|
}
|
|
@@ -2182,12 +1249,12 @@ async function getCurrentBranch() {
|
|
|
2182
1249
|
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD');
|
|
2183
1250
|
// Check for detached HEAD state
|
|
2184
1251
|
if (stdout === 'HEAD') {
|
|
2185
|
-
isDetachedHead
|
|
1252
|
+
store.setState({ isDetachedHead: true });
|
|
2186
1253
|
// Get the short commit hash instead
|
|
2187
1254
|
const { stdout: commitHash } = await execAsync('git rev-parse --short HEAD');
|
|
2188
1255
|
return `HEAD@${commitHash}`;
|
|
2189
1256
|
}
|
|
2190
|
-
isDetachedHead
|
|
1257
|
+
store.setState({ isDetachedHead: false });
|
|
2191
1258
|
return stdout;
|
|
2192
1259
|
} catch (e) {
|
|
2193
1260
|
return null;
|
|
@@ -2213,46 +1280,7 @@ async function hasUncommittedChanges() {
|
|
|
2213
1280
|
}
|
|
2214
1281
|
}
|
|
2215
1282
|
|
|
2216
|
-
|
|
2217
|
-
const authErrors = [
|
|
2218
|
-
'Authentication failed',
|
|
2219
|
-
'could not read Username',
|
|
2220
|
-
'could not read Password',
|
|
2221
|
-
'Permission denied',
|
|
2222
|
-
'invalid credentials',
|
|
2223
|
-
'authorization failed',
|
|
2224
|
-
'fatal: Authentication',
|
|
2225
|
-
'HTTP 401',
|
|
2226
|
-
'HTTP 403',
|
|
2227
|
-
];
|
|
2228
|
-
const msg = (errorMessage || '').toLowerCase();
|
|
2229
|
-
return authErrors.some(err => msg.includes(err.toLowerCase()));
|
|
2230
|
-
}
|
|
2231
|
-
|
|
2232
|
-
function isMergeConflict(errorMessage) {
|
|
2233
|
-
const conflictIndicators = [
|
|
2234
|
-
'CONFLICT',
|
|
2235
|
-
'Automatic merge failed',
|
|
2236
|
-
'fix conflicts',
|
|
2237
|
-
'Merge conflict',
|
|
2238
|
-
];
|
|
2239
|
-
return conflictIndicators.some(ind => (errorMessage || '').includes(ind));
|
|
2240
|
-
}
|
|
2241
|
-
|
|
2242
|
-
function isNetworkError(errorMessage) {
|
|
2243
|
-
const networkErrors = [
|
|
2244
|
-
'Could not resolve host',
|
|
2245
|
-
'unable to access',
|
|
2246
|
-
'Connection refused',
|
|
2247
|
-
'Network is unreachable',
|
|
2248
|
-
'Connection timed out',
|
|
2249
|
-
'Failed to connect',
|
|
2250
|
-
'no route to host',
|
|
2251
|
-
'Temporary failure in name resolution',
|
|
2252
|
-
];
|
|
2253
|
-
const msg = (errorMessage || '').toLowerCase();
|
|
2254
|
-
return networkErrors.some(err => msg.includes(err.toLowerCase()));
|
|
2255
|
-
}
|
|
1283
|
+
// isAuthError, isMergeConflict, isNetworkError imported from src/utils/errors.js
|
|
2256
1284
|
|
|
2257
1285
|
async function getAllBranches() {
|
|
2258
1286
|
try {
|
|
@@ -2337,16 +1365,17 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
2337
1365
|
const isDirty = await hasUncommittedChanges();
|
|
2338
1366
|
if (isDirty) {
|
|
2339
1367
|
addLog(`Cannot switch: uncommitted changes in working directory`, 'error');
|
|
2340
|
-
addLog(`
|
|
1368
|
+
addLog(`Press S to stash changes, or commit manually`, 'warning');
|
|
1369
|
+
pendingDirtyOperation = { type: 'switch', branch: branchName };
|
|
2341
1370
|
showErrorToast(
|
|
2342
1371
|
'Cannot Switch Branch',
|
|
2343
1372
|
'You have uncommitted changes in your working directory that would be lost.',
|
|
2344
|
-
'
|
|
1373
|
+
'Press S to stash changes'
|
|
2345
1374
|
);
|
|
2346
1375
|
return { success: false, reason: 'dirty' };
|
|
2347
1376
|
}
|
|
2348
1377
|
|
|
2349
|
-
const previousBranch = currentBranch;
|
|
1378
|
+
const previousBranch = store.get('currentBranch');
|
|
2350
1379
|
|
|
2351
1380
|
addLog(`Switching to ${safeBranchName}...`, 'update');
|
|
2352
1381
|
render();
|
|
@@ -2360,19 +1389,18 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
2360
1389
|
await execAsync(`git checkout -b "${safeBranchName}" "${REMOTE_NAME}/${safeBranchName}"`);
|
|
2361
1390
|
}
|
|
2362
1391
|
|
|
2363
|
-
currentBranch
|
|
2364
|
-
isDetachedHead = false; // Successfully switched to branch
|
|
1392
|
+
store.setState({ currentBranch: safeBranchName, isDetachedHead: false });
|
|
2365
1393
|
|
|
2366
1394
|
// Clear NEW flag when branch becomes current
|
|
2367
|
-
const branchInfo = branches.find(b => b.name === safeBranchName);
|
|
1395
|
+
const branchInfo = store.get('branches').find(b => b.name === safeBranchName);
|
|
2368
1396
|
if (branchInfo && branchInfo.isNew) {
|
|
2369
1397
|
branchInfo.isNew = false;
|
|
2370
1398
|
}
|
|
2371
1399
|
|
|
2372
1400
|
// Record in history (for undo)
|
|
2373
1401
|
if (recordHistory && previousBranch && previousBranch !== safeBranchName) {
|
|
2374
|
-
switchHistory
|
|
2375
|
-
|
|
1402
|
+
const switchHistory = [{ from: previousBranch, to: safeBranchName, timestamp: Date.now() }, ...store.get('switchHistory')].slice(0, MAX_HISTORY);
|
|
1403
|
+
store.setState({ switchHistory });
|
|
2376
1404
|
}
|
|
2377
1405
|
|
|
2378
1406
|
addLog(`Switched to ${safeBranchName}`, 'success');
|
|
@@ -2395,11 +1423,12 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
2395
1423
|
);
|
|
2396
1424
|
} else if (errMsg.includes('local changes') || errMsg.includes('overwritten')) {
|
|
2397
1425
|
addLog(`Cannot switch: local changes would be overwritten`, 'error');
|
|
2398
|
-
addLog(`
|
|
1426
|
+
addLog(`Press S to stash changes, or commit manually`, 'warning');
|
|
1427
|
+
pendingDirtyOperation = { type: 'switch', branch: branchName };
|
|
2399
1428
|
showErrorToast(
|
|
2400
1429
|
'Cannot Switch Branch',
|
|
2401
1430
|
'Your local changes would be overwritten by checkout.',
|
|
2402
|
-
'
|
|
1431
|
+
'Press S to stash changes'
|
|
2403
1432
|
);
|
|
2404
1433
|
} else {
|
|
2405
1434
|
addLog(`Failed to switch: ${errMsg}`, 'error');
|
|
@@ -2414,17 +1443,18 @@ async function switchToBranch(branchName, recordHistory = true) {
|
|
|
2414
1443
|
}
|
|
2415
1444
|
|
|
2416
1445
|
async function undoLastSwitch() {
|
|
2417
|
-
|
|
1446
|
+
const currentHistory = store.get('switchHistory');
|
|
1447
|
+
if (currentHistory.length === 0) {
|
|
2418
1448
|
addLog('No switch history to undo', 'warning');
|
|
2419
1449
|
return { success: false };
|
|
2420
1450
|
}
|
|
2421
1451
|
|
|
2422
|
-
const lastSwitch =
|
|
1452
|
+
const lastSwitch = currentHistory[0];
|
|
2423
1453
|
addLog(`Undoing: going back to ${lastSwitch.from}`, 'update');
|
|
2424
1454
|
|
|
2425
1455
|
const result = await switchToBranch(lastSwitch.from, false);
|
|
2426
1456
|
if (result.success) {
|
|
2427
|
-
switchHistory.
|
|
1457
|
+
store.setState({ switchHistory: store.get('switchHistory').slice(1) });
|
|
2428
1458
|
addLog(`Undone: back on ${lastSwitch.from}`, 'success');
|
|
2429
1459
|
}
|
|
2430
1460
|
return result;
|
|
@@ -2457,8 +1487,16 @@ async function pullCurrentBranch() {
|
|
|
2457
1487
|
const errMsg = e.stderr || e.message || String(e);
|
|
2458
1488
|
addLog(`Pull failed: ${errMsg}`, 'error');
|
|
2459
1489
|
|
|
2460
|
-
if (
|
|
2461
|
-
|
|
1490
|
+
if (errMsg.includes('local changes') || errMsg.includes('overwritten') || errMsg.includes('uncommitted changes')) {
|
|
1491
|
+
addLog(`Press S to stash changes, or commit manually`, 'warning');
|
|
1492
|
+
pendingDirtyOperation = { type: 'pull' };
|
|
1493
|
+
showErrorToast(
|
|
1494
|
+
'Pull Failed',
|
|
1495
|
+
'Your local changes would be overwritten by pull.',
|
|
1496
|
+
'Press S to stash changes'
|
|
1497
|
+
);
|
|
1498
|
+
} else if (isMergeConflict(errMsg)) {
|
|
1499
|
+
store.setState({ hasMergeConflict: true });
|
|
2462
1500
|
showErrorToast(
|
|
2463
1501
|
'Merge Conflict!',
|
|
2464
1502
|
'Git pull resulted in merge conflicts that need manual resolution.',
|
|
@@ -2487,17 +1525,69 @@ async function pullCurrentBranch() {
|
|
|
2487
1525
|
}
|
|
2488
1526
|
}
|
|
2489
1527
|
|
|
1528
|
+
async function stashAndRetry() {
|
|
1529
|
+
const operation = pendingDirtyOperation;
|
|
1530
|
+
if (!operation) {
|
|
1531
|
+
addLog('No pending operation to retry', 'warning');
|
|
1532
|
+
render();
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
pendingDirtyOperation = null;
|
|
1537
|
+
hideErrorToast();
|
|
1538
|
+
|
|
1539
|
+
addLog('Stashing uncommitted changes...', 'update');
|
|
1540
|
+
render();
|
|
1541
|
+
|
|
1542
|
+
const stashResult = await gitStash({ message: 'git-watchtower: auto-stash before ' + (operation.type === 'switch' ? `switching to ${operation.branch}` : 'pull') });
|
|
1543
|
+
if (!stashResult.success) {
|
|
1544
|
+
addLog(`Stash failed: ${stashResult.error ? stashResult.error.message : 'unknown error'}`, 'error');
|
|
1545
|
+
showErrorToast('Stash Failed', stashResult.error ? stashResult.error.message : 'Could not stash changes.');
|
|
1546
|
+
render();
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
addLog('Changes stashed successfully', 'success');
|
|
1551
|
+
|
|
1552
|
+
if (operation.type === 'switch') {
|
|
1553
|
+
const switchResult = await switchToBranch(operation.branch);
|
|
1554
|
+
if (!switchResult.success) {
|
|
1555
|
+
addLog('Branch switch failed after stash — restoring stashed changes...', 'warning');
|
|
1556
|
+
const popResult = await gitStashPop();
|
|
1557
|
+
if (popResult.success) {
|
|
1558
|
+
addLog('Stashed changes restored', 'info');
|
|
1559
|
+
} else {
|
|
1560
|
+
addLog('Warning: could not restore stashed changes. Run: git stash pop', 'error');
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
await pollGitChanges();
|
|
1564
|
+
} else if (operation.type === 'pull') {
|
|
1565
|
+
const pullResult = await pullCurrentBranch();
|
|
1566
|
+
if (!pullResult.success) {
|
|
1567
|
+
addLog('Pull failed after stash — restoring stashed changes...', 'warning');
|
|
1568
|
+
const popResult = await gitStashPop();
|
|
1569
|
+
if (popResult.success) {
|
|
1570
|
+
addLog('Stashed changes restored', 'info');
|
|
1571
|
+
} else {
|
|
1572
|
+
addLog('Warning: could not restore stashed changes. Run: git stash pop', 'error');
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
await pollGitChanges();
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
render();
|
|
1579
|
+
}
|
|
1580
|
+
|
|
2490
1581
|
// ============================================================================
|
|
2491
1582
|
// Polling
|
|
2492
1583
|
// ============================================================================
|
|
2493
1584
|
|
|
2494
1585
|
async function pollGitChanges() {
|
|
2495
|
-
if (isPolling) return;
|
|
2496
|
-
isPolling
|
|
2497
|
-
pollingStatus = 'fetching';
|
|
1586
|
+
if (store.get('isPolling')) return;
|
|
1587
|
+
store.setState({ isPolling: true, pollingStatus: 'fetching' });
|
|
2498
1588
|
|
|
2499
1589
|
// Casino mode: start slot reels spinning (no sound - too annoying)
|
|
2500
|
-
if (casinoModeEnabled) {
|
|
1590
|
+
if (store.get('casinoModeEnabled')) {
|
|
2501
1591
|
casino.startSlotReels(render);
|
|
2502
1592
|
}
|
|
2503
1593
|
|
|
@@ -2507,25 +1597,28 @@ async function pollGitChanges() {
|
|
|
2507
1597
|
|
|
2508
1598
|
try {
|
|
2509
1599
|
const newCurrentBranch = await getCurrentBranch();
|
|
1600
|
+
const prevCurrentBranch = store.get('currentBranch');
|
|
2510
1601
|
|
|
2511
|
-
if (
|
|
2512
|
-
addLog(`Branch switched externally: ${
|
|
1602
|
+
if (prevCurrentBranch && newCurrentBranch !== prevCurrentBranch) {
|
|
1603
|
+
addLog(`Branch switched externally: ${prevCurrentBranch} → ${newCurrentBranch}`, 'warning');
|
|
2513
1604
|
notifyClients();
|
|
2514
1605
|
}
|
|
2515
|
-
currentBranch
|
|
1606
|
+
store.setState({ currentBranch: newCurrentBranch });
|
|
2516
1607
|
|
|
2517
1608
|
const allBranches = await getAllBranches();
|
|
2518
1609
|
|
|
2519
1610
|
// Track fetch duration
|
|
2520
|
-
lastFetchDuration = Date.now() - fetchStartTime;
|
|
1611
|
+
const lastFetchDuration = Date.now() - fetchStartTime;
|
|
1612
|
+
store.setState({ lastFetchDuration });
|
|
2521
1613
|
|
|
2522
1614
|
// Check for slow fetches
|
|
2523
1615
|
if (lastFetchDuration > 30000 && !verySlowFetchWarningShown) {
|
|
2524
1616
|
addLog(`⚠ Fetches taking ${Math.round(lastFetchDuration / 1000)}s - network may be slow`, 'warning');
|
|
2525
1617
|
verySlowFetchWarningShown = true;
|
|
2526
1618
|
// Slow down polling
|
|
2527
|
-
|
|
2528
|
-
|
|
1619
|
+
const newInterval = Math.min(store.get('adaptivePollInterval') * 2, 60000);
|
|
1620
|
+
store.setState({ adaptivePollInterval: newInterval });
|
|
1621
|
+
addLog(`Polling interval increased to ${newInterval / 1000}s`, 'info');
|
|
2529
1622
|
restartPolling();
|
|
2530
1623
|
} else if (lastFetchDuration > 15000 && !slowFetchWarningShown) {
|
|
2531
1624
|
addLog(`Fetches taking ${Math.round(lastFetchDuration / 1000)}s`, 'warning');
|
|
@@ -2534,21 +1627,22 @@ async function pollGitChanges() {
|
|
|
2534
1627
|
// Reset warnings if fetches are fast again
|
|
2535
1628
|
slowFetchWarningShown = false;
|
|
2536
1629
|
verySlowFetchWarningShown = false;
|
|
2537
|
-
if (adaptivePollInterval > GIT_POLL_INTERVAL) {
|
|
2538
|
-
adaptivePollInterval
|
|
2539
|
-
addLog(`Polling interval restored to ${
|
|
1630
|
+
if (store.get('adaptivePollInterval') > GIT_POLL_INTERVAL) {
|
|
1631
|
+
store.setState({ adaptivePollInterval: GIT_POLL_INTERVAL });
|
|
1632
|
+
addLog(`Polling interval restored to ${GIT_POLL_INTERVAL / 1000}s`, 'info');
|
|
2540
1633
|
restartPolling();
|
|
2541
1634
|
}
|
|
2542
1635
|
}
|
|
2543
1636
|
|
|
2544
1637
|
// Network success - reset failure counter
|
|
2545
|
-
|
|
2546
|
-
if (isOffline) {
|
|
2547
|
-
isOffline = false;
|
|
1638
|
+
if (store.get('isOffline')) {
|
|
2548
1639
|
addLog('Connection restored', 'success');
|
|
2549
1640
|
}
|
|
1641
|
+
store.setState({ consecutiveNetworkFailures: 0, isOffline: false });
|
|
1642
|
+
|
|
2550
1643
|
const fetchedBranchNames = new Set(allBranches.map(b => b.name));
|
|
2551
1644
|
const now = Date.now();
|
|
1645
|
+
const currentBranches = store.get('branches');
|
|
2552
1646
|
|
|
2553
1647
|
// Detect NEW branches (not seen before)
|
|
2554
1648
|
const newBranchList = [];
|
|
@@ -2560,7 +1654,7 @@ async function pollGitChanges() {
|
|
|
2560
1654
|
newBranchList.push(branch);
|
|
2561
1655
|
} else {
|
|
2562
1656
|
// Preserve isNew flag from previous poll cycle for branches not yet switched to
|
|
2563
|
-
const prevBranch =
|
|
1657
|
+
const prevBranch = currentBranches.find(b => b.name === branch.name);
|
|
2564
1658
|
if (prevBranch && prevBranch.isNew) {
|
|
2565
1659
|
branch.isNew = true;
|
|
2566
1660
|
branch.newAt = prevBranch.newAt;
|
|
@@ -2573,7 +1667,7 @@ async function pollGitChanges() {
|
|
|
2573
1667
|
for (const knownName of knownBranchNames) {
|
|
2574
1668
|
if (!fetchedBranchNames.has(knownName)) {
|
|
2575
1669
|
// This branch was deleted from remote
|
|
2576
|
-
const existingInList =
|
|
1670
|
+
const existingInList = currentBranches.find(b => b.name === knownName);
|
|
2577
1671
|
if (existingInList && !existingInList.isDeleted) {
|
|
2578
1672
|
existingInList.isDeleted = true;
|
|
2579
1673
|
existingInList.deletedAt = now;
|
|
@@ -2588,14 +1682,15 @@ async function pollGitChanges() {
|
|
|
2588
1682
|
// Note: isNew flag is only cleared when branch becomes current (see below)
|
|
2589
1683
|
|
|
2590
1684
|
// Keep deleted branches in the list (don't remove them)
|
|
2591
|
-
const
|
|
1685
|
+
const pollFilteredBranches = allBranches;
|
|
2592
1686
|
|
|
2593
1687
|
// Detect updates on other branches (for flash notification)
|
|
2594
1688
|
const updatedBranches = [];
|
|
2595
|
-
|
|
1689
|
+
const currentBranchName = store.get('currentBranch');
|
|
1690
|
+
for (const branch of pollFilteredBranches) {
|
|
2596
1691
|
if (branch.isDeleted) continue;
|
|
2597
1692
|
const prevCommit = previousBranchStates.get(branch.name);
|
|
2598
|
-
if (prevCommit && prevCommit !== branch.commit && branch.name !==
|
|
1693
|
+
if (prevCommit && prevCommit !== branch.commit && branch.name !== currentBranchName) {
|
|
2599
1694
|
updatedBranches.push(branch);
|
|
2600
1695
|
branch.justUpdated = true;
|
|
2601
1696
|
}
|
|
@@ -2603,6 +1698,7 @@ async function pollGitChanges() {
|
|
|
2603
1698
|
}
|
|
2604
1699
|
|
|
2605
1700
|
// Flash and sound for updates or new branches
|
|
1701
|
+
const casinoOn = store.get('casinoModeEnabled');
|
|
2606
1702
|
const notifyBranches = [...updatedBranches, ...newBranchList];
|
|
2607
1703
|
if (notifyBranches.length > 0) {
|
|
2608
1704
|
for (const branch of updatedBranches) {
|
|
@@ -2610,7 +1706,7 @@ async function pollGitChanges() {
|
|
|
2610
1706
|
}
|
|
2611
1707
|
|
|
2612
1708
|
// Casino mode: add funny commentary
|
|
2613
|
-
if (
|
|
1709
|
+
if (casinoOn) {
|
|
2614
1710
|
addLog(`🎰 ${getCasinoMessage('win')}`, 'success');
|
|
2615
1711
|
}
|
|
2616
1712
|
|
|
@@ -2619,7 +1715,7 @@ async function pollGitChanges() {
|
|
|
2619
1715
|
playSound();
|
|
2620
1716
|
|
|
2621
1717
|
// Casino mode: trigger win effect based on number of updated branches
|
|
2622
|
-
if (
|
|
1718
|
+
if (casinoOn) {
|
|
2623
1719
|
// Estimate line changes: more branches = bigger "win"
|
|
2624
1720
|
// Each branch update counts as ~100 lines (placeholder until we calculate actual diff)
|
|
2625
1721
|
const estimatedLines = notifyBranches.length * 100;
|
|
@@ -2631,66 +1727,92 @@ async function pollGitChanges() {
|
|
|
2631
1727
|
}
|
|
2632
1728
|
casino.recordPoll(true);
|
|
2633
1729
|
}
|
|
2634
|
-
} else if (
|
|
1730
|
+
} else if (casinoOn) {
|
|
2635
1731
|
// No updates - stop reels and show result briefly
|
|
2636
1732
|
casino.stopSlotReels(false, render);
|
|
2637
1733
|
casino.recordPoll(false);
|
|
2638
1734
|
}
|
|
2639
1735
|
|
|
2640
1736
|
// Remember which branch was selected before updating the list
|
|
2641
|
-
const
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
1737
|
+
const { selectedBranchName: prevSelName, selectedIndex: prevSelIdx } = store.getState();
|
|
1738
|
+
const previouslySelectedName = prevSelName || (currentBranches[prevSelIdx] ? currentBranches[prevSelIdx].name : null);
|
|
1739
|
+
|
|
1740
|
+
// Sort: new branches first, then by date, merged branches near bottom, deleted at bottom
|
|
1741
|
+
const prStatusMap = store.get('branchPrStatusMap');
|
|
1742
|
+
pollFilteredBranches.sort((a, b) => {
|
|
1743
|
+
const aIsBase = isBaseBranch(a.name);
|
|
1744
|
+
const bIsBase = isBaseBranch(b.name);
|
|
1745
|
+
const aMerged = !aIsBase && prStatusMap.has(a.name) && prStatusMap.get(a.name).state === 'MERGED';
|
|
1746
|
+
const bMerged = !bIsBase && prStatusMap.has(b.name) && prStatusMap.get(b.name).state === 'MERGED';
|
|
2645
1747
|
if (a.isDeleted && !b.isDeleted) return 1;
|
|
2646
1748
|
if (!a.isDeleted && b.isDeleted) return -1;
|
|
1749
|
+
if (aMerged && !bMerged && !b.isDeleted) return 1;
|
|
1750
|
+
if (!aMerged && bMerged && !a.isDeleted) return -1;
|
|
2647
1751
|
if (a.isNew && !b.isNew) return -1;
|
|
2648
1752
|
if (!a.isNew && b.isNew) return 1;
|
|
2649
1753
|
return b.date - a.date;
|
|
2650
1754
|
});
|
|
2651
1755
|
|
|
2652
1756
|
// Store all branches (no limit) - visibleBranchCount controls display
|
|
2653
|
-
branches = filteredBranches;
|
|
2654
|
-
|
|
2655
1757
|
// Restore selection to the same branch (by name) after reordering
|
|
1758
|
+
let newSelectedIndex = prevSelIdx;
|
|
1759
|
+
let newSelectedName = prevSelName;
|
|
2656
1760
|
if (previouslySelectedName) {
|
|
2657
|
-
const
|
|
2658
|
-
if (
|
|
2659
|
-
|
|
2660
|
-
|
|
1761
|
+
const foundIdx = pollFilteredBranches.findIndex(b => b.name === previouslySelectedName);
|
|
1762
|
+
if (foundIdx >= 0) {
|
|
1763
|
+
newSelectedIndex = foundIdx;
|
|
1764
|
+
newSelectedName = previouslySelectedName;
|
|
2661
1765
|
} else {
|
|
2662
1766
|
// Branch fell off the list, keep index at bottom or clamp
|
|
2663
|
-
|
|
2664
|
-
|
|
1767
|
+
newSelectedIndex = Math.min(prevSelIdx, Math.max(0, pollFilteredBranches.length - 1));
|
|
1768
|
+
newSelectedName = pollFilteredBranches[newSelectedIndex] ? pollFilteredBranches[newSelectedIndex].name : null;
|
|
2665
1769
|
}
|
|
2666
|
-
} else if (
|
|
2667
|
-
|
|
2668
|
-
|
|
1770
|
+
} else if (prevSelIdx >= pollFilteredBranches.length) {
|
|
1771
|
+
newSelectedIndex = Math.max(0, pollFilteredBranches.length - 1);
|
|
1772
|
+
newSelectedName = pollFilteredBranches[newSelectedIndex] ? pollFilteredBranches[newSelectedIndex].name : null;
|
|
1773
|
+
}
|
|
1774
|
+
store.setState({ branches: pollFilteredBranches, selectedIndex: newSelectedIndex, selectedBranchName: newSelectedName });
|
|
1775
|
+
|
|
1776
|
+
// Background PR status fetch (throttled to every PR_STATUS_POLL_INTERVAL)
|
|
1777
|
+
const now2 = Date.now();
|
|
1778
|
+
if (!prStatusFetchInFlight && cachedEnv && (now2 - lastPrStatusFetch > PR_STATUS_POLL_INTERVAL)) {
|
|
1779
|
+
prStatusFetchInFlight = true;
|
|
1780
|
+
fetchAllPrStatuses().then(map => {
|
|
1781
|
+
if (map) {
|
|
1782
|
+
store.setState({ branchPrStatusMap: map });
|
|
1783
|
+
render(); // re-render to show updated PR indicators
|
|
1784
|
+
}
|
|
1785
|
+
lastPrStatusFetch = Date.now();
|
|
1786
|
+
prStatusFetchInFlight = false;
|
|
1787
|
+
}).catch(() => {
|
|
1788
|
+
prStatusFetchInFlight = false;
|
|
1789
|
+
});
|
|
2669
1790
|
}
|
|
2670
1791
|
|
|
2671
1792
|
// AUTO-PULL: If current branch has remote updates, pull automatically (if enabled)
|
|
2672
|
-
const
|
|
2673
|
-
|
|
2674
|
-
|
|
1793
|
+
const autoPullBranchName = store.get('currentBranch');
|
|
1794
|
+
const currentInfo = store.get('branches').find(b => b.name === autoPullBranchName);
|
|
1795
|
+
if (AUTO_PULL && currentInfo && currentInfo.hasUpdates && !store.get('hasMergeConflict')) {
|
|
1796
|
+
addLog(`Auto-pulling changes for ${autoPullBranchName}...`, 'update');
|
|
2675
1797
|
render();
|
|
2676
1798
|
|
|
2677
1799
|
// Save the old commit for diff calculation (casino mode)
|
|
2678
1800
|
const oldCommit = currentInfo.commit;
|
|
2679
1801
|
|
|
2680
1802
|
try {
|
|
2681
|
-
await execAsync(`git pull "${REMOTE_NAME}" "${
|
|
2682
|
-
addLog(`Pulled successfully from ${
|
|
1803
|
+
await execAsync(`git pull "${REMOTE_NAME}" "${autoPullBranchName}"`);
|
|
1804
|
+
addLog(`Pulled successfully from ${autoPullBranchName}`, 'success');
|
|
2683
1805
|
currentInfo.hasUpdates = false;
|
|
2684
|
-
hasMergeConflict
|
|
1806
|
+
store.setState({ hasMergeConflict: false });
|
|
2685
1807
|
// Update the stored commit to the new one
|
|
2686
1808
|
const newCommit = await execAsync('git rev-parse --short HEAD');
|
|
2687
1809
|
currentInfo.commit = newCommit.stdout.trim();
|
|
2688
|
-
previousBranchStates.set(
|
|
1810
|
+
previousBranchStates.set(autoPullBranchName, newCommit.stdout.trim());
|
|
2689
1811
|
// Reload browsers
|
|
2690
1812
|
notifyClients();
|
|
2691
1813
|
|
|
2692
1814
|
// Casino mode: calculate actual diff and trigger win effect
|
|
2693
|
-
if (casinoModeEnabled && oldCommit) {
|
|
1815
|
+
if (store.get('casinoModeEnabled') && oldCommit) {
|
|
2694
1816
|
const diffStats = await getDiffStats(oldCommit, 'HEAD');
|
|
2695
1817
|
const totalLines = diffStats.added + diffStats.deleted;
|
|
2696
1818
|
if (totalLines > 0) {
|
|
@@ -2705,7 +1827,7 @@ async function pollGitChanges() {
|
|
|
2705
1827
|
} catch (e) {
|
|
2706
1828
|
const errMsg = e.stderr || e.stdout || e.message || String(e);
|
|
2707
1829
|
if (isMergeConflict(errMsg)) {
|
|
2708
|
-
hasMergeConflict
|
|
1830
|
+
store.setState({ hasMergeConflict: true });
|
|
2709
1831
|
addLog(`MERGE CONFLICT detected!`, 'error');
|
|
2710
1832
|
addLog(`Resolve conflicts manually, then commit`, 'warning');
|
|
2711
1833
|
showErrorToast(
|
|
@@ -2714,7 +1836,7 @@ async function pollGitChanges() {
|
|
|
2714
1836
|
'Run: git status to see conflicts'
|
|
2715
1837
|
);
|
|
2716
1838
|
// Casino mode: trigger loss effect
|
|
2717
|
-
if (casinoModeEnabled) {
|
|
1839
|
+
if (store.get('casinoModeEnabled')) {
|
|
2718
1840
|
casino.triggerLoss('MERGE CONFLICT!', render);
|
|
2719
1841
|
casinoSounds.playLoss();
|
|
2720
1842
|
addLog(`💀 ${getCasinoMessage('loss')}`, 'error');
|
|
@@ -2738,16 +1860,16 @@ async function pollGitChanges() {
|
|
|
2738
1860
|
}
|
|
2739
1861
|
}
|
|
2740
1862
|
|
|
2741
|
-
pollingStatus
|
|
1863
|
+
store.setState({ pollingStatus: 'idle' });
|
|
2742
1864
|
// Casino mode: stop slot reels if still spinning (already handled above, just cleanup)
|
|
2743
|
-
if (casinoModeEnabled && casino.isSlotSpinning()) {
|
|
1865
|
+
if (store.get('casinoModeEnabled') && casino.isSlotSpinning()) {
|
|
2744
1866
|
casino.stopSlotReels(false, render);
|
|
2745
1867
|
}
|
|
2746
1868
|
} catch (err) {
|
|
2747
1869
|
const errMsg = err.stderr || err.message || String(err);
|
|
2748
1870
|
|
|
2749
1871
|
// Casino mode: stop slot reels and show loss on error
|
|
2750
|
-
if (casinoModeEnabled) {
|
|
1872
|
+
if (store.get('casinoModeEnabled')) {
|
|
2751
1873
|
casino.stopSlotReels(false, render);
|
|
2752
1874
|
casino.triggerLoss('BUST!', render);
|
|
2753
1875
|
casinoSounds.playLoss();
|
|
@@ -2755,17 +1877,18 @@ async function pollGitChanges() {
|
|
|
2755
1877
|
|
|
2756
1878
|
// Handle different error types
|
|
2757
1879
|
if (isNetworkError(errMsg)) {
|
|
2758
|
-
consecutiveNetworkFailures
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
1880
|
+
const failures = store.get('consecutiveNetworkFailures') + 1;
|
|
1881
|
+
store.setState({ consecutiveNetworkFailures: failures });
|
|
1882
|
+
if (failures >= 3 && !store.get('isOffline')) {
|
|
1883
|
+
store.setState({ isOffline: true });
|
|
1884
|
+
addLog(`Network unavailable (${failures} failures)`, 'error');
|
|
2762
1885
|
showErrorToast(
|
|
2763
1886
|
'Network Unavailable',
|
|
2764
1887
|
'Cannot connect to the remote repository. Git operations will fail until connection is restored.',
|
|
2765
1888
|
'Check your internet connection'
|
|
2766
1889
|
);
|
|
2767
1890
|
}
|
|
2768
|
-
pollingStatus
|
|
1891
|
+
store.setState({ pollingStatus: 'error' });
|
|
2769
1892
|
} else if (isAuthError(errMsg)) {
|
|
2770
1893
|
addLog(`Authentication error - check credentials`, 'error');
|
|
2771
1894
|
addLog(`Try: git config credential.helper store`, 'warning');
|
|
@@ -2774,13 +1897,13 @@ async function pollGitChanges() {
|
|
|
2774
1897
|
'Failed to authenticate with the remote repository.',
|
|
2775
1898
|
'Run: git config credential.helper store'
|
|
2776
1899
|
);
|
|
2777
|
-
pollingStatus
|
|
1900
|
+
store.setState({ pollingStatus: 'error' });
|
|
2778
1901
|
} else {
|
|
2779
|
-
pollingStatus
|
|
1902
|
+
store.setState({ pollingStatus: 'error' });
|
|
2780
1903
|
addLog(`Polling error: ${errMsg}`, 'error');
|
|
2781
1904
|
}
|
|
2782
1905
|
} finally {
|
|
2783
|
-
isPolling
|
|
1906
|
+
store.setState({ isPolling: false });
|
|
2784
1907
|
render();
|
|
2785
1908
|
}
|
|
2786
1909
|
}
|
|
@@ -2789,7 +1912,7 @@ function restartPolling() {
|
|
|
2789
1912
|
if (pollIntervalId) {
|
|
2790
1913
|
clearInterval(pollIntervalId);
|
|
2791
1914
|
}
|
|
2792
|
-
pollIntervalId = setInterval(pollGitChanges, adaptivePollInterval);
|
|
1915
|
+
pollIntervalId = setInterval(pollGitChanges, store.get('adaptivePollInterval'));
|
|
2793
1916
|
}
|
|
2794
1917
|
|
|
2795
1918
|
// ============================================================================
|
|
@@ -2926,17 +2049,18 @@ function setupFileWatcher() {
|
|
|
2926
2049
|
// Keyboard Input
|
|
2927
2050
|
// ============================================================================
|
|
2928
2051
|
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2052
|
+
// applySearchFilter — replaced by filterBranches import (src/ui/renderer.js)
|
|
2053
|
+
|
|
2054
|
+
// Apply state updates from action handlers to store
|
|
2055
|
+
function applyUpdates(updates) {
|
|
2056
|
+
if (!updates) return false;
|
|
2057
|
+
store.setState(updates);
|
|
2058
|
+
return true;
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
// Build current state snapshot for action handlers
|
|
2062
|
+
function getActionState() {
|
|
2063
|
+
return store.getState();
|
|
2940
2064
|
}
|
|
2941
2065
|
|
|
2942
2066
|
function setupKeyboardInput() {
|
|
@@ -2947,25 +2071,11 @@ function setupKeyboardInput() {
|
|
|
2947
2071
|
process.stdin.setEncoding('utf8');
|
|
2948
2072
|
|
|
2949
2073
|
process.stdin.on('data', async (key) => {
|
|
2950
|
-
// Handle search mode input
|
|
2951
|
-
if (searchMode) {
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
// Escape clears search
|
|
2956
|
-
searchQuery = '';
|
|
2957
|
-
filteredBranches = null;
|
|
2958
|
-
}
|
|
2959
|
-
render();
|
|
2960
|
-
return;
|
|
2961
|
-
} else if (key === '\u007f' || key === '\b') { // Backspace
|
|
2962
|
-
searchQuery = searchQuery.slice(0, -1);
|
|
2963
|
-
applySearchFilter();
|
|
2964
|
-
render();
|
|
2965
|
-
return;
|
|
2966
|
-
} else if (key.length === 1 && key >= ' ' && key <= '~') { // Printable chars
|
|
2967
|
-
searchQuery += key;
|
|
2968
|
-
applySearchFilter();
|
|
2074
|
+
// Handle search mode input via actions module
|
|
2075
|
+
if (store.get('searchMode')) {
|
|
2076
|
+
const searchResult = actions.handleSearchInput(getActionState(), key);
|
|
2077
|
+
if (searchResult) {
|
|
2078
|
+
applyUpdates(searchResult);
|
|
2969
2079
|
render();
|
|
2970
2080
|
return;
|
|
2971
2081
|
}
|
|
@@ -2976,24 +2086,23 @@ function setupKeyboardInput() {
|
|
|
2976
2086
|
}
|
|
2977
2087
|
|
|
2978
2088
|
// Handle modal modes
|
|
2979
|
-
if (previewMode) {
|
|
2089
|
+
if (store.get('previewMode')) {
|
|
2980
2090
|
if (key === 'v' || key === '\u001b' || key === '\r' || key === '\n') {
|
|
2981
|
-
|
|
2982
|
-
previewData = null;
|
|
2091
|
+
applyUpdates(actions.togglePreview(getActionState()));
|
|
2983
2092
|
render();
|
|
2984
2093
|
return;
|
|
2985
2094
|
}
|
|
2986
2095
|
return; // Ignore other keys in preview mode
|
|
2987
2096
|
}
|
|
2988
2097
|
|
|
2989
|
-
if (historyMode) {
|
|
2098
|
+
if (store.get('historyMode')) {
|
|
2990
2099
|
if (key === 'h' || key === '\u001b') {
|
|
2991
|
-
|
|
2100
|
+
applyUpdates(actions.toggleHistory(getActionState()));
|
|
2992
2101
|
render();
|
|
2993
2102
|
return;
|
|
2994
2103
|
}
|
|
2995
2104
|
if (key === 'u') {
|
|
2996
|
-
historyMode
|
|
2105
|
+
store.setState({ historyMode: false });
|
|
2997
2106
|
await undoLastSwitch();
|
|
2998
2107
|
await pollGitChanges();
|
|
2999
2108
|
return;
|
|
@@ -3001,44 +2110,38 @@ function setupKeyboardInput() {
|
|
|
3001
2110
|
return; // Ignore other keys in history mode
|
|
3002
2111
|
}
|
|
3003
2112
|
|
|
3004
|
-
if (infoMode) {
|
|
2113
|
+
if (store.get('infoMode')) {
|
|
3005
2114
|
if (key === 'i' || key === '\u001b') {
|
|
3006
|
-
|
|
2115
|
+
applyUpdates(actions.toggleInfo(getActionState()));
|
|
3007
2116
|
render();
|
|
3008
2117
|
return;
|
|
3009
2118
|
}
|
|
3010
2119
|
return; // Ignore other keys in info mode
|
|
3011
2120
|
}
|
|
3012
2121
|
|
|
3013
|
-
if (logViewMode) {
|
|
2122
|
+
if (store.get('logViewMode')) {
|
|
3014
2123
|
if (key === 'l' || key === '\u001b') {
|
|
3015
|
-
|
|
3016
|
-
logScrollOffset = 0;
|
|
2124
|
+
applyUpdates(actions.toggleLogView(getActionState()));
|
|
3017
2125
|
render();
|
|
3018
2126
|
return;
|
|
3019
2127
|
}
|
|
3020
2128
|
if (key === '1') { // Switch to activity tab
|
|
3021
|
-
|
|
3022
|
-
logScrollOffset = 0;
|
|
2129
|
+
applyUpdates(actions.switchLogTab(getActionState(), 'activity'));
|
|
3023
2130
|
render();
|
|
3024
2131
|
return;
|
|
3025
2132
|
}
|
|
3026
2133
|
if (key === '2') { // Switch to server tab
|
|
3027
|
-
|
|
3028
|
-
logScrollOffset = 0;
|
|
2134
|
+
applyUpdates(actions.switchLogTab(getActionState(), 'server'));
|
|
3029
2135
|
render();
|
|
3030
2136
|
return;
|
|
3031
2137
|
}
|
|
3032
|
-
// Get current log data for scroll bounds
|
|
3033
|
-
const currentLogData = logViewTab === 'server' ? serverLogBuffer : activityLog;
|
|
3034
|
-
const maxScroll = Math.max(0, currentLogData.length - 10);
|
|
3035
2138
|
if (key === '\u001b[A' || key === 'k') { // Up - scroll
|
|
3036
|
-
|
|
2139
|
+
applyUpdates(actions.scrollLog(getActionState(), 'up'));
|
|
3037
2140
|
render();
|
|
3038
2141
|
return;
|
|
3039
2142
|
}
|
|
3040
2143
|
if (key === '\u001b[B' || key === 'j') { // Down - scroll
|
|
3041
|
-
|
|
2144
|
+
applyUpdates(actions.scrollLog(getActionState(), 'down'));
|
|
3042
2145
|
render();
|
|
3043
2146
|
return;
|
|
3044
2147
|
}
|
|
@@ -3050,55 +2153,212 @@ function setupKeyboardInput() {
|
|
|
3050
2153
|
return; // Ignore other keys in log view mode
|
|
3051
2154
|
}
|
|
3052
2155
|
|
|
2156
|
+
if (store.get('actionMode')) {
|
|
2157
|
+
if (key === '\u001b') { // Escape to close
|
|
2158
|
+
applyUpdates(actions.closeActionModal(getActionState()));
|
|
2159
|
+
render();
|
|
2160
|
+
return;
|
|
2161
|
+
}
|
|
2162
|
+
const currentActionData = store.get('actionData');
|
|
2163
|
+
if (!currentActionData) return;
|
|
2164
|
+
const { branch: aBranch, sessionUrl, prInfo, hasGh, hasGlab, ghAuthed, glabAuthed, webUrl, platform, prLoaded } = currentActionData;
|
|
2165
|
+
const cliReady = (platform === 'gitlab') ? (hasGlab && glabAuthed) : (hasGh && ghAuthed);
|
|
2166
|
+
const prLabel = platform === 'gitlab' ? 'MR' : 'PR';
|
|
2167
|
+
|
|
2168
|
+
// Helper to extract the base repo URL from a branch-specific URL
|
|
2169
|
+
const repoUrl = webUrl ? webUrl.replace(/\/tree\/.*$/, '') : null;
|
|
2170
|
+
|
|
2171
|
+
if (key === 'b' && webUrl) { // Open branch on web host
|
|
2172
|
+
addLog(`Opening ${webUrl}`, 'info');
|
|
2173
|
+
openInBrowser(webUrl);
|
|
2174
|
+
render();
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
if (key === 'c' && sessionUrl) { // Open Claude session
|
|
2178
|
+
addLog(`Opening Claude session...`, 'info');
|
|
2179
|
+
openInBrowser(sessionUrl);
|
|
2180
|
+
render();
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
if (key === 'p') { // Create or view PR
|
|
2184
|
+
if (prInfo && repoUrl) {
|
|
2185
|
+
// View existing PR on web
|
|
2186
|
+
const prUrl = platform === 'gitlab'
|
|
2187
|
+
? `${repoUrl}/-/merge_requests/${prInfo.number}`
|
|
2188
|
+
: `${repoUrl}/pull/${prInfo.number}`;
|
|
2189
|
+
addLog(`Opening ${prLabel} #${prInfo.number}...`, 'info');
|
|
2190
|
+
openInBrowser(prUrl);
|
|
2191
|
+
} else if (!prInfo && prLoaded && cliReady) {
|
|
2192
|
+
// Create PR — only if we've confirmed no PR exists (prLoaded=true)
|
|
2193
|
+
addLog(`Creating ${prLabel} for ${aBranch.name}...`, 'update');
|
|
2194
|
+
render();
|
|
2195
|
+
try {
|
|
2196
|
+
let result;
|
|
2197
|
+
if (platform === 'gitlab') {
|
|
2198
|
+
result = await execAsync(`glab mr create --source-branch="${aBranch.name}" --fill --yes 2>&1`);
|
|
2199
|
+
} else {
|
|
2200
|
+
result = await execAsync(`gh pr create --head "${aBranch.name}" --fill 2>&1`);
|
|
2201
|
+
}
|
|
2202
|
+
addLog(`${prLabel} created: ${(result.stdout || '').trim().split('\n').pop()}`, 'success');
|
|
2203
|
+
// Invalidate cache and refresh modal data
|
|
2204
|
+
prInfoCache.delete(aBranch.name);
|
|
2205
|
+
const refreshedData = gatherLocalActionData(aBranch);
|
|
2206
|
+
store.setState({ actionData: refreshedData, actionLoading: true });
|
|
2207
|
+
render();
|
|
2208
|
+
loadAsyncActionData(aBranch, refreshedData).then((fullData) => {
|
|
2209
|
+
if (store.get('actionMode') && store.get('actionData') && store.get('actionData').branch.name === aBranch.name) {
|
|
2210
|
+
store.setState({ actionData: fullData, actionLoading: false });
|
|
2211
|
+
render();
|
|
2212
|
+
}
|
|
2213
|
+
}).catch(() => {});
|
|
2214
|
+
} catch (e) {
|
|
2215
|
+
const msg = (e && e.stderr) || (e && e.message) || String(e);
|
|
2216
|
+
addLog(`Failed to create ${prLabel}: ${msg.split('\n')[0]}`, 'error');
|
|
2217
|
+
}
|
|
2218
|
+
} else if (!prLoaded) {
|
|
2219
|
+
addLog(`Still loading ${prLabel} info...`, 'info');
|
|
2220
|
+
}
|
|
2221
|
+
render();
|
|
2222
|
+
return;
|
|
2223
|
+
}
|
|
2224
|
+
if (key === 'd' && prInfo && repoUrl) { // View diff on web
|
|
2225
|
+
const diffUrl = platform === 'gitlab'
|
|
2226
|
+
? `${repoUrl}/-/merge_requests/${prInfo.number}/diffs`
|
|
2227
|
+
: `${repoUrl}/pull/${prInfo.number}/files`;
|
|
2228
|
+
addLog(`Opening ${prLabel} #${prInfo.number} diff...`, 'info');
|
|
2229
|
+
openInBrowser(diffUrl);
|
|
2230
|
+
render();
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
if (key === 'a' && prInfo && cliReady) { // Approve PR
|
|
2234
|
+
addLog(`Approving ${prLabel} #${prInfo.number}...`, 'update');
|
|
2235
|
+
render();
|
|
2236
|
+
try {
|
|
2237
|
+
if (platform === 'gitlab') {
|
|
2238
|
+
await execAsync(`glab mr approve ${prInfo.number} 2>&1`);
|
|
2239
|
+
} else {
|
|
2240
|
+
await execAsync(`gh pr review ${prInfo.number} --approve 2>&1`);
|
|
2241
|
+
}
|
|
2242
|
+
addLog(`${prLabel} #${prInfo.number} approved`, 'success');
|
|
2243
|
+
// Refresh PR info to show updated status
|
|
2244
|
+
prInfoCache.delete(aBranch.name);
|
|
2245
|
+
} catch (e) {
|
|
2246
|
+
const msg = (e && e.stderr) || (e && e.message) || String(e);
|
|
2247
|
+
addLog(`Failed to approve: ${msg.split('\n')[0]}`, 'error');
|
|
2248
|
+
}
|
|
2249
|
+
render();
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
if (key === 'm' && prInfo && cliReady) { // Merge PR
|
|
2253
|
+
addLog(`Merging ${prLabel} #${prInfo.number}...`, 'update');
|
|
2254
|
+
render();
|
|
2255
|
+
try {
|
|
2256
|
+
if (platform === 'gitlab') {
|
|
2257
|
+
await execAsync(`glab mr merge ${prInfo.number} --squash --remove-source-branch --yes 2>&1`);
|
|
2258
|
+
} else {
|
|
2259
|
+
await execAsync(`gh pr merge ${prInfo.number} --squash --delete-branch 2>&1`);
|
|
2260
|
+
}
|
|
2261
|
+
addLog(`${prLabel} #${prInfo.number} merged`, 'success');
|
|
2262
|
+
store.setState({ actionMode: false, actionData: null, actionLoading: false });
|
|
2263
|
+
prInfoCache.delete(aBranch.name);
|
|
2264
|
+
// Force-refresh bulk PR statuses so inline indicators update immediately
|
|
2265
|
+
lastPrStatusFetch = 0;
|
|
2266
|
+
await pollGitChanges();
|
|
2267
|
+
} catch (e) {
|
|
2268
|
+
const msg = (e && e.stderr) || (e && e.message) || String(e);
|
|
2269
|
+
addLog(`Failed to merge: ${msg.split('\n')[0]}`, 'error');
|
|
2270
|
+
}
|
|
2271
|
+
render();
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if (key === 'i' && cliReady) { // CI status
|
|
2275
|
+
addLog(`Checking CI for ${aBranch.name}...`, 'info');
|
|
2276
|
+
render();
|
|
2277
|
+
try {
|
|
2278
|
+
if (platform === 'gitlab') {
|
|
2279
|
+
const result = await execAsync(`glab ci status --branch "${aBranch.name}" 2>&1`);
|
|
2280
|
+
const lines = (result.stdout || '').trim().split('\n');
|
|
2281
|
+
for (const line of lines.slice(0, 3)) {
|
|
2282
|
+
addLog(line.trim(), 'info');
|
|
2283
|
+
}
|
|
2284
|
+
} else if (prInfo) {
|
|
2285
|
+
const result = await execAsync(`gh pr checks ${prInfo.number} 2>&1`);
|
|
2286
|
+
const lines = (result.stdout || '').trim().split('\n');
|
|
2287
|
+
for (const line of lines.slice(0, 5)) {
|
|
2288
|
+
addLog(line.trim(), 'info');
|
|
2289
|
+
}
|
|
2290
|
+
} else {
|
|
2291
|
+
addLog(`No open ${prLabel} — CI status requires an open ${prLabel} on GitHub`, 'info');
|
|
2292
|
+
}
|
|
2293
|
+
} catch (e) {
|
|
2294
|
+
// gh pr checks exits non-zero when checks fail — stdout still has useful info
|
|
2295
|
+
const output = (e && e.stdout) || '';
|
|
2296
|
+
if (output.trim()) {
|
|
2297
|
+
const lines = output.trim().split('\n');
|
|
2298
|
+
for (const line of lines.slice(0, 5)) {
|
|
2299
|
+
addLog(line.trim(), 'info');
|
|
2300
|
+
}
|
|
2301
|
+
} else {
|
|
2302
|
+
const msg = (e && e.stderr) || (e && e.message) || String(e);
|
|
2303
|
+
addLog(`CI check failed: ${msg.split('\n')[0]}`, 'error');
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
render();
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
return; // Ignore other keys in action mode
|
|
2310
|
+
}
|
|
2311
|
+
|
|
3053
2312
|
// Dismiss flash on any key
|
|
3054
|
-
if (flashMessage) {
|
|
2313
|
+
if (store.get('flashMessage')) {
|
|
3055
2314
|
hideFlash();
|
|
3056
2315
|
if (key !== '\u001b[A' && key !== '\u001b[B' && key !== '\r' && key !== 'q') {
|
|
3057
2316
|
return;
|
|
3058
2317
|
}
|
|
3059
2318
|
}
|
|
3060
2319
|
|
|
3061
|
-
// Dismiss error toast on any key
|
|
3062
|
-
if (errorToast) {
|
|
2320
|
+
// Dismiss error toast on any key (S triggers stash if pending)
|
|
2321
|
+
if (store.get('errorToast')) {
|
|
2322
|
+
if (key === 'S' && pendingDirtyOperation) {
|
|
2323
|
+
await stashAndRetry();
|
|
2324
|
+
return;
|
|
2325
|
+
}
|
|
3063
2326
|
hideErrorToast();
|
|
2327
|
+
pendingDirtyOperation = null;
|
|
3064
2328
|
if (key !== '\u001b[A' && key !== '\u001b[B' && key !== '\r' && key !== 'q') {
|
|
3065
2329
|
return;
|
|
3066
2330
|
}
|
|
3067
2331
|
}
|
|
3068
2332
|
|
|
3069
|
-
const
|
|
2333
|
+
const { filteredBranches: currentFiltered, branches: currentBranchList, selectedIndex: curSelIdx } = store.getState();
|
|
2334
|
+
const displayBranches = currentFiltered !== null ? currentFiltered : currentBranchList;
|
|
2335
|
+
const actionState = getActionState();
|
|
3070
2336
|
|
|
3071
2337
|
switch (key) {
|
|
3072
2338
|
case '\u001b[A': // Up arrow
|
|
3073
|
-
case 'k':
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
selectedBranchName = displayBranches[selectedIndex] ? displayBranches[selectedIndex].name : null;
|
|
3077
|
-
render();
|
|
3078
|
-
}
|
|
2339
|
+
case 'k': {
|
|
2340
|
+
const result = actions.moveUp(actionState);
|
|
2341
|
+
if (result) { applyUpdates(result); render(); }
|
|
3079
2342
|
break;
|
|
2343
|
+
}
|
|
3080
2344
|
|
|
3081
2345
|
case '\u001b[B': // Down arrow
|
|
3082
|
-
case 'j':
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
selectedBranchName = displayBranches[selectedIndex] ? displayBranches[selectedIndex].name : null;
|
|
3086
|
-
render();
|
|
3087
|
-
}
|
|
2346
|
+
case 'j': {
|
|
2347
|
+
const result = actions.moveDown(actionState);
|
|
2348
|
+
if (result) { applyUpdates(result); render(); }
|
|
3088
2349
|
break;
|
|
2350
|
+
}
|
|
3089
2351
|
|
|
3090
2352
|
case '\r': // Enter
|
|
3091
2353
|
case '\n':
|
|
3092
|
-
if (displayBranches.length > 0 &&
|
|
3093
|
-
const branch = displayBranches[
|
|
2354
|
+
if (displayBranches.length > 0 && curSelIdx < displayBranches.length) {
|
|
2355
|
+
const branch = displayBranches[curSelIdx];
|
|
3094
2356
|
if (branch.isDeleted) {
|
|
3095
2357
|
addLog(`Cannot switch to deleted branch: ${branch.name}`, 'error');
|
|
3096
2358
|
render();
|
|
3097
|
-
} else if (branch.name !== currentBranch) {
|
|
2359
|
+
} else if (branch.name !== store.get('currentBranch')) {
|
|
3098
2360
|
// Clear search when switching
|
|
3099
|
-
searchQuery
|
|
3100
|
-
filteredBranches = null;
|
|
3101
|
-
searchMode = false;
|
|
2361
|
+
store.setState({ searchQuery: '', filteredBranches: null, searchMode: false });
|
|
3102
2362
|
await switchToBranch(branch.name);
|
|
3103
2363
|
await pollGitChanges();
|
|
3104
2364
|
}
|
|
@@ -3106,30 +2366,28 @@ function setupKeyboardInput() {
|
|
|
3106
2366
|
break;
|
|
3107
2367
|
|
|
3108
2368
|
case 'v': // Preview pane
|
|
3109
|
-
if (displayBranches.length > 0 &&
|
|
3110
|
-
const branch = displayBranches[
|
|
2369
|
+
if (displayBranches.length > 0 && curSelIdx < displayBranches.length) {
|
|
2370
|
+
const branch = displayBranches[curSelIdx];
|
|
3111
2371
|
addLog(`Loading preview for ${branch.name}...`, 'info');
|
|
3112
2372
|
render();
|
|
3113
|
-
|
|
3114
|
-
previewMode
|
|
2373
|
+
const pvData = await getPreviewData(branch.name);
|
|
2374
|
+
store.setState({ previewData: pvData, previewMode: true });
|
|
3115
2375
|
render();
|
|
3116
2376
|
}
|
|
3117
2377
|
break;
|
|
3118
2378
|
|
|
3119
2379
|
case '/': // Search mode
|
|
3120
|
-
|
|
3121
|
-
searchQuery = '';
|
|
3122
|
-
selectedIndex = 0;
|
|
2380
|
+
applyUpdates(actions.enterSearchMode(actionState));
|
|
3123
2381
|
render();
|
|
3124
2382
|
break;
|
|
3125
2383
|
|
|
3126
2384
|
case 'h': // History
|
|
3127
|
-
|
|
2385
|
+
applyUpdates(actions.toggleHistory(actionState));
|
|
3128
2386
|
render();
|
|
3129
2387
|
break;
|
|
3130
2388
|
|
|
3131
2389
|
case 'i': // Server info
|
|
3132
|
-
|
|
2390
|
+
applyUpdates(actions.toggleInfo(actionState));
|
|
3133
2391
|
render();
|
|
3134
2392
|
break;
|
|
3135
2393
|
|
|
@@ -3158,13 +2416,11 @@ function setupKeyboardInput() {
|
|
|
3158
2416
|
}
|
|
3159
2417
|
break;
|
|
3160
2418
|
|
|
3161
|
-
case 'l': // View server logs
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
logScrollOffset = 0;
|
|
3165
|
-
render();
|
|
3166
|
-
}
|
|
2419
|
+
case 'l': { // View server logs
|
|
2420
|
+
const logResult = actions.toggleLogView(actionState);
|
|
2421
|
+
if (logResult) { applyUpdates(logResult); render(); }
|
|
3167
2422
|
break;
|
|
2423
|
+
}
|
|
3168
2424
|
|
|
3169
2425
|
case 'o': // Open live server in browser
|
|
3170
2426
|
if (!NO_SERVER) {
|
|
@@ -3175,6 +2431,32 @@ function setupKeyboardInput() {
|
|
|
3175
2431
|
}
|
|
3176
2432
|
break;
|
|
3177
2433
|
|
|
2434
|
+
case 'b': { // Branch action modal
|
|
2435
|
+
const branch = displayBranches.length > 0 && curSelIdx < displayBranches.length
|
|
2436
|
+
? displayBranches[curSelIdx] : null;
|
|
2437
|
+
if (branch) {
|
|
2438
|
+
// Phase 1: Open modal instantly with local/cached data
|
|
2439
|
+
const localData = gatherLocalActionData(branch);
|
|
2440
|
+
store.setState({ actionData: localData, actionMode: true, actionLoading: !localData.prLoaded });
|
|
2441
|
+
render();
|
|
2442
|
+
|
|
2443
|
+
// Phase 2: Load async data (session URL, PR info) in background
|
|
2444
|
+
loadAsyncActionData(branch, localData).then((fullData) => {
|
|
2445
|
+
// Only update if modal is still open for the same branch
|
|
2446
|
+
if (store.get('actionMode') && store.get('actionData') && store.get('actionData').branch.name === branch.name) {
|
|
2447
|
+
store.setState({ actionData: fullData, actionLoading: false });
|
|
2448
|
+
render();
|
|
2449
|
+
}
|
|
2450
|
+
}).catch(() => {
|
|
2451
|
+
if (store.get('actionMode') && store.get('actionData') && store.get('actionData').branch.name === branch.name) {
|
|
2452
|
+
store.setState({ actionLoading: false });
|
|
2453
|
+
render();
|
|
2454
|
+
}
|
|
2455
|
+
});
|
|
2456
|
+
}
|
|
2457
|
+
break;
|
|
2458
|
+
}
|
|
2459
|
+
|
|
3178
2460
|
case 'f':
|
|
3179
2461
|
addLog('Fetching all branches...', 'update');
|
|
3180
2462
|
await pollGitChanges();
|
|
@@ -3185,71 +2467,85 @@ function setupKeyboardInput() {
|
|
|
3185
2467
|
render();
|
|
3186
2468
|
break;
|
|
3187
2469
|
|
|
3188
|
-
case 's':
|
|
3189
|
-
|
|
3190
|
-
addLog(`Sound notifications ${soundEnabled ? 'enabled' : 'disabled'}`, 'info');
|
|
3191
|
-
if (soundEnabled) playSound();
|
|
2470
|
+
case 's': {
|
|
2471
|
+
applyUpdates(actions.toggleSound(actionState));
|
|
2472
|
+
addLog(`Sound notifications ${store.get('soundEnabled') ? 'enabled' : 'disabled'}`, 'info');
|
|
2473
|
+
if (store.get('soundEnabled')) playSound();
|
|
3192
2474
|
render();
|
|
3193
2475
|
break;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
case 'S': // Stash changes (only active with pending dirty operation)
|
|
2479
|
+
if (pendingDirtyOperation) {
|
|
2480
|
+
await stashAndRetry();
|
|
2481
|
+
}
|
|
2482
|
+
break;
|
|
3194
2483
|
|
|
3195
|
-
case 'c': // Toggle casino mode
|
|
3196
|
-
|
|
3197
|
-
|
|
3198
|
-
|
|
2484
|
+
case 'c': { // Toggle casino mode
|
|
2485
|
+
const newCasinoState = casino.toggle();
|
|
2486
|
+
store.setState({ casinoModeEnabled: newCasinoState });
|
|
2487
|
+
addLog(`Casino mode ${newCasinoState ? '🎰 ENABLED' : 'disabled'}`, newCasinoState ? 'success' : 'info');
|
|
2488
|
+
if (newCasinoState) {
|
|
3199
2489
|
addLog(`Have you noticed this game has that 'variable rewards' thing going on? 🤔😉`, 'info');
|
|
3200
|
-
if (soundEnabled) {
|
|
2490
|
+
if (store.get('soundEnabled')) {
|
|
3201
2491
|
casinoSounds.playJackpot();
|
|
3202
2492
|
}
|
|
3203
2493
|
}
|
|
3204
2494
|
render();
|
|
3205
2495
|
break;
|
|
2496
|
+
}
|
|
3206
2497
|
|
|
3207
2498
|
// Number keys to set visible branch count
|
|
3208
2499
|
case '1': case '2': case '3': case '4': case '5':
|
|
3209
2500
|
case '6': case '7': case '8': case '9':
|
|
3210
|
-
|
|
3211
|
-
addLog(`Showing ${visibleBranchCount} branches`, 'info');
|
|
2501
|
+
applyUpdates(actions.setVisibleBranchCount(actionState, parseInt(key, 10)));
|
|
2502
|
+
addLog(`Showing ${store.get('visibleBranchCount')} branches`, 'info');
|
|
3212
2503
|
render();
|
|
3213
2504
|
break;
|
|
3214
2505
|
|
|
3215
2506
|
case '0': // 0 = 10 branches
|
|
3216
|
-
|
|
3217
|
-
addLog(`Showing ${visibleBranchCount} branches`, 'info');
|
|
2507
|
+
applyUpdates(actions.setVisibleBranchCount(actionState, 10));
|
|
2508
|
+
addLog(`Showing ${store.get('visibleBranchCount')} branches`, 'info');
|
|
3218
2509
|
render();
|
|
3219
2510
|
break;
|
|
3220
2511
|
|
|
3221
2512
|
case '+':
|
|
3222
|
-
case '=': // = key (same key as + without shift)
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
2513
|
+
case '=': { // = key (same key as + without shift)
|
|
2514
|
+
const incResult = actions.increaseVisibleBranches(actionState, getMaxBranchesForScreen());
|
|
2515
|
+
if (incResult) {
|
|
2516
|
+
applyUpdates(incResult);
|
|
2517
|
+
addLog(`Showing ${store.get('visibleBranchCount')} branches`, 'info');
|
|
3226
2518
|
render();
|
|
3227
2519
|
}
|
|
3228
2520
|
break;
|
|
2521
|
+
}
|
|
3229
2522
|
|
|
3230
2523
|
case '-':
|
|
3231
|
-
case '_': // _ key (same key as - with shift)
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
2524
|
+
case '_': { // _ key (same key as - with shift)
|
|
2525
|
+
const decResult = actions.decreaseVisibleBranches(actionState);
|
|
2526
|
+
if (decResult) {
|
|
2527
|
+
applyUpdates(decResult);
|
|
2528
|
+
addLog(`Showing ${store.get('visibleBranchCount')} branches`, 'info');
|
|
3235
2529
|
render();
|
|
3236
2530
|
}
|
|
3237
2531
|
break;
|
|
2532
|
+
}
|
|
3238
2533
|
|
|
3239
2534
|
case 'q':
|
|
3240
2535
|
case '\u0003': // Ctrl+C
|
|
3241
2536
|
await shutdown();
|
|
3242
2537
|
break;
|
|
3243
2538
|
|
|
3244
|
-
case '\u001b': // Escape - clear search if active, otherwise quit
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
filteredBranches = null;
|
|
3248
|
-
render();
|
|
3249
|
-
} else {
|
|
2539
|
+
case '\u001b': { // Escape - clear search if active, otherwise quit
|
|
2540
|
+
const escResult = actions.handleEscape(actionState);
|
|
2541
|
+
if (escResult && escResult._quit) {
|
|
3250
2542
|
await shutdown();
|
|
2543
|
+
} else if (escResult) {
|
|
2544
|
+
applyUpdates(escResult);
|
|
2545
|
+
render();
|
|
3251
2546
|
}
|
|
3252
2547
|
break;
|
|
2548
|
+
}
|
|
3253
2549
|
}
|
|
3254
2550
|
});
|
|
3255
2551
|
}
|
|
@@ -3348,40 +2644,52 @@ async function start() {
|
|
|
3348
2644
|
}
|
|
3349
2645
|
|
|
3350
2646
|
// Get initial state
|
|
3351
|
-
|
|
2647
|
+
const initBranch = await getCurrentBranch();
|
|
2648
|
+
store.setState({ currentBranch: initBranch });
|
|
3352
2649
|
|
|
3353
2650
|
// Warn if in detached HEAD state
|
|
3354
|
-
if (isDetachedHead) {
|
|
2651
|
+
if (store.get('isDetachedHead')) {
|
|
3355
2652
|
addLog(`Warning: In detached HEAD state`, 'warning');
|
|
3356
2653
|
}
|
|
3357
|
-
|
|
2654
|
+
const initBranches = await getAllBranches();
|
|
2655
|
+
store.setState({ branches: initBranches });
|
|
3358
2656
|
|
|
3359
2657
|
// Initialize previous states and known branches
|
|
3360
|
-
for (const branch of
|
|
2658
|
+
for (const branch of initBranches) {
|
|
3361
2659
|
previousBranchStates.set(branch.name, branch.commit);
|
|
3362
2660
|
knownBranchNames.add(branch.name);
|
|
3363
2661
|
}
|
|
3364
2662
|
|
|
3365
2663
|
// Find current branch in list and select it
|
|
3366
|
-
const currentIndex =
|
|
2664
|
+
const currentIndex = initBranches.findIndex(b => b.name === initBranch);
|
|
3367
2665
|
if (currentIndex >= 0) {
|
|
3368
|
-
selectedIndex
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
selectedBranchName = branches[0].name;
|
|
2666
|
+
store.setState({ selectedIndex: currentIndex, selectedBranchName: initBranch });
|
|
2667
|
+
} else if (initBranches.length > 0) {
|
|
2668
|
+
store.setState({ selectedBranchName: initBranches[0].name });
|
|
3372
2669
|
}
|
|
3373
2670
|
|
|
3374
|
-
// Load sparklines in background
|
|
2671
|
+
// Load sparklines and action cache in background
|
|
3375
2672
|
refreshAllSparklines().catch(() => {});
|
|
2673
|
+
initActionCache().then(() => {
|
|
2674
|
+
// Once env is known, kick off initial PR status fetch
|
|
2675
|
+
fetchAllPrStatuses().then(map => {
|
|
2676
|
+
if (map) {
|
|
2677
|
+
store.setState({ branchPrStatusMap: map });
|
|
2678
|
+
lastPrStatusFetch = Date.now();
|
|
2679
|
+
render();
|
|
2680
|
+
}
|
|
2681
|
+
}).catch(() => {});
|
|
2682
|
+
}).catch(() => {});
|
|
3376
2683
|
|
|
3377
2684
|
// Start server based on mode
|
|
2685
|
+
const startBranchName = store.get('currentBranch');
|
|
3378
2686
|
if (SERVER_MODE === 'none') {
|
|
3379
2687
|
addLog(`Running in no-server mode (branch monitoring only)`, 'info');
|
|
3380
|
-
addLog(`Current branch: ${
|
|
2688
|
+
addLog(`Current branch: ${startBranchName}`, 'info');
|
|
3381
2689
|
render();
|
|
3382
2690
|
} else if (SERVER_MODE === 'command') {
|
|
3383
2691
|
addLog(`Command mode: ${SERVER_COMMAND}`, 'info');
|
|
3384
|
-
addLog(`Current branch: ${
|
|
2692
|
+
addLog(`Current branch: ${startBranchName}`, 'info');
|
|
3385
2693
|
render();
|
|
3386
2694
|
// Start the user's dev server
|
|
3387
2695
|
startServerProcess();
|
|
@@ -3390,7 +2698,7 @@ async function start() {
|
|
|
3390
2698
|
server.listen(PORT, () => {
|
|
3391
2699
|
addLog(`Server started on http://localhost:${PORT}`, 'success');
|
|
3392
2700
|
addLog(`Serving ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`, 'info');
|
|
3393
|
-
addLog(`Current branch: ${currentBranch}`, 'info');
|
|
2701
|
+
addLog(`Current branch: ${store.get('currentBranch')}`, 'info');
|
|
3394
2702
|
// Add server log entries for static server
|
|
3395
2703
|
addServerLog(`Static server started on http://localhost:${PORT}`);
|
|
3396
2704
|
addServerLog(`Serving files from: ${STATIC_DIR.replace(PROJECT_ROOT, '.')}`);
|
|
@@ -3424,7 +2732,7 @@ async function start() {
|
|
|
3424
2732
|
});
|
|
3425
2733
|
|
|
3426
2734
|
// Start polling with adaptive interval
|
|
3427
|
-
pollIntervalId = setInterval(pollGitChanges, adaptivePollInterval);
|
|
2735
|
+
pollIntervalId = setInterval(pollGitChanges, store.get('adaptivePollInterval'));
|
|
3428
2736
|
|
|
3429
2737
|
// Initial render
|
|
3430
2738
|
render();
|