diffstalker 0.2.1 → 0.2.3

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.
Files changed (42) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/README.md +43 -35
  5. package/bun.lock +60 -4
  6. package/dist/App.js +495 -131
  7. package/dist/KeyBindings.js +134 -10
  8. package/dist/MouseHandlers.js +67 -20
  9. package/dist/core/ExplorerStateManager.js +37 -75
  10. package/dist/core/GitStateManager.js +252 -46
  11. package/dist/git/diff.js +99 -18
  12. package/dist/git/status.js +111 -54
  13. package/dist/git/test-helpers.js +67 -0
  14. package/dist/index.js +54 -43
  15. package/dist/ipc/CommandClient.js +6 -7
  16. package/dist/state/UIState.js +22 -0
  17. package/dist/types/remote.js +5 -0
  18. package/dist/ui/PaneRenderers.js +45 -15
  19. package/dist/ui/modals/BranchPicker.js +157 -0
  20. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  21. package/dist/ui/modals/FileFinder.js +45 -75
  22. package/dist/ui/modals/HotkeysModal.js +35 -3
  23. package/dist/ui/modals/SoftResetConfirm.js +68 -0
  24. package/dist/ui/modals/StashListModal.js +98 -0
  25. package/dist/ui/modals/ThemePicker.js +1 -2
  26. package/dist/ui/widgets/CommitPanel.js +113 -7
  27. package/dist/ui/widgets/CompareListView.js +44 -23
  28. package/dist/ui/widgets/DiffView.js +216 -170
  29. package/dist/ui/widgets/ExplorerView.js +50 -54
  30. package/dist/ui/widgets/FileList.js +62 -95
  31. package/dist/ui/widgets/FlatFileList.js +65 -0
  32. package/dist/ui/widgets/Footer.js +25 -15
  33. package/dist/ui/widgets/Header.js +51 -9
  34. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  35. package/dist/utils/ansiTruncate.js +0 -1
  36. package/dist/utils/displayRows.js +101 -21
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +0 -1
  40. package/metrics/v0.2.2.json +229 -0
  41. package/metrics/v0.2.3.json +243 -0
  42. package/package.json +10 -3
package/dist/App.js CHANGED
@@ -15,11 +15,17 @@ import { HotkeysModal } from './ui/modals/HotkeysModal.js';
15
15
  import { BaseBranchPicker } from './ui/modals/BaseBranchPicker.js';
16
16
  import { DiscardConfirm } from './ui/modals/DiscardConfirm.js';
17
17
  import { FileFinder } from './ui/modals/FileFinder.js';
18
+ import { StashListModal } from './ui/modals/StashListModal.js';
19
+ import { BranchPicker } from './ui/modals/BranchPicker.js';
20
+ import { SoftResetConfirm } from './ui/modals/SoftResetConfirm.js';
21
+ import { CommitActionConfirm } from './ui/modals/CommitActionConfirm.js';
18
22
  import { CommitFlowState } from './state/CommitFlowState.js';
19
23
  import { UIState } from './state/UIState.js';
20
24
  import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
21
25
  import { saveConfig } from './config.js';
22
26
  import { getCategoryForIndex, getIndexForCategoryPosition, } from './utils/fileCategories.js';
27
+ import { buildFlatFileList, getFlatFileAtIndex, getFlatFileIndexByPath, } from './utils/flatFileList.js';
28
+ import { extractHunkPatch } from './git/diff.js';
23
29
  /**
24
30
  * Main application controller.
25
31
  * Coordinates between GitStateManager, UIState, and blessed widgets.
@@ -41,10 +47,19 @@ export class App {
41
47
  commitTextarea = null;
42
48
  // Active modals
43
49
  activeModal = null;
44
- // Cached total rows for scroll bounds (single source of truth from render)
50
+ // Auto-clear timer for remote operation status
51
+ remoteClearTimer = null;
52
+ // Cached total rows and hunk info for scroll bounds (single source of truth from render)
45
53
  bottomPaneTotalRows = 0;
54
+ bottomPaneHunkCount = 0;
55
+ bottomPaneHunkBoundaries = [];
46
56
  // Selection anchor: remembers category + position before stage/unstage
47
57
  pendingSelectionAnchor = null;
58
+ // Flat view mode state
59
+ cachedFlatFiles = [];
60
+ pendingFlatSelectionPath = null;
61
+ pendingHunkIndex = null;
62
+ combinedHunkMapping = [];
48
63
  constructor(options) {
49
64
  this.config = options.config;
50
65
  this.commandServer = options.commandServer ?? null;
@@ -158,18 +173,33 @@ export class App {
158
173
  toggleFollow: () => this.toggleFollow(),
159
174
  showDiscardConfirm: (file) => this.showDiscardConfirm(file),
160
175
  render: () => this.render(),
176
+ toggleCurrentHunk: () => this.toggleCurrentHunk(),
177
+ navigateNextHunk: () => this.navigateNextHunk(),
178
+ navigatePrevHunk: () => this.navigatePrevHunk(),
179
+ push: () => this.gitManager?.push(),
180
+ fetchRemote: () => this.gitManager?.fetchRemote(),
181
+ pullRebase: () => this.gitManager?.pullRebase(),
182
+ stash: () => this.gitManager?.stash(),
183
+ stashPop: () => this.gitManager?.stashPop(),
184
+ openStashListModal: () => this.openStashListModal(),
185
+ openBranchPicker: () => this.openBranchPicker(),
186
+ showSoftResetConfirm: () => this.showSoftResetConfirm(),
187
+ cherryPickSelected: () => this.cherryPickSelected(),
188
+ revertSelected: () => this.revertSelected(),
161
189
  }, {
162
190
  hasActiveModal: () => this.activeModal !== null,
163
191
  getBottomTab: () => this.uiState.state.bottomTab,
164
192
  getCurrentPane: () => this.uiState.state.currentPane,
165
193
  isCommitInputFocused: () => this.commitFlowState.state.inputFocused,
194
+ isRemoteInProgress: () => this.gitManager?.remoteState.inProgress ?? false,
166
195
  getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
167
196
  getSelectedIndex: () => this.uiState.state.selectedIndex,
168
197
  uiState: this.uiState,
169
- explorerManager: this.explorerManager,
198
+ getExplorerManager: () => this.explorerManager,
170
199
  commitFlowState: this.commitFlowState,
171
- gitManager: this.gitManager,
200
+ getGitManager: () => this.gitManager,
172
201
  layout: this.layout,
202
+ getCachedFlatFiles: () => this.cachedFlatFiles,
173
203
  });
174
204
  }
175
205
  setupMouseEventHandlers() {
@@ -178,30 +208,59 @@ export class App {
178
208
  selectCompareItem: (selection) => this.selectCompareItem(selection),
179
209
  selectFileByIndex: (index) => this.selectFileByIndex(index),
180
210
  toggleFileByIndex: (index) => this.toggleFileByIndex(index),
211
+ enterExplorerDirectory: () => this.enterExplorerDirectory(),
181
212
  toggleMouseMode: () => this.toggleMouseMode(),
182
213
  toggleFollow: () => this.toggleFollow(),
214
+ selectHunkAtRow: (row) => this.selectHunkAtRow(row),
215
+ focusCommitInput: () => this.focusCommitInput(),
216
+ toggleAmend: () => {
217
+ this.commitFlowState.toggleAmend();
218
+ this.render();
219
+ },
183
220
  render: () => this.render(),
184
221
  }, {
185
222
  uiState: this.uiState,
186
- explorerManager: this.explorerManager,
223
+ getExplorerManager: () => this.explorerManager,
187
224
  getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
188
225
  getHistoryCommitCount: () => this.gitManager?.historyState.commits.length ?? 0,
189
226
  getCompareCommits: () => this.gitManager?.compareState?.compareDiff?.commits ?? [],
190
227
  getCompareFiles: () => this.gitManager?.compareState?.compareDiff?.files ?? [],
191
228
  getBottomPaneTotalRows: () => this.bottomPaneTotalRows,
192
229
  getScreenWidth: () => this.screen.width || 80,
230
+ getCachedFlatFiles: () => this.cachedFlatFiles,
193
231
  });
194
232
  }
233
+ /**
234
+ * Toggle staging for a flat file entry (stage if unstaged/partial, unstage if fully staged).
235
+ */
236
+ async toggleFlatEntry(entry) {
237
+ this.pendingFlatSelectionPath = entry.path;
238
+ if (entry.stagingState === 'staged') {
239
+ if (entry.stagedEntry)
240
+ await this.gitManager?.unstage(entry.stagedEntry);
241
+ }
242
+ else {
243
+ if (entry.unstagedEntry)
244
+ await this.gitManager?.stage(entry.unstagedEntry);
245
+ }
246
+ }
195
247
  async toggleFileByIndex(index) {
196
- const files = this.gitManager?.state.status?.files ?? [];
197
- const file = getFileAtIndex(files, index);
198
- if (file) {
199
- this.pendingSelectionAnchor = getCategoryForIndex(files, this.uiState.state.selectedIndex);
200
- if (file.staged) {
201
- await this.gitManager?.unstage(file);
202
- }
203
- else {
204
- await this.gitManager?.stage(file);
248
+ if (this.uiState.state.flatViewMode) {
249
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
250
+ if (flatEntry)
251
+ await this.toggleFlatEntry(flatEntry);
252
+ }
253
+ else {
254
+ const files = this.gitManager?.state.status?.files ?? [];
255
+ const file = getFileAtIndex(files, index);
256
+ if (file) {
257
+ this.pendingSelectionAnchor = getCategoryForIndex(files, this.uiState.state.selectedIndex);
258
+ if (file.staged) {
259
+ await this.gitManager?.unstage(file);
260
+ }
261
+ else {
262
+ await this.gitManager?.stage(file);
263
+ }
205
264
  }
206
265
  }
207
266
  }
@@ -212,6 +271,10 @@ export class App {
212
271
  });
213
272
  // Load data when switching tabs
214
273
  this.uiState.on('tab-change', (tab) => {
274
+ // Reset hunk selection when leaving diff tab
275
+ if (tab !== 'diff') {
276
+ this.uiState.setSelectedHunkIndex(0);
277
+ }
215
278
  if (tab === 'history') {
216
279
  this.gitManager?.loadHistory();
217
280
  }
@@ -224,6 +287,13 @@ export class App {
224
287
  this.explorerManager?.loadDirectory('');
225
288
  }
226
289
  }
290
+ else if (tab === 'commit') {
291
+ this.gitManager?.loadStashList();
292
+ // Also load history if needed for HEAD commit display
293
+ if (!this.gitManager?.historyState.commits.length) {
294
+ this.gitManager?.loadHistory();
295
+ }
296
+ }
227
297
  });
228
298
  // Handle modal opening/closing
229
299
  this.uiState.on('modal-change', (modal) => {
@@ -305,23 +375,11 @@ export class App {
305
375
  this.gitManager = getManagerForRepo(this.repoPath);
306
376
  // Listen to state changes
307
377
  this.gitManager.on('state-change', () => {
308
- const files = this.gitManager?.state.status?.files ?? [];
309
- if (this.pendingSelectionAnchor) {
310
- // Restore selection to same category + position after stage/unstage
311
- const anchor = this.pendingSelectionAnchor;
312
- this.pendingSelectionAnchor = null;
313
- const newIndex = getIndexForCategoryPosition(files, anchor.category, anchor.categoryIndex);
314
- this.uiState.setSelectedIndex(newIndex);
315
- this.selectFileByIndex(newIndex);
316
- }
317
- else if (files.length > 0) {
318
- // Default: clamp selected index to valid range
319
- const maxIndex = files.length - 1;
320
- if (this.uiState.state.selectedIndex > maxIndex) {
321
- this.uiState.setSelectedIndex(maxIndex);
322
- }
378
+ // Skip reconciliation while loading — the pending anchor must wait
379
+ // for the new status to arrive before being consumed
380
+ if (!this.gitManager?.state.isLoading) {
381
+ this.reconcileSelectionAfterStateChange();
323
382
  }
324
- // Update explorer git status when git state changes
325
383
  this.updateExplorerGitStatus();
326
384
  this.render();
327
385
  });
@@ -341,12 +399,73 @@ export class App {
341
399
  this.gitManager.on('compare-selection-change', () => {
342
400
  this.render();
343
401
  });
402
+ this.gitManager.on('remote-state-change', (remoteState) => {
403
+ // Auto-clear success after 3s, error after 5s
404
+ if (this.remoteClearTimer)
405
+ clearTimeout(this.remoteClearTimer);
406
+ if (remoteState.lastResult && !remoteState.inProgress) {
407
+ this.remoteClearTimer = setTimeout(() => {
408
+ this.gitManager?.clearRemoteState();
409
+ }, 3000);
410
+ }
411
+ else if (remoteState.error) {
412
+ this.remoteClearTimer = setTimeout(() => {
413
+ this.gitManager?.clearRemoteState();
414
+ }, 5000);
415
+ }
416
+ this.render();
417
+ });
344
418
  // Start watching and do initial refresh
345
419
  this.gitManager.startWatching();
346
420
  this.gitManager.refresh();
347
421
  // Initialize explorer manager
348
422
  this.initExplorerManager();
349
423
  }
424
+ /**
425
+ * After git state changes, reconcile the selected file index.
426
+ * Handles both flat mode (path-based anchoring) and categorized mode (category-based anchoring).
427
+ */
428
+ reconcileSelectionAfterStateChange() {
429
+ const files = this.gitManager?.state.status?.files ?? [];
430
+ if (this.uiState.state.flatViewMode && this.pendingFlatSelectionPath) {
431
+ const flatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
432
+ const targetPath = this.pendingFlatSelectionPath;
433
+ this.pendingFlatSelectionPath = null;
434
+ const newIndex = getFlatFileIndexByPath(flatFiles, targetPath);
435
+ if (newIndex >= 0) {
436
+ this.uiState.setSelectedIndex(newIndex);
437
+ this.selectFileByIndex(newIndex);
438
+ }
439
+ else if (flatFiles.length > 0) {
440
+ const clamped = Math.min(this.uiState.state.selectedIndex, flatFiles.length - 1);
441
+ this.uiState.setSelectedIndex(clamped);
442
+ this.selectFileByIndex(clamped);
443
+ }
444
+ return;
445
+ }
446
+ if (this.pendingSelectionAnchor) {
447
+ const anchor = this.pendingSelectionAnchor;
448
+ this.pendingSelectionAnchor = null;
449
+ const newIndex = getIndexForCategoryPosition(files, anchor.category, anchor.categoryIndex);
450
+ this.uiState.setSelectedIndex(newIndex);
451
+ this.selectFileByIndex(newIndex);
452
+ return;
453
+ }
454
+ // No pending anchor — just clamp to valid range
455
+ if (this.uiState.state.flatViewMode) {
456
+ const flatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
457
+ const maxIndex = flatFiles.length - 1;
458
+ if (maxIndex >= 0 && this.uiState.state.selectedIndex > maxIndex) {
459
+ this.uiState.setSelectedIndex(maxIndex);
460
+ }
461
+ }
462
+ else if (files.length > 0) {
463
+ const maxIndex = files.length - 1;
464
+ if (this.uiState.state.selectedIndex > maxIndex) {
465
+ this.uiState.setSelectedIndex(maxIndex);
466
+ }
467
+ }
468
+ }
350
469
  initExplorerManager() {
351
470
  // Clean up existing manager
352
471
  if (this.explorerManager) {
@@ -365,6 +484,8 @@ export class App {
365
484
  });
366
485
  // Load root directory
367
486
  this.explorerManager.loadDirectory('');
487
+ // Pre-load file paths for file finder (runs in background)
488
+ this.explorerManager.loadFilePaths();
368
489
  // Update git status after tree is loaded
369
490
  this.updateExplorerGitStatus();
370
491
  }
@@ -469,93 +590,98 @@ export class App {
469
590
  };
470
591
  }
471
592
  // Navigation methods
472
- navigateUp() {
593
+ /**
594
+ * Scroll the content pane (diff or explorer file content) by delta lines.
595
+ */
596
+ scrollActiveDiffPane(delta) {
473
597
  const state = this.uiState.state;
474
- if (state.bottomTab === 'history') {
475
- if (state.currentPane === 'history') {
476
- this.navigateHistoryUp();
477
- }
478
- else if (state.currentPane === 'diff') {
479
- this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
480
- }
481
- return;
598
+ if (state.bottomTab === 'explorer') {
599
+ const newOffset = Math.max(0, state.explorerFileScrollOffset + delta);
600
+ this.uiState.setExplorerFileScrollOffset(newOffset);
482
601
  }
483
- if (state.bottomTab === 'compare') {
484
- if (state.currentPane === 'compare') {
485
- this.navigateCompareUp();
486
- }
487
- else if (state.currentPane === 'diff') {
488
- this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
489
- }
490
- return;
602
+ else {
603
+ const newOffset = Math.max(0, state.diffScrollOffset + delta);
604
+ this.uiState.setDiffScrollOffset(newOffset);
491
605
  }
492
- if (state.bottomTab === 'explorer') {
493
- if (state.currentPane === 'explorer') {
494
- this.navigateExplorerUp();
495
- }
496
- else if (state.currentPane === 'diff') {
497
- this.uiState.setExplorerFileScrollOffset(Math.max(0, state.explorerFileScrollOffset - 3));
498
- }
606
+ }
607
+ /**
608
+ * Navigate the file list by one item and keep selection visible.
609
+ */
610
+ navigateFileList(direction) {
611
+ const state = this.uiState.state;
612
+ const files = this.gitManager?.state.status?.files ?? [];
613
+ // Determine max index based on view mode
614
+ const maxIndex = state.flatViewMode ? this.cachedFlatFiles.length - 1 : files.length - 1;
615
+ if (maxIndex < 0)
499
616
  return;
617
+ const newIndex = direction === -1
618
+ ? Math.max(0, state.selectedIndex - 1)
619
+ : Math.min(maxIndex, state.selectedIndex + 1);
620
+ this.uiState.setSelectedIndex(newIndex);
621
+ this.selectFileByIndex(newIndex);
622
+ // In flat mode row === index + 1 (header row); in categorized mode account for headers/spacers
623
+ const row = state.flatViewMode ? newIndex + 1 : getRowFromFileIndex(newIndex, files);
624
+ this.scrollToKeepRowVisible(row, direction, state.fileListScrollOffset);
625
+ }
626
+ /**
627
+ * Scroll the file list to keep a given row visible.
628
+ */
629
+ scrollToKeepRowVisible(row, direction, currentOffset) {
630
+ if (direction === -1 && row < currentOffset) {
631
+ this.uiState.setFileListScrollOffset(row);
500
632
  }
501
- if (state.currentPane === 'files') {
502
- const files = this.gitManager?.state.status?.files ?? [];
503
- const newIndex = Math.max(0, state.selectedIndex - 1);
504
- this.uiState.setSelectedIndex(newIndex);
505
- this.selectFileByIndex(newIndex);
506
- // Keep selection visible - scroll up if needed
507
- const row = getRowFromFileIndex(newIndex, files);
508
- if (row < state.fileListScrollOffset) {
509
- this.uiState.setFileListScrollOffset(row);
633
+ else if (direction === 1) {
634
+ const visibleEnd = currentOffset + this.layout.dimensions.topPaneHeight - 1;
635
+ if (row >= visibleEnd) {
636
+ this.uiState.setFileListScrollOffset(currentOffset + (row - visibleEnd + 1));
510
637
  }
511
638
  }
512
- else if (state.currentPane === 'diff') {
513
- this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
514
- }
515
639
  }
516
- navigateDown() {
517
- const state = this.uiState.state;
518
- const files = this.gitManager?.state.status?.files ?? [];
519
- if (state.bottomTab === 'history') {
520
- if (state.currentPane === 'history') {
640
+ /**
641
+ * Navigate the active list pane by one item in the given direction.
642
+ */
643
+ navigateActiveList(direction) {
644
+ const tab = this.uiState.state.bottomTab;
645
+ if (tab === 'history') {
646
+ if (direction === -1)
647
+ this.navigateHistoryUp();
648
+ else
521
649
  this.navigateHistoryDown();
522
- }
523
- else if (state.currentPane === 'diff') {
524
- this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
525
- }
526
- return;
527
650
  }
528
- if (state.bottomTab === 'compare') {
529
- if (state.currentPane === 'compare') {
651
+ else if (tab === 'compare') {
652
+ if (direction === -1)
653
+ this.navigateCompareUp();
654
+ else
530
655
  this.navigateCompareDown();
531
- }
532
- else if (state.currentPane === 'diff') {
533
- this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
534
- }
535
- return;
536
656
  }
537
- if (state.bottomTab === 'explorer') {
538
- if (state.currentPane === 'explorer') {
657
+ else if (tab === 'explorer') {
658
+ if (direction === -1)
659
+ this.navigateExplorerUp();
660
+ else
539
661
  this.navigateExplorerDown();
540
- }
541
- else if (state.currentPane === 'diff') {
542
- this.uiState.setExplorerFileScrollOffset(state.explorerFileScrollOffset + 3);
543
- }
544
- return;
545
662
  }
546
- if (state.currentPane === 'files') {
547
- const newIndex = Math.min(files.length - 1, state.selectedIndex + 1);
548
- this.uiState.setSelectedIndex(newIndex);
549
- this.selectFileByIndex(newIndex);
550
- // Keep selection visible - scroll down if needed
551
- const row = getRowFromFileIndex(newIndex, files);
552
- const visibleEnd = state.fileListScrollOffset + this.layout.dimensions.topPaneHeight - 1;
553
- if (row >= visibleEnd) {
554
- this.uiState.setFileListScrollOffset(state.fileListScrollOffset + (row - visibleEnd + 1));
555
- }
663
+ else {
664
+ this.navigateFileList(direction);
556
665
  }
557
- else if (state.currentPane === 'diff') {
558
- this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
666
+ }
667
+ navigateUp() {
668
+ const state = this.uiState.state;
669
+ const isListPane = state.currentPane !== 'diff';
670
+ if (isListPane) {
671
+ this.navigateActiveList(-1);
672
+ }
673
+ else {
674
+ this.scrollActiveDiffPane(-3);
675
+ }
676
+ }
677
+ navigateDown() {
678
+ const state = this.uiState.state;
679
+ const isListPane = state.currentPane !== 'diff';
680
+ if (isListPane) {
681
+ this.navigateActiveList(1);
682
+ }
683
+ else {
684
+ this.scrollActiveDiffPane(3);
559
685
  }
560
686
  }
561
687
  navigateHistoryUp() {
@@ -691,12 +817,26 @@ export class App {
691
817
  this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
692
818
  }
693
819
  selectFileByIndex(index) {
694
- const files = this.gitManager?.state.status?.files ?? [];
695
- const file = getFileAtIndex(files, index);
696
- if (file) {
697
- // Reset diff scroll when changing files
698
- this.uiState.setDiffScrollOffset(0);
699
- this.gitManager?.selectFile(file);
820
+ if (this.uiState.state.flatViewMode) {
821
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
822
+ if (flatEntry) {
823
+ // Prefer unstaged entry (shows unstaged diff for partial files), fallback to staged
824
+ const file = flatEntry.unstagedEntry ?? flatEntry.stagedEntry;
825
+ if (file) {
826
+ this.uiState.setDiffScrollOffset(0);
827
+ this.uiState.setSelectedHunkIndex(0);
828
+ this.gitManager?.selectFile(file);
829
+ }
830
+ }
831
+ }
832
+ else {
833
+ const files = this.gitManager?.state.status?.files ?? [];
834
+ const file = getFileAtIndex(files, index);
835
+ if (file) {
836
+ this.uiState.setDiffScrollOffset(0);
837
+ this.uiState.setSelectedHunkIndex(0);
838
+ this.gitManager?.selectFile(file);
839
+ }
700
840
  }
701
841
  }
702
842
  /**
@@ -726,32 +866,64 @@ export class App {
726
866
  async stageSelected() {
727
867
  const files = this.gitManager?.state.status?.files ?? [];
728
868
  const index = this.uiState.state.selectedIndex;
729
- const selectedFile = getFileAtIndex(files, index);
730
- if (selectedFile && !selectedFile.staged) {
731
- this.pendingSelectionAnchor = getCategoryForIndex(files, index);
732
- await this.gitManager?.stage(selectedFile);
869
+ if (this.uiState.state.flatViewMode) {
870
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
871
+ if (!flatEntry)
872
+ return;
873
+ // Stage: operate on the unstaged entry if available
874
+ const file = flatEntry.unstagedEntry;
875
+ if (file) {
876
+ this.pendingFlatSelectionPath = flatEntry.path;
877
+ await this.gitManager?.stage(file);
878
+ }
879
+ }
880
+ else {
881
+ const selectedFile = getFileAtIndex(files, index);
882
+ if (selectedFile && !selectedFile.staged) {
883
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
884
+ await this.gitManager?.stage(selectedFile);
885
+ }
733
886
  }
734
887
  }
735
888
  async unstageSelected() {
736
889
  const files = this.gitManager?.state.status?.files ?? [];
737
890
  const index = this.uiState.state.selectedIndex;
738
- const selectedFile = getFileAtIndex(files, index);
739
- if (selectedFile?.staged) {
740
- this.pendingSelectionAnchor = getCategoryForIndex(files, index);
741
- await this.gitManager?.unstage(selectedFile);
891
+ if (this.uiState.state.flatViewMode) {
892
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
893
+ if (!flatEntry)
894
+ return;
895
+ const file = flatEntry.stagedEntry;
896
+ if (file) {
897
+ this.pendingFlatSelectionPath = flatEntry.path;
898
+ await this.gitManager?.unstage(file);
899
+ }
900
+ }
901
+ else {
902
+ const selectedFile = getFileAtIndex(files, index);
903
+ if (selectedFile?.staged) {
904
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
905
+ await this.gitManager?.unstage(selectedFile);
906
+ }
742
907
  }
743
908
  }
744
909
  async toggleSelected() {
745
- const files = this.gitManager?.state.status?.files ?? [];
746
910
  const index = this.uiState.state.selectedIndex;
747
- const selectedFile = getFileAtIndex(files, index);
748
- if (selectedFile) {
749
- this.pendingSelectionAnchor = getCategoryForIndex(files, index);
750
- if (selectedFile.staged) {
751
- await this.gitManager?.unstage(selectedFile);
752
- }
753
- else {
754
- await this.gitManager?.stage(selectedFile);
911
+ if (this.uiState.state.flatViewMode) {
912
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
913
+ if (flatEntry)
914
+ await this.toggleFlatEntry(flatEntry);
915
+ }
916
+ else {
917
+ const files = this.gitManager?.state.status?.files ?? [];
918
+ const selectedFile = getFileAtIndex(files, index);
919
+ if (selectedFile) {
920
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
921
+ if (selectedFile.staged) {
922
+ await this.gitManager?.unstage(selectedFile);
923
+ }
924
+ else {
925
+ await this.gitManager?.stage(selectedFile);
926
+ }
755
927
  }
756
928
  }
757
929
  }
@@ -770,18 +942,125 @@ export class App {
770
942
  });
771
943
  this.activeModal.focus();
772
944
  }
945
+ // Hunk navigation and staging
946
+ navigateNextHunk() {
947
+ const current = this.uiState.state.selectedHunkIndex;
948
+ if (this.bottomPaneHunkCount > 0 && current < this.bottomPaneHunkCount - 1) {
949
+ this.uiState.setSelectedHunkIndex(current + 1);
950
+ this.scrollHunkIntoView(current + 1);
951
+ }
952
+ }
953
+ navigatePrevHunk() {
954
+ const current = this.uiState.state.selectedHunkIndex;
955
+ if (current > 0) {
956
+ this.uiState.setSelectedHunkIndex(current - 1);
957
+ this.scrollHunkIntoView(current - 1);
958
+ }
959
+ }
960
+ scrollHunkIntoView(hunkIndex) {
961
+ const boundary = this.bottomPaneHunkBoundaries[hunkIndex];
962
+ if (!boundary)
963
+ return;
964
+ const scrollOffset = this.uiState.state.diffScrollOffset;
965
+ const visibleHeight = this.layout.dimensions.bottomPaneHeight;
966
+ // If hunk header is outside the visible area, scroll so it's at top
967
+ if (boundary.startRow < scrollOffset || boundary.startRow >= scrollOffset + visibleHeight) {
968
+ this.uiState.setDiffScrollOffset(boundary.startRow);
969
+ }
970
+ }
971
+ selectHunkAtRow(visualRow) {
972
+ if (this.uiState.state.bottomTab !== 'diff')
973
+ return;
974
+ if (this.bottomPaneHunkBoundaries.length === 0)
975
+ return;
976
+ // Focus the diff pane so the hunk gutter appears
977
+ this.uiState.setPane('diff');
978
+ const absoluteRow = this.uiState.state.diffScrollOffset + visualRow;
979
+ for (let i = 0; i < this.bottomPaneHunkBoundaries.length; i++) {
980
+ const b = this.bottomPaneHunkBoundaries[i];
981
+ if (absoluteRow >= b.startRow && absoluteRow < b.endRow) {
982
+ this.uiState.setSelectedHunkIndex(i);
983
+ return;
984
+ }
985
+ }
986
+ }
987
+ async toggleCurrentHunk() {
988
+ const selectedFile = this.gitManager?.state.selectedFile;
989
+ if (!selectedFile || selectedFile.status === 'untracked')
990
+ return;
991
+ if (this.uiState.state.flatViewMode) {
992
+ await this.toggleCurrentHunkFlat();
993
+ }
994
+ else {
995
+ await this.toggleCurrentHunkCategorized(selectedFile);
996
+ }
997
+ }
998
+ async toggleCurrentHunkFlat() {
999
+ const mapping = this.combinedHunkMapping[this.uiState.state.selectedHunkIndex];
1000
+ if (!mapping)
1001
+ return;
1002
+ const combined = this.gitManager?.state.combinedFileDiffs;
1003
+ if (!combined)
1004
+ return;
1005
+ const rawDiff = mapping.source === 'unstaged' ? combined.unstaged.raw : combined.staged.raw;
1006
+ const patch = extractHunkPatch(rawDiff, mapping.hunkIndex);
1007
+ if (!patch)
1008
+ return;
1009
+ // Preserve hunk index across refresh — file stays selected via path-only fallback
1010
+ this.pendingHunkIndex = this.uiState.state.selectedHunkIndex;
1011
+ if (mapping.source === 'staged') {
1012
+ await this.gitManager?.unstageHunk(patch);
1013
+ }
1014
+ else {
1015
+ await this.gitManager?.stageHunk(patch);
1016
+ }
1017
+ }
1018
+ async toggleCurrentHunkCategorized(selectedFile) {
1019
+ const rawDiff = this.gitManager?.state.diff?.raw;
1020
+ if (!rawDiff)
1021
+ return;
1022
+ const patch = extractHunkPatch(rawDiff, this.uiState.state.selectedHunkIndex);
1023
+ if (!patch)
1024
+ return;
1025
+ const files = this.gitManager?.state.status?.files ?? [];
1026
+ this.pendingSelectionAnchor = getCategoryForIndex(files, this.uiState.state.selectedIndex);
1027
+ if (selectedFile.staged) {
1028
+ await this.gitManager?.unstageHunk(patch);
1029
+ }
1030
+ else {
1031
+ await this.gitManager?.stageHunk(patch);
1032
+ }
1033
+ }
773
1034
  async openFileFinder() {
774
- const allPaths = (await this.explorerManager?.getAllFilePaths()) ?? [];
1035
+ let allPaths = this.explorerManager?.getCachedFilePaths() ?? [];
1036
+ if (allPaths.length === 0) {
1037
+ // First open or cache not yet loaded — wait for it
1038
+ await this.explorerManager?.loadFilePaths();
1039
+ allPaths = this.explorerManager?.getCachedFilePaths() ?? [];
1040
+ }
775
1041
  if (allPaths.length === 0)
776
1042
  return;
777
1043
  this.activeModal = new FileFinder(this.screen, allPaths, async (selectedPath) => {
778
1044
  this.activeModal = null;
1045
+ // Switch to explorer tab if not already there
1046
+ if (this.uiState.state.bottomTab !== 'explorer') {
1047
+ this.uiState.setTab('explorer');
1048
+ }
779
1049
  // Navigate to the selected file in explorer
780
1050
  const success = await this.explorerManager?.navigateToPath(selectedPath);
781
1051
  if (success) {
782
- // Reset scroll to show selected file
783
- this.uiState.setExplorerScrollOffset(0);
1052
+ // Sync selected index from explorer manager
1053
+ const selectedIndex = this.explorerManager?.state.selectedIndex ?? 0;
1054
+ this.uiState.setExplorerSelectedIndex(selectedIndex);
784
1055
  this.uiState.setExplorerFileScrollOffset(0);
1056
+ // Scroll to make selected file visible
1057
+ const visibleHeight = this.layout.dimensions.topPaneHeight;
1058
+ if (selectedIndex >= visibleHeight) {
1059
+ this.uiState.setExplorerScrollOffset(selectedIndex - Math.floor(visibleHeight / 2));
1060
+ }
1061
+ else {
1062
+ this.uiState.setExplorerScrollOffset(0);
1063
+ }
785
1064
  }
786
1065
  this.render();
787
1066
  }, () => {
@@ -790,6 +1069,66 @@ export class App {
790
1069
  });
791
1070
  this.activeModal.focus();
792
1071
  }
1072
+ openStashListModal() {
1073
+ const entries = this.gitManager?.state.stashList ?? [];
1074
+ this.activeModal = new StashListModal(this.screen, entries, (index) => {
1075
+ this.activeModal = null;
1076
+ this.gitManager?.stashPop(index);
1077
+ }, () => {
1078
+ this.activeModal = null;
1079
+ });
1080
+ this.activeModal.focus();
1081
+ }
1082
+ openBranchPicker() {
1083
+ this.gitManager?.getLocalBranches().then((branches) => {
1084
+ this.activeModal = new BranchPicker(this.screen, branches, (name) => {
1085
+ this.activeModal = null;
1086
+ this.gitManager?.switchBranch(name);
1087
+ }, (name) => {
1088
+ this.activeModal = null;
1089
+ this.gitManager?.createBranch(name);
1090
+ }, () => {
1091
+ this.activeModal = null;
1092
+ });
1093
+ this.activeModal.focus();
1094
+ });
1095
+ }
1096
+ showSoftResetConfirm() {
1097
+ const headCommit = this.gitManager?.historyState.commits[0];
1098
+ if (!headCommit)
1099
+ return;
1100
+ this.activeModal = new SoftResetConfirm(this.screen, headCommit, () => {
1101
+ this.activeModal = null;
1102
+ this.gitManager?.softReset();
1103
+ }, () => {
1104
+ this.activeModal = null;
1105
+ });
1106
+ this.activeModal.focus();
1107
+ }
1108
+ cherryPickSelected() {
1109
+ const commit = this.gitManager?.historyState.selectedCommit;
1110
+ if (!commit)
1111
+ return;
1112
+ this.activeModal = new CommitActionConfirm(this.screen, 'Cherry-pick', commit, () => {
1113
+ this.activeModal = null;
1114
+ this.gitManager?.cherryPick(commit.hash);
1115
+ }, () => {
1116
+ this.activeModal = null;
1117
+ });
1118
+ this.activeModal.focus();
1119
+ }
1120
+ revertSelected() {
1121
+ const commit = this.gitManager?.historyState.selectedCommit;
1122
+ if (!commit)
1123
+ return;
1124
+ this.activeModal = new CommitActionConfirm(this.screen, 'Revert', commit, () => {
1125
+ this.activeModal = null;
1126
+ this.gitManager?.revertCommit(commit.hash);
1127
+ }, () => {
1128
+ this.activeModal = null;
1129
+ });
1130
+ this.activeModal.focus();
1131
+ }
793
1132
  async commit(message) {
794
1133
  await this.gitManager?.commit(message);
795
1134
  }
@@ -800,6 +1139,7 @@ export class App {
800
1139
  const willEnable = !this.uiState.state.mouseEnabled;
801
1140
  this.uiState.toggleMouse();
802
1141
  // Access program for terminal mouse control (not on screen's TS types)
1142
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
803
1143
  const program = this.screen.program;
804
1144
  if (willEnable) {
805
1145
  program.enableMouse();
@@ -842,19 +1182,31 @@ export class App {
842
1182
  this.updateHeader();
843
1183
  this.updateTopPane();
844
1184
  this.updateBottomPane();
1185
+ // Restore hunk index after diff refresh (e.g. after hunk toggle in flat mode)
1186
+ if (this.pendingHunkIndex !== null && this.bottomPaneHunkCount > 0) {
1187
+ const restored = Math.min(this.pendingHunkIndex, this.bottomPaneHunkCount - 1);
1188
+ this.pendingHunkIndex = null;
1189
+ this.uiState.setSelectedHunkIndex(restored);
1190
+ this.updateBottomPane(); // Re-render with correct hunk selection
1191
+ }
845
1192
  this.updateFooter();
846
1193
  this.screen.render();
847
1194
  }
848
1195
  updateHeader() {
849
1196
  const gitState = this.gitManager?.state;
850
1197
  const width = this.screen.width || 80;
851
- const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width);
1198
+ const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width, this.gitManager?.remoteState ?? null);
852
1199
  this.layout.headerBox.setContent(content);
853
1200
  }
854
1201
  updateTopPane() {
855
1202
  const state = this.uiState.state;
856
1203
  const width = this.screen.width || 80;
857
- const content = renderTopPane(state, this.gitManager?.state.status?.files ?? [], this.gitManager?.historyState?.commits ?? [], this.gitManager?.compareState?.compareDiff ?? null, this.compareSelection, this.explorerManager?.state, width, this.layout.dimensions.topPaneHeight);
1204
+ const files = this.gitManager?.state.status?.files ?? [];
1205
+ // Build and cache flat file list when in flat mode
1206
+ if (state.flatViewMode) {
1207
+ this.cachedFlatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
1208
+ }
1209
+ const content = renderTopPane(state, files, this.gitManager?.historyState?.commits ?? [], this.gitManager?.compareState?.compareDiff ?? null, this.compareSelection, this.explorerManager?.state, width, this.layout.dimensions.topPaneHeight, this.gitManager?.state.hunkCounts, state.flatViewMode ? this.cachedFlatFiles : undefined);
858
1210
  this.layout.topPane.setContent(content);
859
1211
  }
860
1212
  updateBottomPane() {
@@ -864,8 +1216,17 @@ export class App {
864
1216
  const stagedCount = files.filter((f) => f.staged).length;
865
1217
  // Update staged count for commit validation
866
1218
  this.commitFlowState.setStagedCount(stagedCount);
867
- const { content, totalRows } = renderBottomPane(state, this.gitManager?.state.diff ?? null, this.gitManager?.historyState, this.gitManager?.compareSelectionState, this.explorerManager?.state?.selectedFile ?? null, this.commitFlowState.state, stagedCount, this.currentTheme, width, this.layout.dimensions.bottomPaneHeight);
1219
+ // Pass selectedHunkIndex and staged status only when diff pane is focused on diff tab
1220
+ const diffPaneFocused = state.bottomTab === 'diff' && state.currentPane === 'diff';
1221
+ const hunkIndex = diffPaneFocused ? state.selectedHunkIndex : undefined;
1222
+ const isFileStaged = diffPaneFocused ? this.gitManager?.state.selectedFile?.staged : undefined;
1223
+ const { content, totalRows, hunkCount, hunkBoundaries, hunkMapping } = renderBottomPane(state, this.gitManager?.state.diff ?? null, this.gitManager?.historyState, this.gitManager?.compareSelectionState, this.explorerManager?.state?.selectedFile ?? null, this.commitFlowState.state, stagedCount, this.currentTheme, width, this.layout.dimensions.bottomPaneHeight, hunkIndex, isFileStaged, state.flatViewMode ? this.gitManager?.state.combinedFileDiffs : undefined, this.gitManager?.state.status?.branch ?? null, this.gitManager?.remoteState ?? null, this.gitManager?.state.stashList, this.gitManager?.historyState.commits[0] ?? null);
868
1224
  this.bottomPaneTotalRows = totalRows;
1225
+ this.bottomPaneHunkCount = hunkCount;
1226
+ this.bottomPaneHunkBoundaries = hunkBoundaries;
1227
+ this.combinedHunkMapping = hunkMapping ?? [];
1228
+ // Silently clamp hunk index to actual count (handles async refresh after hunk staging)
1229
+ this.uiState.clampSelectedHunkIndex(hunkCount);
869
1230
  this.layout.bottomPane.setContent(content);
870
1231
  // Manage commit textarea visibility
871
1232
  if (this.commitTextarea) {
@@ -880,7 +1241,7 @@ export class App {
880
1241
  updateFooter() {
881
1242
  const state = this.uiState.state;
882
1243
  const width = this.screen.width || 80;
883
- const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, this.followMode?.isEnabled ?? false, this.explorerManager?.showOnlyChanges ?? false, width);
1244
+ const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, this.followMode?.isEnabled ?? false, this.explorerManager?.showOnlyChanges ?? false, width, state.currentPane);
884
1245
  this.layout.footerBox.setContent(content);
885
1246
  }
886
1247
  /**
@@ -900,6 +1261,9 @@ export class App {
900
1261
  if (this.commandServer) {
901
1262
  this.commandServer.stop();
902
1263
  }
1264
+ if (this.remoteClearTimer) {
1265
+ clearTimeout(this.remoteClearTimer);
1266
+ }
903
1267
  // Destroy screen (this will clean up terminal)
904
1268
  this.screen.destroy();
905
1269
  }