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.
@@ -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
- // Package info for --version
69
- const PACKAGE_VERSION = '1.0.0';
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
- // Valid git branch name pattern (conservative)
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 File Support
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
- const CONFIG_FILE_NAME = '.watchtowerrc.json';
109
- const PROJECT_ROOT = process.cwd();
89
+ // Centralized state store
90
+ const { Store } = require('../src/state/store');
91
+ const store = new Store();
110
92
 
111
- function getConfigPath() {
112
- return path.join(PROJECT_ROOT, CONFIG_FILE_NAME);
113
- }
93
+ const PROJECT_ROOT = process.cwd();
114
94
 
115
95
  function loadConfig() {
116
- const configPath = getConfigPath();
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
- const configPath = getConfigPath();
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 applyCliArgsToConfig(config, cliArgs);
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 applyCliArgsToConfig(config, cliArgs);
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
- // Parse CLI arguments
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
- const cliArgs = parseArgs();
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
- visibleBranchCount = config.visibleBranches || 7;
655
- soundEnabled = config.soundEnabled !== false;
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
- casinoModeEnabled = config.casinoMode === true;
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 timestamp = new Date().toLocaleTimeString();
667
- serverLogBuffer.push({ timestamp, line, isError });
668
- if (serverLogBuffer.length > MAX_SERVER_LOG_LINES) {
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
- // Open URL in default browser (cross-platform)
418
+ // openInBrowser imported from src/utils/browser.js
678
419
  function openInBrowser(url) {
679
- const platform = process.platform;
680
- let command;
420
+ openUrl(url, (error) => {
421
+ addLog(`Failed to open browser: ${error.message}`, 'error');
422
+ });
423
+ }
681
424
 
682
- if (platform === 'darwin') {
683
- command = `open "${url}"`;
684
- } else if (platform === 'win32') {
685
- command = `start "" "${url}"`;
686
- } else {
687
- // Linux and other Unix-like systems
688
- command = `xdg-open "${url}"`;
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
- exec(command, (error) => {
692
- if (error) {
693
- addLog(`Failed to open browser: ${error.message}`, 'error');
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 = false;
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 = true;
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 = false;
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 = false;
653
+ store.setState({ serverRunning: false });
750
654
  if (code !== 0 && code !== null) {
751
- serverCrashed = true;
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 = true;
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 = false;
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
- // Git state
811
- let isDetachedHead = false;
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
- // Box drawing characters
883
- const box = {
884
- topLeft: '┌',
885
- topRight: '┐',
886
- bottomLeft: '',
887
- bottomRight: '┘',
888
- horizontal: '─',
889
- vertical: '',
890
- teeRight: '',
891
- teeLeft: '┤',
892
- cross: '┼',
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
- // Activity log entries
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
- // Preview pane state
930
- let previewMode = false;
931
- let previewData = null;
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
- // Search/filter state
934
- let searchMode = false;
935
- let searchQuery = '';
936
- let filteredBranches = null;
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 cache (conservative - only update on manual fetch)
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
- // Parse the summary line: "X files changed, Y insertions(+), Z deletions(-)"
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
- function formatTimeAgo(date) {
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
- if (diffSec < 10) return 'just now';
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
- function truncate(str, maxLen) {
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
- // Calculate max branches that fit: header(2) + branch box + log box(~12) + footer(2)
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
- activityLog.unshift({ timestamp, message, icon: icons[type] || '○', color: colors[type] || 'white' });
1109
- if (activityLog.length > MAX_LOG_ENTRIES) activityLog.pop();
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
- // Sparkline characters (8 levels)
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
- const max = Math.max(...commitCounts, 1);
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
- for (const branch of branches.slice(0, 20)) { // Limit to top 20
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
- terminalWidth = process.stdout.columns || 80;
1251
- terminalHeight = process.stdout.rows || 24;
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
- // Top border
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
- for (let i = 0; i < height; i++) {
1280
- write(ansi.moveTo(row + i, col));
1281
- write(' '.repeat(width));
1282
- }
1014
+ write(renderClearArea(row, col, width, height));
1283
1015
  }
1284
1016
 
1285
- function renderHeader() {
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
- return startRow + height;
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
- function renderFooter() {
1573
- const row = terminalHeight - 1;
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
- // Content
1974
- let contentRow = row + 2;
1067
+ // renderLogView, renderInfo, renderActionModal
1068
+ // now delegated to renderer module (src/ui/renderer.js)
1975
1069
 
1976
- if (!NO_SERVER) {
1977
- write(ansi.moveTo(contentRow, col + 3));
1978
- write(ansi.white + ansi.bold + 'Dev Server' + ansi.reset);
1979
- contentRow++;
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
- renderHeader();
2032
- const logStart = renderBranchList();
2033
- const statsStart = renderActivityLog(logStart);
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); // Center the display
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
- if (flashMessage) {
2104
- renderFlash();
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 = message;
1198
+ store.setState({ flashMessage: message });
2133
1199
  render();
2134
1200
 
2135
1201
  flashTimeout = setTimeout(() => {
2136
- flashMessage = null;
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 = null;
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 = { title, message, hint };
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 = null;
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 = null;
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 = true;
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 = false;
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
- function isAuthError(errorMessage) {
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(`Commit or stash your changes first`, 'warning');
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
- 'Run: git stash or git commit'
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 = safeBranchName;
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.unshift({ from: previousBranch, to: safeBranchName, timestamp: Date.now() });
2375
- if (switchHistory.length > MAX_HISTORY) switchHistory.pop();
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(`Commit or stash your changes first`, 'warning');
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
- 'Run: git stash or git commit'
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
- if (switchHistory.length === 0) {
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 = switchHistory[0];
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.shift(); // Remove the undone entry
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 (isMergeConflict(errMsg)) {
2461
- hasMergeConflict = true;
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 = true;
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 (currentBranch && newCurrentBranch !== currentBranch) {
2512
- addLog(`Branch switched externally: ${currentBranch} → ${newCurrentBranch}`, 'warning');
1602
+ if (prevCurrentBranch && newCurrentBranch !== prevCurrentBranch) {
1603
+ addLog(`Branch switched externally: ${prevCurrentBranch} → ${newCurrentBranch}`, 'warning');
2513
1604
  notifyClients();
2514
1605
  }
2515
- currentBranch = newCurrentBranch;
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
- adaptivePollInterval = Math.min(adaptivePollInterval * 2, 60000);
2528
- addLog(`Polling interval increased to ${adaptivePollInterval / 1000}s`, 'info');
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 = GIT_POLL_INTERVAL;
2539
- addLog(`Polling interval restored to ${adaptivePollInterval / 1000}s`, 'info');
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
- consecutiveNetworkFailures = 0;
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 = branches.find(b => b.name === branch.name);
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 = branches.find(b => b.name === knownName);
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 filteredBranches = allBranches;
1685
+ const pollFilteredBranches = allBranches;
2592
1686
 
2593
1687
  // Detect updates on other branches (for flash notification)
2594
1688
  const updatedBranches = [];
2595
- for (const branch of filteredBranches) {
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 !== currentBranch) {
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 (casinoModeEnabled) {
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 (casinoModeEnabled) {
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 (casinoModeEnabled) {
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 previouslySelectedName = selectedBranchName || (branches[selectedIndex] ? branches[selectedIndex].name : null);
2642
-
2643
- // Sort: new branches first, then by date, deleted branches at the bottom
2644
- filteredBranches.sort((a, b) => {
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 newIndex = branches.findIndex(b => b.name === previouslySelectedName);
2658
- if (newIndex >= 0) {
2659
- selectedIndex = newIndex;
2660
- selectedBranchName = previouslySelectedName;
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
- selectedIndex = Math.min(selectedIndex, Math.max(0, branches.length - 1));
2664
- selectedBranchName = branches[selectedIndex] ? branches[selectedIndex].name : null;
1767
+ newSelectedIndex = Math.min(prevSelIdx, Math.max(0, pollFilteredBranches.length - 1));
1768
+ newSelectedName = pollFilteredBranches[newSelectedIndex] ? pollFilteredBranches[newSelectedIndex].name : null;
2665
1769
  }
2666
- } else if (selectedIndex >= branches.length) {
2667
- selectedIndex = Math.max(0, branches.length - 1);
2668
- selectedBranchName = branches[selectedIndex] ? branches[selectedIndex].name : null;
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 currentInfo = branches.find(b => b.name === currentBranch);
2673
- if (AUTO_PULL && currentInfo && currentInfo.hasUpdates && !hasMergeConflict) {
2674
- addLog(`Auto-pulling changes for ${currentBranch}...`, 'update');
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}" "${currentBranch}"`);
2682
- addLog(`Pulled successfully from ${currentBranch}`, 'success');
1803
+ await execAsync(`git pull "${REMOTE_NAME}" "${autoPullBranchName}"`);
1804
+ addLog(`Pulled successfully from ${autoPullBranchName}`, 'success');
2683
1805
  currentInfo.hasUpdates = false;
2684
- hasMergeConflict = false;
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(currentBranch, newCommit.stdout.trim());
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 = true;
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 = 'idle';
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
- if (consecutiveNetworkFailures >= 3 && !isOffline) {
2760
- isOffline = true;
2761
- addLog(`Network unavailable (${consecutiveNetworkFailures} failures)`, 'error');
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 = 'error';
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 = 'error';
1900
+ store.setState({ pollingStatus: 'error' });
2778
1901
  } else {
2779
- pollingStatus = 'error';
1902
+ store.setState({ pollingStatus: 'error' });
2780
1903
  addLog(`Polling error: ${errMsg}`, 'error');
2781
1904
  }
2782
1905
  } finally {
2783
- isPolling = false;
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
- function applySearchFilter() {
2930
- if (!searchQuery) {
2931
- filteredBranches = null;
2932
- return;
2933
- }
2934
- const query = searchQuery.toLowerCase();
2935
- filteredBranches = branches.filter(b => b.name.toLowerCase().includes(query));
2936
- // Reset selection if out of bounds
2937
- if (selectedIndex >= filteredBranches.length) {
2938
- selectedIndex = Math.max(0, filteredBranches.length - 1);
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
- if (key === '\u001b' || key === '\r' || key === '\n') { // Escape or Enter exits search
2953
- searchMode = false;
2954
- if (key === '\u001b') {
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
- previewMode = false;
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
- historyMode = false;
2100
+ applyUpdates(actions.toggleHistory(getActionState()));
2992
2101
  render();
2993
2102
  return;
2994
2103
  }
2995
2104
  if (key === 'u') {
2996
- historyMode = false;
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
- infoMode = false;
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
- logViewMode = false;
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
- logViewTab = 'activity';
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
- logViewTab = 'server';
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
- logScrollOffset = Math.min(logScrollOffset + 1, maxScroll);
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
- logScrollOffset = Math.max(0, logScrollOffset - 1);
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 displayBranches = filteredBranches !== null ? filteredBranches : branches;
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
- if (selectedIndex > 0) {
3075
- selectedIndex--;
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
- if (selectedIndex < displayBranches.length - 1) {
3084
- selectedIndex++;
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 && selectedIndex < displayBranches.length) {
3093
- const branch = displayBranches[selectedIndex];
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 && selectedIndex < displayBranches.length) {
3110
- const branch = displayBranches[selectedIndex];
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
- previewData = await getPreviewData(branch.name);
3114
- previewMode = true;
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
- searchMode = true;
3121
- searchQuery = '';
3122
- selectedIndex = 0;
2380
+ applyUpdates(actions.enterSearchMode(actionState));
3123
2381
  render();
3124
2382
  break;
3125
2383
 
3126
2384
  case 'h': // History
3127
- historyMode = true;
2385
+ applyUpdates(actions.toggleHistory(actionState));
3128
2386
  render();
3129
2387
  break;
3130
2388
 
3131
2389
  case 'i': // Server info
3132
- infoMode = true;
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
- if (!NO_SERVER) {
3163
- logViewMode = true;
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
- soundEnabled = !soundEnabled;
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
- casinoModeEnabled = casino.toggle();
3197
- addLog(`Casino mode ${casinoModeEnabled ? '🎰 ENABLED' : 'disabled'}`, casinoModeEnabled ? 'success' : 'info');
3198
- if (casinoModeEnabled) {
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
- visibleBranchCount = parseInt(key, 10);
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
- visibleBranchCount = 10;
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
- if (visibleBranchCount < getMaxBranchesForScreen()) {
3224
- visibleBranchCount++;
3225
- addLog(`Showing ${visibleBranchCount} branches`, 'info');
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
- if (visibleBranchCount > 1) {
3233
- visibleBranchCount--;
3234
- addLog(`Showing ${visibleBranchCount} branches`, 'info');
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
- if (searchQuery || filteredBranches) {
3246
- searchQuery = '';
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
- currentBranch = await getCurrentBranch();
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
- branches = await getAllBranches();
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 branches) {
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 = branches.findIndex(b => b.name === currentBranch);
2664
+ const currentIndex = initBranches.findIndex(b => b.name === initBranch);
3367
2665
  if (currentIndex >= 0) {
3368
- selectedIndex = currentIndex;
3369
- selectedBranchName = currentBranch;
3370
- } else if (branches.length > 0) {
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: ${currentBranch}`, 'info');
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: ${currentBranch}`, 'info');
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();