diffstalker 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/App.js CHANGED
@@ -20,6 +20,8 @@ import { UIState } from './state/UIState.js';
20
20
  import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
21
21
  import { saveConfig } from './config.js';
22
22
  import { getCategoryForIndex, getIndexForCategoryPosition, } from './utils/fileCategories.js';
23
+ import { buildFlatFileList, getFlatFileAtIndex, getFlatFileIndexByPath, } from './utils/flatFileList.js';
24
+ import { extractHunkPatch } from './git/diff.js';
23
25
  /**
24
26
  * Main application controller.
25
27
  * Coordinates between GitStateManager, UIState, and blessed widgets.
@@ -41,10 +43,17 @@ export class App {
41
43
  commitTextarea = null;
42
44
  // Active modals
43
45
  activeModal = null;
44
- // Cached total rows for scroll bounds (single source of truth from render)
46
+ // Cached total rows and hunk info for scroll bounds (single source of truth from render)
45
47
  bottomPaneTotalRows = 0;
48
+ bottomPaneHunkCount = 0;
49
+ bottomPaneHunkBoundaries = [];
46
50
  // Selection anchor: remembers category + position before stage/unstage
47
51
  pendingSelectionAnchor = null;
52
+ // Flat view mode state
53
+ cachedFlatFiles = [];
54
+ pendingFlatSelectionPath = null;
55
+ pendingHunkIndex = null;
56
+ combinedHunkMapping = [];
48
57
  constructor(options) {
49
58
  this.config = options.config;
50
59
  this.commandServer = options.commandServer ?? null;
@@ -158,6 +167,9 @@ export class App {
158
167
  toggleFollow: () => this.toggleFollow(),
159
168
  showDiscardConfirm: (file) => this.showDiscardConfirm(file),
160
169
  render: () => this.render(),
170
+ toggleCurrentHunk: () => this.toggleCurrentHunk(),
171
+ navigateNextHunk: () => this.navigateNextHunk(),
172
+ navigatePrevHunk: () => this.navigatePrevHunk(),
161
173
  }, {
162
174
  hasActiveModal: () => this.activeModal !== null,
163
175
  getBottomTab: () => this.uiState.state.bottomTab,
@@ -166,10 +178,11 @@ export class App {
166
178
  getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
167
179
  getSelectedIndex: () => this.uiState.state.selectedIndex,
168
180
  uiState: this.uiState,
169
- explorerManager: this.explorerManager,
181
+ getExplorerManager: () => this.explorerManager,
170
182
  commitFlowState: this.commitFlowState,
171
- gitManager: this.gitManager,
183
+ getGitManager: () => this.gitManager,
172
184
  layout: this.layout,
185
+ getCachedFlatFiles: () => this.cachedFlatFiles,
173
186
  });
174
187
  }
175
188
  setupMouseEventHandlers() {
@@ -178,30 +191,54 @@ export class App {
178
191
  selectCompareItem: (selection) => this.selectCompareItem(selection),
179
192
  selectFileByIndex: (index) => this.selectFileByIndex(index),
180
193
  toggleFileByIndex: (index) => this.toggleFileByIndex(index),
194
+ enterExplorerDirectory: () => this.enterExplorerDirectory(),
181
195
  toggleMouseMode: () => this.toggleMouseMode(),
182
196
  toggleFollow: () => this.toggleFollow(),
197
+ selectHunkAtRow: (row) => this.selectHunkAtRow(row),
183
198
  render: () => this.render(),
184
199
  }, {
185
200
  uiState: this.uiState,
186
- explorerManager: this.explorerManager,
201
+ getExplorerManager: () => this.explorerManager,
187
202
  getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
188
203
  getHistoryCommitCount: () => this.gitManager?.historyState.commits.length ?? 0,
189
204
  getCompareCommits: () => this.gitManager?.compareState?.compareDiff?.commits ?? [],
190
205
  getCompareFiles: () => this.gitManager?.compareState?.compareDiff?.files ?? [],
191
206
  getBottomPaneTotalRows: () => this.bottomPaneTotalRows,
192
207
  getScreenWidth: () => this.screen.width || 80,
208
+ getCachedFlatFiles: () => this.cachedFlatFiles,
193
209
  });
194
210
  }
211
+ /**
212
+ * Toggle staging for a flat file entry (stage if unstaged/partial, unstage if fully staged).
213
+ */
214
+ async toggleFlatEntry(entry) {
215
+ this.pendingFlatSelectionPath = entry.path;
216
+ if (entry.stagingState === 'staged') {
217
+ if (entry.stagedEntry)
218
+ await this.gitManager?.unstage(entry.stagedEntry);
219
+ }
220
+ else {
221
+ if (entry.unstagedEntry)
222
+ await this.gitManager?.stage(entry.unstagedEntry);
223
+ }
224
+ }
195
225
  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);
226
+ if (this.uiState.state.flatViewMode) {
227
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
228
+ if (flatEntry)
229
+ await this.toggleFlatEntry(flatEntry);
230
+ }
231
+ else {
232
+ const files = this.gitManager?.state.status?.files ?? [];
233
+ const file = getFileAtIndex(files, index);
234
+ if (file) {
235
+ this.pendingSelectionAnchor = getCategoryForIndex(files, this.uiState.state.selectedIndex);
236
+ if (file.staged) {
237
+ await this.gitManager?.unstage(file);
238
+ }
239
+ else {
240
+ await this.gitManager?.stage(file);
241
+ }
205
242
  }
206
243
  }
207
244
  }
@@ -212,6 +249,10 @@ export class App {
212
249
  });
213
250
  // Load data when switching tabs
214
251
  this.uiState.on('tab-change', (tab) => {
252
+ // Reset hunk selection when leaving diff tab
253
+ if (tab !== 'diff') {
254
+ this.uiState.setSelectedHunkIndex(0);
255
+ }
215
256
  if (tab === 'history') {
216
257
  this.gitManager?.loadHistory();
217
258
  }
@@ -305,23 +346,11 @@ export class App {
305
346
  this.gitManager = getManagerForRepo(this.repoPath);
306
347
  // Listen to state changes
307
348
  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);
349
+ // Skip reconciliation while loading — the pending anchor must wait
350
+ // for the new status to arrive before being consumed
351
+ if (!this.gitManager?.state.isLoading) {
352
+ this.reconcileSelectionAfterStateChange();
316
353
  }
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
- }
323
- }
324
- // Update explorer git status when git state changes
325
354
  this.updateExplorerGitStatus();
326
355
  this.render();
327
356
  });
@@ -347,6 +376,51 @@ export class App {
347
376
  // Initialize explorer manager
348
377
  this.initExplorerManager();
349
378
  }
379
+ /**
380
+ * After git state changes, reconcile the selected file index.
381
+ * Handles both flat mode (path-based anchoring) and categorized mode (category-based anchoring).
382
+ */
383
+ reconcileSelectionAfterStateChange() {
384
+ const files = this.gitManager?.state.status?.files ?? [];
385
+ if (this.uiState.state.flatViewMode && this.pendingFlatSelectionPath) {
386
+ const flatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
387
+ const targetPath = this.pendingFlatSelectionPath;
388
+ this.pendingFlatSelectionPath = null;
389
+ const newIndex = getFlatFileIndexByPath(flatFiles, targetPath);
390
+ if (newIndex >= 0) {
391
+ this.uiState.setSelectedIndex(newIndex);
392
+ this.selectFileByIndex(newIndex);
393
+ }
394
+ else if (flatFiles.length > 0) {
395
+ const clamped = Math.min(this.uiState.state.selectedIndex, flatFiles.length - 1);
396
+ this.uiState.setSelectedIndex(clamped);
397
+ this.selectFileByIndex(clamped);
398
+ }
399
+ return;
400
+ }
401
+ if (this.pendingSelectionAnchor) {
402
+ const anchor = this.pendingSelectionAnchor;
403
+ this.pendingSelectionAnchor = null;
404
+ const newIndex = getIndexForCategoryPosition(files, anchor.category, anchor.categoryIndex);
405
+ this.uiState.setSelectedIndex(newIndex);
406
+ this.selectFileByIndex(newIndex);
407
+ return;
408
+ }
409
+ // No pending anchor — just clamp to valid range
410
+ if (this.uiState.state.flatViewMode) {
411
+ const flatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
412
+ const maxIndex = flatFiles.length - 1;
413
+ if (maxIndex >= 0 && this.uiState.state.selectedIndex > maxIndex) {
414
+ this.uiState.setSelectedIndex(maxIndex);
415
+ }
416
+ }
417
+ else if (files.length > 0) {
418
+ const maxIndex = files.length - 1;
419
+ if (this.uiState.state.selectedIndex > maxIndex) {
420
+ this.uiState.setSelectedIndex(maxIndex);
421
+ }
422
+ }
423
+ }
350
424
  initExplorerManager() {
351
425
  // Clean up existing manager
352
426
  if (this.explorerManager) {
@@ -469,93 +543,98 @@ export class App {
469
543
  };
470
544
  }
471
545
  // Navigation methods
472
- navigateUp() {
546
+ /**
547
+ * Scroll the content pane (diff or explorer file content) by delta lines.
548
+ */
549
+ scrollActiveDiffPane(delta) {
473
550
  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;
551
+ if (state.bottomTab === 'explorer') {
552
+ const newOffset = Math.max(0, state.explorerFileScrollOffset + delta);
553
+ this.uiState.setExplorerFileScrollOffset(newOffset);
482
554
  }
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;
555
+ else {
556
+ const newOffset = Math.max(0, state.diffScrollOffset + delta);
557
+ this.uiState.setDiffScrollOffset(newOffset);
491
558
  }
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
- }
559
+ }
560
+ /**
561
+ * Navigate the file list by one item and keep selection visible.
562
+ */
563
+ navigateFileList(direction) {
564
+ const state = this.uiState.state;
565
+ const files = this.gitManager?.state.status?.files ?? [];
566
+ // Determine max index based on view mode
567
+ const maxIndex = state.flatViewMode ? this.cachedFlatFiles.length - 1 : files.length - 1;
568
+ if (maxIndex < 0)
499
569
  return;
570
+ const newIndex = direction === -1
571
+ ? Math.max(0, state.selectedIndex - 1)
572
+ : Math.min(maxIndex, state.selectedIndex + 1);
573
+ this.uiState.setSelectedIndex(newIndex);
574
+ this.selectFileByIndex(newIndex);
575
+ // In flat mode row === index + 1 (header row); in categorized mode account for headers/spacers
576
+ const row = state.flatViewMode ? newIndex + 1 : getRowFromFileIndex(newIndex, files);
577
+ this.scrollToKeepRowVisible(row, direction, state.fileListScrollOffset);
578
+ }
579
+ /**
580
+ * Scroll the file list to keep a given row visible.
581
+ */
582
+ scrollToKeepRowVisible(row, direction, currentOffset) {
583
+ if (direction === -1 && row < currentOffset) {
584
+ this.uiState.setFileListScrollOffset(row);
500
585
  }
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);
586
+ else if (direction === 1) {
587
+ const visibleEnd = currentOffset + this.layout.dimensions.topPaneHeight - 1;
588
+ if (row >= visibleEnd) {
589
+ this.uiState.setFileListScrollOffset(currentOffset + (row - visibleEnd + 1));
510
590
  }
511
591
  }
512
- else if (state.currentPane === 'diff') {
513
- this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
514
- }
515
592
  }
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') {
593
+ /**
594
+ * Navigate the active list pane by one item in the given direction.
595
+ */
596
+ navigateActiveList(direction) {
597
+ const tab = this.uiState.state.bottomTab;
598
+ if (tab === 'history') {
599
+ if (direction === -1)
600
+ this.navigateHistoryUp();
601
+ else
521
602
  this.navigateHistoryDown();
522
- }
523
- else if (state.currentPane === 'diff') {
524
- this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
525
- }
526
- return;
527
603
  }
528
- if (state.bottomTab === 'compare') {
529
- if (state.currentPane === 'compare') {
604
+ else if (tab === 'compare') {
605
+ if (direction === -1)
606
+ this.navigateCompareUp();
607
+ else
530
608
  this.navigateCompareDown();
531
- }
532
- else if (state.currentPane === 'diff') {
533
- this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
534
- }
535
- return;
536
609
  }
537
- if (state.bottomTab === 'explorer') {
538
- if (state.currentPane === 'explorer') {
610
+ else if (tab === 'explorer') {
611
+ if (direction === -1)
612
+ this.navigateExplorerUp();
613
+ else
539
614
  this.navigateExplorerDown();
540
- }
541
- else if (state.currentPane === 'diff') {
542
- this.uiState.setExplorerFileScrollOffset(state.explorerFileScrollOffset + 3);
543
- }
544
- return;
545
615
  }
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
- }
616
+ else {
617
+ this.navigateFileList(direction);
556
618
  }
557
- else if (state.currentPane === 'diff') {
558
- this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
619
+ }
620
+ navigateUp() {
621
+ const state = this.uiState.state;
622
+ const isListPane = state.currentPane !== 'diff';
623
+ if (isListPane) {
624
+ this.navigateActiveList(-1);
625
+ }
626
+ else {
627
+ this.scrollActiveDiffPane(-3);
628
+ }
629
+ }
630
+ navigateDown() {
631
+ const state = this.uiState.state;
632
+ const isListPane = state.currentPane !== 'diff';
633
+ if (isListPane) {
634
+ this.navigateActiveList(1);
635
+ }
636
+ else {
637
+ this.scrollActiveDiffPane(3);
559
638
  }
560
639
  }
561
640
  navigateHistoryUp() {
@@ -691,12 +770,26 @@ export class App {
691
770
  this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
692
771
  }
693
772
  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);
773
+ if (this.uiState.state.flatViewMode) {
774
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
775
+ if (flatEntry) {
776
+ // Prefer unstaged entry (shows unstaged diff for partial files), fallback to staged
777
+ const file = flatEntry.unstagedEntry ?? flatEntry.stagedEntry;
778
+ if (file) {
779
+ this.uiState.setDiffScrollOffset(0);
780
+ this.uiState.setSelectedHunkIndex(0);
781
+ this.gitManager?.selectFile(file);
782
+ }
783
+ }
784
+ }
785
+ else {
786
+ const files = this.gitManager?.state.status?.files ?? [];
787
+ const file = getFileAtIndex(files, index);
788
+ if (file) {
789
+ this.uiState.setDiffScrollOffset(0);
790
+ this.uiState.setSelectedHunkIndex(0);
791
+ this.gitManager?.selectFile(file);
792
+ }
700
793
  }
701
794
  }
702
795
  /**
@@ -726,32 +819,64 @@ export class App {
726
819
  async stageSelected() {
727
820
  const files = this.gitManager?.state.status?.files ?? [];
728
821
  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);
822
+ if (this.uiState.state.flatViewMode) {
823
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
824
+ if (!flatEntry)
825
+ return;
826
+ // Stage: operate on the unstaged entry if available
827
+ const file = flatEntry.unstagedEntry;
828
+ if (file) {
829
+ this.pendingFlatSelectionPath = flatEntry.path;
830
+ await this.gitManager?.stage(file);
831
+ }
832
+ }
833
+ else {
834
+ const selectedFile = getFileAtIndex(files, index);
835
+ if (selectedFile && !selectedFile.staged) {
836
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
837
+ await this.gitManager?.stage(selectedFile);
838
+ }
733
839
  }
734
840
  }
735
841
  async unstageSelected() {
736
842
  const files = this.gitManager?.state.status?.files ?? [];
737
843
  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);
844
+ if (this.uiState.state.flatViewMode) {
845
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
846
+ if (!flatEntry)
847
+ return;
848
+ const file = flatEntry.stagedEntry;
849
+ if (file) {
850
+ this.pendingFlatSelectionPath = flatEntry.path;
851
+ await this.gitManager?.unstage(file);
852
+ }
853
+ }
854
+ else {
855
+ const selectedFile = getFileAtIndex(files, index);
856
+ if (selectedFile?.staged) {
857
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
858
+ await this.gitManager?.unstage(selectedFile);
859
+ }
742
860
  }
743
861
  }
744
862
  async toggleSelected() {
745
- const files = this.gitManager?.state.status?.files ?? [];
746
863
  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);
864
+ if (this.uiState.state.flatViewMode) {
865
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
866
+ if (flatEntry)
867
+ await this.toggleFlatEntry(flatEntry);
868
+ }
869
+ else {
870
+ const files = this.gitManager?.state.status?.files ?? [];
871
+ const selectedFile = getFileAtIndex(files, index);
872
+ if (selectedFile) {
873
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
874
+ if (selectedFile.staged) {
875
+ await this.gitManager?.unstage(selectedFile);
876
+ }
877
+ else {
878
+ await this.gitManager?.stage(selectedFile);
879
+ }
755
880
  }
756
881
  }
757
882
  }
@@ -770,18 +895,120 @@ export class App {
770
895
  });
771
896
  this.activeModal.focus();
772
897
  }
898
+ // Hunk navigation and staging
899
+ navigateNextHunk() {
900
+ const current = this.uiState.state.selectedHunkIndex;
901
+ if (this.bottomPaneHunkCount > 0 && current < this.bottomPaneHunkCount - 1) {
902
+ this.uiState.setSelectedHunkIndex(current + 1);
903
+ this.scrollHunkIntoView(current + 1);
904
+ }
905
+ }
906
+ navigatePrevHunk() {
907
+ const current = this.uiState.state.selectedHunkIndex;
908
+ if (current > 0) {
909
+ this.uiState.setSelectedHunkIndex(current - 1);
910
+ this.scrollHunkIntoView(current - 1);
911
+ }
912
+ }
913
+ scrollHunkIntoView(hunkIndex) {
914
+ const boundary = this.bottomPaneHunkBoundaries[hunkIndex];
915
+ if (!boundary)
916
+ return;
917
+ const scrollOffset = this.uiState.state.diffScrollOffset;
918
+ const visibleHeight = this.layout.dimensions.bottomPaneHeight;
919
+ // If hunk header is outside the visible area, scroll so it's at top
920
+ if (boundary.startRow < scrollOffset || boundary.startRow >= scrollOffset + visibleHeight) {
921
+ this.uiState.setDiffScrollOffset(boundary.startRow);
922
+ }
923
+ }
924
+ selectHunkAtRow(visualRow) {
925
+ if (this.uiState.state.bottomTab !== 'diff')
926
+ return;
927
+ if (this.bottomPaneHunkBoundaries.length === 0)
928
+ return;
929
+ // Focus the diff pane so the hunk gutter appears
930
+ this.uiState.setPane('diff');
931
+ const absoluteRow = this.uiState.state.diffScrollOffset + visualRow;
932
+ for (let i = 0; i < this.bottomPaneHunkBoundaries.length; i++) {
933
+ const b = this.bottomPaneHunkBoundaries[i];
934
+ if (absoluteRow >= b.startRow && absoluteRow < b.endRow) {
935
+ this.uiState.setSelectedHunkIndex(i);
936
+ return;
937
+ }
938
+ }
939
+ }
940
+ async toggleCurrentHunk() {
941
+ const selectedFile = this.gitManager?.state.selectedFile;
942
+ if (!selectedFile || selectedFile.status === 'untracked')
943
+ return;
944
+ if (this.uiState.state.flatViewMode) {
945
+ await this.toggleCurrentHunkFlat();
946
+ }
947
+ else {
948
+ await this.toggleCurrentHunkCategorized(selectedFile);
949
+ }
950
+ }
951
+ async toggleCurrentHunkFlat() {
952
+ const mapping = this.combinedHunkMapping[this.uiState.state.selectedHunkIndex];
953
+ if (!mapping)
954
+ return;
955
+ const combined = this.gitManager?.state.combinedFileDiffs;
956
+ if (!combined)
957
+ return;
958
+ const rawDiff = mapping.source === 'unstaged' ? combined.unstaged.raw : combined.staged.raw;
959
+ const patch = extractHunkPatch(rawDiff, mapping.hunkIndex);
960
+ if (!patch)
961
+ return;
962
+ // Preserve hunk index across refresh — file stays selected via path-only fallback
963
+ this.pendingHunkIndex = this.uiState.state.selectedHunkIndex;
964
+ if (mapping.source === 'staged') {
965
+ await this.gitManager?.unstageHunk(patch);
966
+ }
967
+ else {
968
+ await this.gitManager?.stageHunk(patch);
969
+ }
970
+ }
971
+ async toggleCurrentHunkCategorized(selectedFile) {
972
+ const rawDiff = this.gitManager?.state.diff?.raw;
973
+ if (!rawDiff)
974
+ return;
975
+ const patch = extractHunkPatch(rawDiff, this.uiState.state.selectedHunkIndex);
976
+ if (!patch)
977
+ return;
978
+ const files = this.gitManager?.state.status?.files ?? [];
979
+ this.pendingSelectionAnchor = getCategoryForIndex(files, this.uiState.state.selectedIndex);
980
+ if (selectedFile.staged) {
981
+ await this.gitManager?.unstageHunk(patch);
982
+ }
983
+ else {
984
+ await this.gitManager?.stageHunk(patch);
985
+ }
986
+ }
773
987
  async openFileFinder() {
774
988
  const allPaths = (await this.explorerManager?.getAllFilePaths()) ?? [];
775
989
  if (allPaths.length === 0)
776
990
  return;
777
991
  this.activeModal = new FileFinder(this.screen, allPaths, async (selectedPath) => {
778
992
  this.activeModal = null;
993
+ // Switch to explorer tab if not already there
994
+ if (this.uiState.state.bottomTab !== 'explorer') {
995
+ this.uiState.setTab('explorer');
996
+ }
779
997
  // Navigate to the selected file in explorer
780
998
  const success = await this.explorerManager?.navigateToPath(selectedPath);
781
999
  if (success) {
782
- // Reset scroll to show selected file
783
- this.uiState.setExplorerScrollOffset(0);
1000
+ // Sync selected index from explorer manager
1001
+ const selectedIndex = this.explorerManager?.state.selectedIndex ?? 0;
1002
+ this.uiState.setExplorerSelectedIndex(selectedIndex);
784
1003
  this.uiState.setExplorerFileScrollOffset(0);
1004
+ // Scroll to make selected file visible
1005
+ const visibleHeight = this.layout.dimensions.topPaneHeight;
1006
+ if (selectedIndex >= visibleHeight) {
1007
+ this.uiState.setExplorerScrollOffset(selectedIndex - Math.floor(visibleHeight / 2));
1008
+ }
1009
+ else {
1010
+ this.uiState.setExplorerScrollOffset(0);
1011
+ }
785
1012
  }
786
1013
  this.render();
787
1014
  }, () => {
@@ -800,6 +1027,7 @@ export class App {
800
1027
  const willEnable = !this.uiState.state.mouseEnabled;
801
1028
  this.uiState.toggleMouse();
802
1029
  // Access program for terminal mouse control (not on screen's TS types)
1030
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
803
1031
  const program = this.screen.program;
804
1032
  if (willEnable) {
805
1033
  program.enableMouse();
@@ -842,6 +1070,13 @@ export class App {
842
1070
  this.updateHeader();
843
1071
  this.updateTopPane();
844
1072
  this.updateBottomPane();
1073
+ // Restore hunk index after diff refresh (e.g. after hunk toggle in flat mode)
1074
+ if (this.pendingHunkIndex !== null && this.bottomPaneHunkCount > 0) {
1075
+ const restored = Math.min(this.pendingHunkIndex, this.bottomPaneHunkCount - 1);
1076
+ this.pendingHunkIndex = null;
1077
+ this.uiState.setSelectedHunkIndex(restored);
1078
+ this.updateBottomPane(); // Re-render with correct hunk selection
1079
+ }
845
1080
  this.updateFooter();
846
1081
  this.screen.render();
847
1082
  }
@@ -854,7 +1089,12 @@ export class App {
854
1089
  updateTopPane() {
855
1090
  const state = this.uiState.state;
856
1091
  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);
1092
+ const files = this.gitManager?.state.status?.files ?? [];
1093
+ // Build and cache flat file list when in flat mode
1094
+ if (state.flatViewMode) {
1095
+ this.cachedFlatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
1096
+ }
1097
+ 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
1098
  this.layout.topPane.setContent(content);
859
1099
  }
860
1100
  updateBottomPane() {
@@ -864,8 +1104,17 @@ export class App {
864
1104
  const stagedCount = files.filter((f) => f.staged).length;
865
1105
  // Update staged count for commit validation
866
1106
  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);
1107
+ // Pass selectedHunkIndex and staged status only when diff pane is focused on diff tab
1108
+ const diffPaneFocused = state.bottomTab === 'diff' && state.currentPane === 'diff';
1109
+ const hunkIndex = diffPaneFocused ? state.selectedHunkIndex : undefined;
1110
+ const isFileStaged = diffPaneFocused ? this.gitManager?.state.selectedFile?.staged : undefined;
1111
+ 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);
868
1112
  this.bottomPaneTotalRows = totalRows;
1113
+ this.bottomPaneHunkCount = hunkCount;
1114
+ this.bottomPaneHunkBoundaries = hunkBoundaries;
1115
+ this.combinedHunkMapping = hunkMapping ?? [];
1116
+ // Silently clamp hunk index to actual count (handles async refresh after hunk staging)
1117
+ this.uiState.clampSelectedHunkIndex(hunkCount);
869
1118
  this.layout.bottomPane.setContent(content);
870
1119
  // Manage commit textarea visibility
871
1120
  if (this.commitTextarea) {
@@ -880,7 +1129,7 @@ export class App {
880
1129
  updateFooter() {
881
1130
  const state = this.uiState.state;
882
1131
  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);
1132
+ const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, this.followMode?.isEnabled ?? false, this.explorerManager?.showOnlyChanges ?? false, width, state.currentPane);
884
1133
  this.layout.footerBox.setContent(content);
885
1134
  }
886
1135
  /**