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/.dependency-cruiser.cjs +67 -0
- package/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +15 -0
- package/README.md +43 -35
- package/bun.lock +60 -4
- package/dist/App.js +378 -129
- package/dist/KeyBindings.js +59 -9
- package/dist/MouseHandlers.js +56 -20
- package/dist/core/ExplorerStateManager.js +17 -38
- package/dist/core/GitStateManager.js +111 -46
- package/dist/git/diff.js +99 -18
- package/dist/git/status.js +16 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +53 -47
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +22 -0
- package/dist/ui/PaneRenderers.js +33 -13
- package/dist/ui/modals/FileFinder.js +26 -65
- package/dist/ui/modals/HotkeysModal.js +12 -3
- package/dist/ui/modals/ThemePicker.js +1 -2
- package/dist/ui/widgets/CommitPanel.js +1 -1
- package/dist/ui/widgets/CompareListView.js +44 -23
- package/dist/ui/widgets/DiffView.js +216 -170
- package/dist/ui/widgets/ExplorerView.js +50 -54
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -15
- package/dist/ui/widgets/Header.js +14 -6
- package/dist/ui/widgets/fileRowFormatters.js +73 -0
- package/dist/utils/ansiTruncate.js +0 -1
- package/dist/utils/displayRows.js +101 -21
- package/dist/utils/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +0 -1
- package/metrics/v0.2.2.json +229 -0
- package/package.json +6 -2
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
|
-
|
|
181
|
+
getExplorerManager: () => this.explorerManager,
|
|
170
182
|
commitFlowState: this.commitFlowState,
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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 === '
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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 (
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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 (
|
|
529
|
-
if (
|
|
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 (
|
|
538
|
-
if (
|
|
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
|
-
|
|
547
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
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
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
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
|
-
//
|
|
783
|
-
this.
|
|
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
|
|
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
|
-
|
|
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
|
/**
|