diffstalker 0.2.0 → 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.
Files changed (46) 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/.github/workflows/release.yml +8 -0
  5. package/README.md +43 -35
  6. package/bun.lock +82 -3
  7. package/dist/App.js +555 -552
  8. package/dist/FollowMode.js +85 -0
  9. package/dist/KeyBindings.js +228 -0
  10. package/dist/MouseHandlers.js +192 -0
  11. package/dist/core/ExplorerStateManager.js +423 -78
  12. package/dist/core/GitStateManager.js +260 -119
  13. package/dist/git/diff.js +102 -17
  14. package/dist/git/status.js +16 -54
  15. package/dist/git/test-helpers.js +67 -0
  16. package/dist/index.js +60 -53
  17. package/dist/ipc/CommandClient.js +6 -7
  18. package/dist/state/UIState.js +39 -4
  19. package/dist/ui/PaneRenderers.js +76 -0
  20. package/dist/ui/modals/FileFinder.js +193 -0
  21. package/dist/ui/modals/HotkeysModal.js +12 -3
  22. package/dist/ui/modals/ThemePicker.js +1 -2
  23. package/dist/ui/widgets/CommitPanel.js +1 -1
  24. package/dist/ui/widgets/CompareListView.js +123 -80
  25. package/dist/ui/widgets/DiffView.js +228 -180
  26. package/dist/ui/widgets/ExplorerContent.js +15 -28
  27. package/dist/ui/widgets/ExplorerView.js +148 -43
  28. package/dist/ui/widgets/FileList.js +62 -95
  29. package/dist/ui/widgets/FlatFileList.js +65 -0
  30. package/dist/ui/widgets/Footer.js +25 -11
  31. package/dist/ui/widgets/Header.js +17 -52
  32. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  33. package/dist/utils/ansiTruncate.js +0 -1
  34. package/dist/utils/displayRows.js +101 -21
  35. package/dist/utils/fileCategories.js +37 -0
  36. package/dist/utils/fileTree.js +148 -0
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +15 -0
  40. package/metrics/.gitkeep +0 -0
  41. package/metrics/v0.2.1.json +268 -0
  42. package/metrics/v0.2.2.json +229 -0
  43. package/package.json +9 -2
  44. package/dist/utils/ansiToBlessed.js +0 -125
  45. package/dist/utils/mouseCoordinates.js +0 -165
  46. package/dist/utils/rowCalculations.js +0 -246
package/dist/App.js CHANGED
@@ -1,24 +1,27 @@
1
1
  import blessed from 'neo-blessed';
2
- import { LayoutManager, SPLIT_RATIO_STEP } from './ui/Layout.js';
2
+ import { LayoutManager } from './ui/Layout.js';
3
+ import { setupKeyBindings } from './KeyBindings.js';
4
+ import { renderTopPane, renderBottomPane } from './ui/PaneRenderers.js';
5
+ import { setupMouseHandlers } from './MouseHandlers.js';
6
+ import { FollowMode } from './FollowMode.js';
3
7
  import { formatHeader } from './ui/widgets/Header.js';
4
8
  import { formatFooter } from './ui/widgets/Footer.js';
5
- import { formatFileList, getFileAtIndex, getFileListTotalRows, getFileIndexFromRow, getRowFromFileIndex, } from './ui/widgets/FileList.js';
6
- import { formatDiff, formatHistoryDiff } from './ui/widgets/DiffView.js';
7
- import { formatCommitPanel } from './ui/widgets/CommitPanel.js';
8
- import { formatHistoryView, getCommitAtIndex, } from './ui/widgets/HistoryView.js';
9
- import { formatCompareListView, getCompareListTotalRows, getNextCompareSelection, getRowFromCompareSelection, getCompareSelectionFromRow, } from './ui/widgets/CompareListView.js';
10
- import { formatExplorerView, getExplorerTotalRows, } from './ui/widgets/ExplorerView.js';
11
- import { formatExplorerContent, getExplorerContentTotalRows, } from './ui/widgets/ExplorerContent.js';
9
+ import { getFileAtIndex, getRowFromFileIndex } from './ui/widgets/FileList.js';
10
+ import { getCommitAtIndex } from './ui/widgets/HistoryView.js';
11
+ import { getNextCompareSelection, getRowFromCompareSelection, } from './ui/widgets/CompareListView.js';
12
12
  import { ExplorerStateManager, } from './core/ExplorerStateManager.js';
13
13
  import { ThemePicker } from './ui/modals/ThemePicker.js';
14
14
  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
+ import { FileFinder } from './ui/modals/FileFinder.js';
17
18
  import { CommitFlowState } from './state/CommitFlowState.js';
18
19
  import { UIState } from './state/UIState.js';
19
20
  import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
20
- import { FilePathWatcher } from './core/FilePathWatcher.js';
21
21
  import { saveConfig } from './config.js';
22
+ import { getCategoryForIndex, getIndexForCategoryPosition, } from './utils/fileCategories.js';
23
+ import { buildFlatFileList, getFlatFileAtIndex, getFlatFileIndexByPath, } from './utils/flatFileList.js';
24
+ import { extractHunkPatch } from './git/diff.js';
22
25
  /**
23
26
  * Main application controller.
24
27
  * Coordinates between GitStateManager, UIState, and blessed widgets.
@@ -28,21 +31,29 @@ export class App {
28
31
  layout;
29
32
  uiState;
30
33
  gitManager = null;
31
- fileWatcher = null;
34
+ followMode = null;
32
35
  explorerManager = null;
33
36
  config;
34
37
  commandServer;
35
38
  // Current state
36
39
  repoPath;
37
- watcherState = { enabled: false };
38
40
  currentTheme;
39
41
  // Commit flow state
40
42
  commitFlowState;
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 = [];
50
+ // Selection anchor: remembers category + position before stage/unstage
51
+ pendingSelectionAnchor = null;
52
+ // Flat view mode state
53
+ cachedFlatFiles = [];
54
+ pendingFlatSelectionPath = null;
55
+ pendingHunkIndex = null;
56
+ combinedHunkMapping = [];
46
57
  constructor(options) {
47
58
  this.config = options.config;
48
59
  this.commandServer = options.commandServer ?? null;
@@ -116,12 +127,16 @@ export class App {
116
127
  // Setup keyboard handlers
117
128
  this.setupKeyboardHandlers();
118
129
  // Setup mouse handlers
119
- this.setupMouseHandlers();
130
+ this.setupMouseEventHandlers();
120
131
  // Setup state change listeners
121
132
  this.setupStateListeners();
122
- // Setup file watcher if enabled
133
+ // Setup follow mode if enabled
123
134
  if (this.config.watcherEnabled) {
124
- this.setupFileWatcher();
135
+ this.followMode = new FollowMode(this.config.targetFile, () => this.repoPath, {
136
+ onRepoChange: (newPath, state) => this.handleFollowRepoChange(newPath, state),
137
+ onFileNavigate: (rawContent) => this.handleFollowFileNavigate(rawContent),
138
+ });
139
+ this.followMode.start();
125
140
  }
126
141
  // Setup IPC command handler if command server provided
127
142
  if (this.commandServer) {
@@ -133,326 +148,98 @@ export class App {
133
148
  this.render();
134
149
  }
135
150
  setupKeyboardHandlers() {
136
- // Quit
137
- this.screen.key(['q', 'C-c'], () => {
138
- this.exit();
139
- });
140
- // Navigation (skip if modal is open - modal handles its own keys)
141
- this.screen.key(['j', 'down'], () => {
142
- if (this.activeModal)
143
- return;
144
- this.navigateDown();
145
- });
146
- this.screen.key(['k', 'up'], () => {
147
- if (this.activeModal)
148
- return;
149
- this.navigateUp();
150
- });
151
- // Tab switching (skip if modal is open)
152
- this.screen.key(['1'], () => {
153
- if (this.activeModal)
154
- return;
155
- this.uiState.setTab('diff');
156
- });
157
- this.screen.key(['2'], () => {
158
- if (this.activeModal)
159
- return;
160
- this.uiState.setTab('commit');
161
- });
162
- this.screen.key(['3'], () => {
163
- if (this.activeModal)
164
- return;
165
- this.uiState.setTab('history');
166
- });
167
- this.screen.key(['4'], () => {
168
- if (this.activeModal)
169
- return;
170
- this.uiState.setTab('compare');
171
- });
172
- this.screen.key(['5'], () => {
173
- if (this.activeModal)
174
- return;
175
- this.uiState.setTab('explorer');
176
- });
177
- // Pane toggle (skip if modal is open)
178
- this.screen.key(['tab'], () => {
179
- if (this.activeModal)
180
- return;
181
- this.uiState.togglePane();
182
- });
183
- // Staging operations (skip if modal is open)
184
- this.screen.key(['s'], () => {
185
- if (this.activeModal)
186
- return;
187
- this.stageSelected();
188
- });
189
- this.screen.key(['S-u'], () => {
190
- if (this.activeModal)
191
- return;
192
- this.unstageSelected();
193
- });
194
- this.screen.key(['S-a'], () => {
195
- if (this.activeModal)
196
- return;
197
- this.stageAll();
198
- });
199
- this.screen.key(['S-z'], () => {
200
- if (this.activeModal)
201
- return;
202
- this.unstageAll();
203
- });
204
- // Select/toggle (skip if modal is open)
205
- this.screen.key(['enter', 'space'], () => {
206
- if (this.activeModal)
207
- return;
208
- const state = this.uiState.state;
209
- if (state.bottomTab === 'explorer' && state.currentPane === 'explorer') {
210
- this.enterExplorerDirectory();
211
- }
212
- else {
213
- this.toggleSelected();
214
- }
215
- });
216
- // Explorer: go up directory (skip if modal is open)
217
- this.screen.key(['backspace'], () => {
218
- if (this.activeModal)
219
- return;
220
- const state = this.uiState.state;
221
- if (state.bottomTab === 'explorer' && state.currentPane === 'explorer') {
222
- this.goExplorerUp();
223
- }
224
- });
225
- // Commit (skip if modal is open)
226
- this.screen.key(['c'], () => {
227
- if (this.activeModal)
228
- return;
229
- this.uiState.setTab('commit');
230
- });
231
- // Commit panel specific keys (only when on commit tab)
232
- this.screen.key(['i'], () => {
233
- if (this.uiState.state.bottomTab === 'commit' && !this.commitFlowState.state.inputFocused) {
234
- this.focusCommitInput();
235
- }
236
- });
237
- this.screen.key(['a'], () => {
238
- if (this.uiState.state.bottomTab === 'commit' && !this.commitFlowState.state.inputFocused) {
239
- this.commitFlowState.toggleAmend();
240
- this.render();
241
- }
242
- });
243
- this.screen.key(['escape'], () => {
244
- if (this.uiState.state.bottomTab === 'commit') {
245
- if (this.commitFlowState.state.inputFocused) {
246
- this.unfocusCommitInput();
247
- }
248
- else {
249
- this.uiState.setTab('diff');
250
- }
251
- }
252
- });
253
- // Refresh
254
- this.screen.key(['r'], () => this.refresh());
255
- // Display toggles
256
- this.screen.key(['w'], () => this.uiState.toggleWrapMode());
257
- this.screen.key(['m'], () => this.toggleMouseMode());
258
- this.screen.key(['S-t'], () => this.uiState.toggleAutoTab());
259
- // Split ratio adjustments
260
- this.screen.key(['-', '_'], () => {
261
- this.uiState.adjustSplitRatio(-SPLIT_RATIO_STEP);
262
- this.layout.setSplitRatio(this.uiState.state.splitRatio);
263
- this.render();
264
- });
265
- this.screen.key(['=', '+'], () => {
266
- this.uiState.adjustSplitRatio(SPLIT_RATIO_STEP);
267
- this.layout.setSplitRatio(this.uiState.state.splitRatio);
268
- this.render();
269
- });
270
- // Theme picker
271
- this.screen.key(['t'], () => this.uiState.openModal('theme'));
272
- // Hotkeys modal
273
- this.screen.key(['?'], () => this.uiState.toggleModal('hotkeys'));
274
- // Follow toggle
275
- this.screen.key(['f'], () => this.toggleFollow());
276
- // Compare view: base branch picker
277
- this.screen.key(['b'], () => {
278
- if (this.uiState.state.bottomTab === 'compare') {
279
- this.uiState.openModal('baseBranch');
280
- }
281
- });
282
- // Compare view: toggle uncommitted
283
- this.screen.key(['u'], () => {
284
- if (this.uiState.state.bottomTab === 'compare') {
285
- this.uiState.toggleIncludeUncommitted();
286
- const includeUncommitted = this.uiState.state.includeUncommitted;
287
- this.gitManager?.refreshCompareDiff(includeUncommitted);
288
- }
289
- });
290
- // Discard changes (with confirmation)
291
- this.screen.key(['d'], () => {
292
- if (this.uiState.state.bottomTab === 'diff') {
293
- const files = this.gitManager?.state.status?.files ?? [];
294
- const selectedIndex = this.uiState.state.selectedIndex;
295
- const selectedFile = files[selectedIndex];
296
- // Only allow discard for unstaged modified files
297
- if (selectedFile && !selectedFile.staged && selectedFile.status !== 'untracked') {
298
- this.showDiscardConfirm(selectedFile);
299
- }
300
- }
151
+ setupKeyBindings(this.screen, {
152
+ exit: () => this.exit(),
153
+ navigateDown: () => this.navigateDown(),
154
+ navigateUp: () => this.navigateUp(),
155
+ stageSelected: () => this.stageSelected(),
156
+ unstageSelected: () => this.unstageSelected(),
157
+ stageAll: () => this.stageAll(),
158
+ unstageAll: () => this.unstageAll(),
159
+ toggleSelected: () => this.toggleSelected(),
160
+ enterExplorerDirectory: () => this.enterExplorerDirectory(),
161
+ goExplorerUp: () => this.goExplorerUp(),
162
+ openFileFinder: () => this.openFileFinder(),
163
+ focusCommitInput: () => this.focusCommitInput(),
164
+ unfocusCommitInput: () => this.unfocusCommitInput(),
165
+ refresh: () => this.refresh(),
166
+ toggleMouseMode: () => this.toggleMouseMode(),
167
+ toggleFollow: () => this.toggleFollow(),
168
+ showDiscardConfirm: (file) => this.showDiscardConfirm(file),
169
+ render: () => this.render(),
170
+ toggleCurrentHunk: () => this.toggleCurrentHunk(),
171
+ navigateNextHunk: () => this.navigateNextHunk(),
172
+ navigatePrevHunk: () => this.navigatePrevHunk(),
173
+ }, {
174
+ hasActiveModal: () => this.activeModal !== null,
175
+ getBottomTab: () => this.uiState.state.bottomTab,
176
+ getCurrentPane: () => this.uiState.state.currentPane,
177
+ isCommitInputFocused: () => this.commitFlowState.state.inputFocused,
178
+ getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
179
+ getSelectedIndex: () => this.uiState.state.selectedIndex,
180
+ uiState: this.uiState,
181
+ getExplorerManager: () => this.explorerManager,
182
+ commitFlowState: this.commitFlowState,
183
+ getGitManager: () => this.gitManager,
184
+ layout: this.layout,
185
+ getCachedFlatFiles: () => this.cachedFlatFiles,
301
186
  });
302
187
  }
303
- setupMouseHandlers() {
304
- const SCROLL_AMOUNT = 3;
305
- // Mouse wheel on top pane
306
- this.layout.topPane.on('wheeldown', () => {
307
- this.handleTopPaneScroll(SCROLL_AMOUNT);
308
- });
309
- this.layout.topPane.on('wheelup', () => {
310
- this.handleTopPaneScroll(-SCROLL_AMOUNT);
311
- });
312
- // Mouse wheel on bottom pane
313
- this.layout.bottomPane.on('wheeldown', () => {
314
- this.handleBottomPaneScroll(SCROLL_AMOUNT);
315
- });
316
- this.layout.bottomPane.on('wheelup', () => {
317
- this.handleBottomPaneScroll(-SCROLL_AMOUNT);
318
- });
319
- // Click on top pane to select item
320
- this.layout.topPane.on('click', (mouse) => {
321
- // Convert screen Y to pane-relative row (blessed click coords are screen-relative)
322
- const clickedRow = this.layout.screenYToTopPaneRow(mouse.y);
323
- if (clickedRow >= 0) {
324
- this.handleTopPaneClick(clickedRow);
325
- }
326
- });
327
- // Click on footer for tabs and toggles
328
- this.layout.footerBox.on('click', (mouse) => {
329
- this.handleFooterClick(mouse.x);
188
+ setupMouseEventHandlers() {
189
+ setupMouseHandlers(this.layout, {
190
+ selectHistoryCommitByIndex: (index) => this.selectHistoryCommitByIndex(index),
191
+ selectCompareItem: (selection) => this.selectCompareItem(selection),
192
+ selectFileByIndex: (index) => this.selectFileByIndex(index),
193
+ toggleFileByIndex: (index) => this.toggleFileByIndex(index),
194
+ enterExplorerDirectory: () => this.enterExplorerDirectory(),
195
+ toggleMouseMode: () => this.toggleMouseMode(),
196
+ toggleFollow: () => this.toggleFollow(),
197
+ selectHunkAtRow: (row) => this.selectHunkAtRow(row),
198
+ render: () => this.render(),
199
+ }, {
200
+ uiState: this.uiState,
201
+ getExplorerManager: () => this.explorerManager,
202
+ getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
203
+ getHistoryCommitCount: () => this.gitManager?.historyState.commits.length ?? 0,
204
+ getCompareCommits: () => this.gitManager?.compareState?.compareDiff?.commits ?? [],
205
+ getCompareFiles: () => this.gitManager?.compareState?.compareDiff?.files ?? [],
206
+ getBottomPaneTotalRows: () => this.bottomPaneTotalRows,
207
+ getScreenWidth: () => this.screen.width || 80,
208
+ getCachedFlatFiles: () => this.cachedFlatFiles,
330
209
  });
331
210
  }
332
- handleTopPaneClick(row) {
333
- const state = this.uiState.state;
334
- if (state.bottomTab === 'history') {
335
- const index = state.historyScrollOffset + row;
336
- this.uiState.setHistorySelectedIndex(index);
337
- this.selectHistoryCommitByIndex(index);
338
- }
339
- else if (state.bottomTab === 'compare') {
340
- // For compare view, need to map row to selection
341
- const compareState = this.gitManager?.compareState;
342
- const commits = compareState?.compareDiff?.commits ?? [];
343
- const files = compareState?.compareDiff?.files ?? [];
344
- const selection = getCompareSelectionFromRow(state.compareScrollOffset + row, commits, files);
345
- if (selection) {
346
- this.selectCompareItem(selection);
347
- }
348
- }
349
- else if (state.bottomTab === 'explorer') {
350
- const index = state.explorerScrollOffset + row;
351
- this.explorerManager?.selectIndex(index);
352
- this.uiState.setExplorerSelectedIndex(index);
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);
353
219
  }
354
220
  else {
355
- // Diff tab - select file
356
- const files = this.gitManager?.state.status?.files ?? [];
357
- // Account for section headers in file list
358
- const fileIndex = getFileIndexFromRow(row + state.fileListScrollOffset, files);
359
- if (fileIndex !== null && fileIndex >= 0) {
360
- this.uiState.setSelectedIndex(fileIndex);
361
- this.selectFileByIndex(fileIndex);
362
- }
363
- }
364
- }
365
- handleFooterClick(x) {
366
- const width = this.screen.width || 80;
367
- // Footer layout: left side has toggles, right side has tabs
368
- // Tabs are right-aligned, so we calculate from the right
369
- // Tab format: [1]Diff [2]Commit [3]History [4]Compare [5]Explorer
370
- // Approximate positions from right edge
371
- const tabPositions = [
372
- { tab: 'explorer', label: '[5]Explorer', width: 11 },
373
- { tab: 'compare', label: '[4]Compare', width: 10 },
374
- { tab: 'history', label: '[3]History', width: 10 },
375
- { tab: 'commit', label: '[2]Commit', width: 9 },
376
- { tab: 'diff', label: '[1]Diff', width: 7 },
377
- ];
378
- let rightEdge = width;
379
- for (const { tab, width: tabWidth } of tabPositions) {
380
- const leftEdge = rightEdge - tabWidth - 1; // -1 for space
381
- if (x >= leftEdge && x < rightEdge) {
382
- this.uiState.setTab(tab);
383
- return;
384
- }
385
- rightEdge = leftEdge;
386
- }
387
- // Left side toggles (approximate positions)
388
- // Format: ? [scroll] [auto] [wrap] [dots]
389
- if (x >= 2 && x <= 9) {
390
- // [scroll] or m:[select]
391
- this.toggleMouseMode();
392
- }
393
- else if (x >= 11 && x <= 16) {
394
- // [auto]
395
- this.uiState.toggleAutoTab();
396
- }
397
- else if (x >= 18 && x <= 23) {
398
- // [wrap]
399
- this.uiState.toggleWrapMode();
400
- }
401
- else if (x >= 25 && x <= 30 && this.uiState.state.bottomTab === 'explorer') {
402
- // [dots] - only visible in explorer
403
- this.uiState.toggleMiddleDots();
404
- }
405
- else if (x === 0) {
406
- // ? - open hotkeys
407
- this.uiState.openModal('hotkeys');
221
+ if (entry.unstagedEntry)
222
+ await this.gitManager?.stage(entry.unstagedEntry);
408
223
  }
409
224
  }
410
- handleTopPaneScroll(delta) {
411
- const state = this.uiState.state;
412
- const visibleHeight = this.layout.dimensions.topPaneHeight;
413
- if (state.bottomTab === 'history') {
414
- const totalRows = this.gitManager?.historyState.commits.length ?? 0;
415
- const maxOffset = Math.max(0, totalRows - visibleHeight);
416
- const newOffset = Math.min(maxOffset, Math.max(0, state.historyScrollOffset + delta));
417
- this.uiState.setHistoryScrollOffset(newOffset);
418
- }
419
- else if (state.bottomTab === 'compare') {
420
- const compareState = this.gitManager?.compareState;
421
- const totalRows = getCompareListTotalRows(compareState?.compareDiff?.commits ?? [], compareState?.compareDiff?.files ?? []);
422
- const maxOffset = Math.max(0, totalRows - visibleHeight);
423
- const newOffset = Math.min(maxOffset, Math.max(0, state.compareScrollOffset + delta));
424
- this.uiState.setCompareScrollOffset(newOffset);
425
- }
426
- else if (state.bottomTab === 'explorer') {
427
- const totalRows = getExplorerTotalRows(this.explorerManager?.state.items ?? []);
428
- const maxOffset = Math.max(0, totalRows - visibleHeight);
429
- const newOffset = Math.min(maxOffset, Math.max(0, state.explorerScrollOffset + delta));
430
- this.uiState.setExplorerScrollOffset(newOffset);
225
+ async toggleFileByIndex(index) {
226
+ if (this.uiState.state.flatViewMode) {
227
+ const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
228
+ if (flatEntry)
229
+ await this.toggleFlatEntry(flatEntry);
431
230
  }
432
231
  else {
433
232
  const files = this.gitManager?.state.status?.files ?? [];
434
- const totalRows = getFileListTotalRows(files);
435
- const maxOffset = Math.max(0, totalRows - visibleHeight);
436
- const newOffset = Math.min(maxOffset, Math.max(0, state.fileListScrollOffset + delta));
437
- this.uiState.setFileListScrollOffset(newOffset);
438
- }
439
- }
440
- handleBottomPaneScroll(delta) {
441
- const state = this.uiState.state;
442
- const visibleHeight = this.layout.dimensions.bottomPaneHeight;
443
- const width = this.screen.width || 80;
444
- if (state.bottomTab === 'explorer') {
445
- const selectedFile = this.explorerManager?.state.selectedFile;
446
- const totalRows = getExplorerContentTotalRows(selectedFile?.content ?? null, selectedFile?.path ?? null, selectedFile?.truncated ?? false, width, state.wrapMode);
447
- const maxOffset = Math.max(0, totalRows - visibleHeight);
448
- const newOffset = Math.min(maxOffset, Math.max(0, state.explorerFileScrollOffset + delta));
449
- this.uiState.setExplorerFileScrollOffset(newOffset);
450
- }
451
- else {
452
- // Use cached totalRows from last render (single source of truth)
453
- const maxOffset = Math.max(0, this.bottomPaneTotalRows - visibleHeight);
454
- const newOffset = Math.min(maxOffset, Math.max(0, state.diffScrollOffset + delta));
455
- this.uiState.setDiffScrollOffset(newOffset);
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
+ }
242
+ }
456
243
  }
457
244
  }
458
245
  setupStateListeners() {
@@ -462,6 +249,10 @@ export class App {
462
249
  });
463
250
  // Load data when switching tabs
464
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
+ }
465
256
  if (tab === 'history') {
466
257
  this.gitManager?.loadHistory();
467
258
  }
@@ -470,7 +261,7 @@ export class App {
470
261
  }
471
262
  else if (tab === 'explorer') {
472
263
  // Explorer is already loaded on init, but refresh if needed
473
- if (!this.explorerManager?.state.items.length) {
264
+ if (!this.explorerManager?.state.displayRows.length) {
474
265
  this.explorerManager?.loadDirectory('');
475
266
  }
476
267
  }
@@ -532,48 +323,35 @@ export class App {
532
323
  }, 500);
533
324
  });
534
325
  }
535
- setupFileWatcher() {
536
- this.fileWatcher = new FilePathWatcher(this.config.targetFile);
537
- this.fileWatcher.on('path-change', (state) => {
538
- if (state.path && state.path !== this.repoPath) {
539
- this.repoPath = state.path;
540
- this.watcherState = {
541
- enabled: true,
542
- sourceFile: state.sourceFile ?? this.config.targetFile,
543
- rawContent: state.rawContent ?? undefined,
544
- lastUpdate: state.lastUpdate ?? undefined,
545
- };
546
- this.initGitManager();
547
- this.render();
548
- }
549
- // Navigate to the followed file if it's within the repo
550
- if (state.rawContent) {
551
- this.navigateToFile(state.rawContent);
552
- this.render();
553
- }
554
- });
555
- this.watcherState = {
556
- enabled: true,
557
- sourceFile: this.config.targetFile,
558
- };
559
- this.fileWatcher.start();
560
- // Navigate to the initially followed file
561
- const initialState = this.fileWatcher.state;
562
- if (initialState.rawContent) {
563
- this.watcherState.rawContent = initialState.rawContent;
564
- this.navigateToFile(initialState.rawContent);
565
- }
326
+ handleFollowRepoChange(newPath, _state) {
327
+ const oldRepoPath = this.repoPath;
328
+ this.repoPath = newPath;
329
+ this.initGitManager(oldRepoPath);
330
+ this.resetRepoSpecificState();
331
+ this.loadCurrentTabData();
332
+ this.render();
333
+ }
334
+ handleFollowFileNavigate(rawContent) {
335
+ this.navigateToFile(rawContent);
336
+ this.render();
566
337
  }
567
- initGitManager() {
338
+ initGitManager(oldRepoPath) {
568
339
  // Clean up existing manager
569
340
  if (this.gitManager) {
570
341
  this.gitManager.removeAllListeners();
571
- removeManagerForRepo(this.repoPath);
342
+ // Use oldRepoPath if provided (when switching repos), otherwise use current path
343
+ removeManagerForRepo(oldRepoPath ?? this.repoPath);
572
344
  }
573
345
  // Get or create manager for this repo
574
346
  this.gitManager = getManagerForRepo(this.repoPath);
575
347
  // Listen to state changes
576
348
  this.gitManager.on('state-change', () => {
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();
353
+ }
354
+ this.updateExplorerGitStatus();
577
355
  this.render();
578
356
  });
579
357
  this.gitManager.on('history-state-change', (historyState) => {
@@ -598,6 +376,51 @@ export class App {
598
376
  // Initialize explorer manager
599
377
  this.initExplorerManager();
600
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
+ }
601
424
  initExplorerManager() {
602
425
  // Clean up existing manager
603
426
  if (this.explorerManager) {
@@ -607,6 +430,7 @@ export class App {
607
430
  const options = {
608
431
  hideHidden: true,
609
432
  hideGitignored: true,
433
+ showOnlyChanges: false,
610
434
  };
611
435
  this.explorerManager = new ExplorerStateManager(this.repoPath, options);
612
436
  // Listen to state changes
@@ -615,6 +439,58 @@ export class App {
615
439
  });
616
440
  // Load root directory
617
441
  this.explorerManager.loadDirectory('');
442
+ // Update git status after tree is loaded
443
+ this.updateExplorerGitStatus();
444
+ }
445
+ /**
446
+ * Build git status map and update explorer.
447
+ */
448
+ updateExplorerGitStatus() {
449
+ if (!this.explorerManager || !this.gitManager)
450
+ return;
451
+ const files = this.gitManager.state.status?.files ?? [];
452
+ const statusMap = {
453
+ files: new Map(),
454
+ directories: new Set(),
455
+ };
456
+ for (const file of files) {
457
+ statusMap.files.set(file.path, { status: file.status, staged: file.staged });
458
+ // Mark all parent directories as having changed children
459
+ const parts = file.path.split('/');
460
+ let dirPath = '';
461
+ for (let i = 0; i < parts.length - 1; i++) {
462
+ dirPath = dirPath ? `${dirPath}/${parts[i]}` : parts[i];
463
+ statusMap.directories.add(dirPath);
464
+ }
465
+ // Also mark root as having changes
466
+ statusMap.directories.add('');
467
+ }
468
+ this.explorerManager.setGitStatus(statusMap);
469
+ }
470
+ /**
471
+ * Reset UI state that's specific to a repository.
472
+ * Called when switching to a new repo via file watcher.
473
+ */
474
+ resetRepoSpecificState() {
475
+ // Reset compare selection (App-level state)
476
+ this.compareSelection = null;
477
+ // Reset UI state scroll offsets and selections
478
+ this.uiState.resetForNewRepo();
479
+ }
480
+ /**
481
+ * Load data for the current tab.
482
+ * Called after switching repos to refresh tab-specific data.
483
+ */
484
+ loadCurrentTabData() {
485
+ const tab = this.uiState.state.bottomTab;
486
+ if (tab === 'history') {
487
+ this.gitManager?.loadHistory();
488
+ }
489
+ else if (tab === 'compare') {
490
+ this.gitManager?.refreshCompareDiff(this.uiState.state.includeUncommitted);
491
+ }
492
+ // Diff tab data is loaded by gitManager.refresh() in initGitManager
493
+ // Explorer data is loaded by initExplorerManager()
618
494
  }
619
495
  setupCommandHandler() {
620
496
  if (!this.commandServer)
@@ -667,93 +543,98 @@ export class App {
667
543
  };
668
544
  }
669
545
  // Navigation methods
670
- navigateUp() {
546
+ /**
547
+ * Scroll the content pane (diff or explorer file content) by delta lines.
548
+ */
549
+ scrollActiveDiffPane(delta) {
671
550
  const state = this.uiState.state;
672
- if (state.bottomTab === 'history') {
673
- if (state.currentPane === 'history') {
674
- this.navigateHistoryUp();
675
- }
676
- else if (state.currentPane === 'diff') {
677
- this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
678
- }
679
- return;
551
+ if (state.bottomTab === 'explorer') {
552
+ const newOffset = Math.max(0, state.explorerFileScrollOffset + delta);
553
+ this.uiState.setExplorerFileScrollOffset(newOffset);
680
554
  }
681
- if (state.bottomTab === 'compare') {
682
- if (state.currentPane === 'compare') {
683
- this.navigateCompareUp();
684
- }
685
- else if (state.currentPane === 'diff') {
686
- this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
687
- }
688
- return;
555
+ else {
556
+ const newOffset = Math.max(0, state.diffScrollOffset + delta);
557
+ this.uiState.setDiffScrollOffset(newOffset);
689
558
  }
690
- if (state.bottomTab === 'explorer') {
691
- if (state.currentPane === 'explorer') {
692
- this.navigateExplorerUp();
693
- }
694
- else if (state.currentPane === 'diff') {
695
- this.uiState.setExplorerFileScrollOffset(Math.max(0, state.explorerFileScrollOffset - 3));
696
- }
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)
697
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);
698
585
  }
699
- if (state.currentPane === 'files') {
700
- const files = this.gitManager?.state.status?.files ?? [];
701
- const newIndex = Math.max(0, state.selectedIndex - 1);
702
- this.uiState.setSelectedIndex(newIndex);
703
- this.selectFileByIndex(newIndex);
704
- // Keep selection visible - scroll up if needed
705
- const row = getRowFromFileIndex(newIndex, files);
706
- if (row < state.fileListScrollOffset) {
707
- 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));
708
590
  }
709
591
  }
710
- else if (state.currentPane === 'diff') {
711
- this.uiState.setDiffScrollOffset(Math.max(0, state.diffScrollOffset - 3));
712
- }
713
592
  }
714
- navigateDown() {
715
- const state = this.uiState.state;
716
- const files = this.gitManager?.state.status?.files ?? [];
717
- if (state.bottomTab === 'history') {
718
- 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
719
602
  this.navigateHistoryDown();
720
- }
721
- else if (state.currentPane === 'diff') {
722
- this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
723
- }
724
- return;
725
603
  }
726
- if (state.bottomTab === 'compare') {
727
- if (state.currentPane === 'compare') {
604
+ else if (tab === 'compare') {
605
+ if (direction === -1)
606
+ this.navigateCompareUp();
607
+ else
728
608
  this.navigateCompareDown();
729
- }
730
- else if (state.currentPane === 'diff') {
731
- this.uiState.setDiffScrollOffset(state.diffScrollOffset + 3);
732
- }
733
- return;
734
609
  }
735
- if (state.bottomTab === 'explorer') {
736
- if (state.currentPane === 'explorer') {
610
+ else if (tab === 'explorer') {
611
+ if (direction === -1)
612
+ this.navigateExplorerUp();
613
+ else
737
614
  this.navigateExplorerDown();
738
- }
739
- else if (state.currentPane === 'diff') {
740
- this.uiState.setExplorerFileScrollOffset(state.explorerFileScrollOffset + 3);
741
- }
742
- return;
743
615
  }
744
- if (state.currentPane === 'files') {
745
- const newIndex = Math.min(files.length - 1, state.selectedIndex + 1);
746
- this.uiState.setSelectedIndex(newIndex);
747
- this.selectFileByIndex(newIndex);
748
- // Keep selection visible - scroll down if needed
749
- const row = getRowFromFileIndex(newIndex, files);
750
- const visibleEnd = state.fileListScrollOffset + this.layout.dimensions.topPaneHeight - 1;
751
- if (row >= visibleEnd) {
752
- this.uiState.setFileListScrollOffset(state.fileListScrollOffset + (row - visibleEnd + 1));
753
- }
616
+ else {
617
+ this.navigateFileList(direction);
754
618
  }
755
- else if (state.currentPane === 'diff') {
756
- 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);
757
638
  }
758
639
  }
759
640
  navigateHistoryUp() {
@@ -853,8 +734,8 @@ export class App {
853
734
  // Explorer navigation
854
735
  navigateExplorerUp() {
855
736
  const state = this.uiState.state;
856
- const items = this.explorerManager?.state.items ?? [];
857
- if (items.length === 0)
737
+ const rows = this.explorerManager?.state.displayRows ?? [];
738
+ if (rows.length === 0)
858
739
  return;
859
740
  const newScrollOffset = this.explorerManager?.navigateUp(state.explorerScrollOffset);
860
741
  if (newScrollOffset !== null && newScrollOffset !== undefined) {
@@ -864,8 +745,8 @@ export class App {
864
745
  }
865
746
  navigateExplorerDown() {
866
747
  const state = this.uiState.state;
867
- const items = this.explorerManager?.state.items ?? [];
868
- if (items.length === 0)
748
+ const rows = this.explorerManager?.state.displayRows ?? [];
749
+ if (rows.length === 0)
869
750
  return;
870
751
  const visibleHeight = this.layout.dimensions.topPaneHeight;
871
752
  const newScrollOffset = this.explorerManager?.navigateDown(state.explorerScrollOffset, visibleHeight);
@@ -876,23 +757,39 @@ export class App {
876
757
  }
877
758
  async enterExplorerDirectory() {
878
759
  await this.explorerManager?.enterDirectory();
879
- this.uiState.setExplorerScrollOffset(0);
760
+ // Reset file content scroll when expanding/collapsing
880
761
  this.uiState.setExplorerFileScrollOffset(0);
881
- this.uiState.setExplorerSelectedIndex(0);
762
+ // Sync selected index from explorer manager (it maintains selection by path)
763
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
882
764
  }
883
765
  async goExplorerUp() {
884
766
  await this.explorerManager?.goUp();
885
- this.uiState.setExplorerScrollOffset(0);
767
+ // Reset file content scroll when collapsing
886
768
  this.uiState.setExplorerFileScrollOffset(0);
887
- this.uiState.setExplorerSelectedIndex(0);
769
+ // Sync selected index from explorer manager
770
+ this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
888
771
  }
889
772
  selectFileByIndex(index) {
890
- const files = this.gitManager?.state.status?.files ?? [];
891
- const file = getFileAtIndex(files, index);
892
- if (file) {
893
- // Reset diff scroll when changing files
894
- this.uiState.setDiffScrollOffset(0);
895
- 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
+ }
896
793
  }
897
794
  }
898
795
  /**
@@ -921,27 +818,65 @@ export class App {
921
818
  // Git operations
922
819
  async stageSelected() {
923
820
  const files = this.gitManager?.state.status?.files ?? [];
924
- const selectedFile = files[this.uiState.state.selectedIndex];
925
- if (selectedFile && !selectedFile.staged) {
926
- await this.gitManager?.stage(selectedFile);
821
+ const index = this.uiState.state.selectedIndex;
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
+ }
927
839
  }
928
840
  }
929
841
  async unstageSelected() {
930
842
  const files = this.gitManager?.state.status?.files ?? [];
931
- const selectedFile = files[this.uiState.state.selectedIndex];
932
- if (selectedFile?.staged) {
933
- await this.gitManager?.unstage(selectedFile);
843
+ const index = this.uiState.state.selectedIndex;
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
+ }
934
853
  }
935
- }
936
- async toggleSelected() {
937
- const files = this.gitManager?.state.status?.files ?? [];
938
- const selectedFile = files[this.uiState.state.selectedIndex];
939
- if (selectedFile) {
940
- if (selectedFile.staged) {
854
+ else {
855
+ const selectedFile = getFileAtIndex(files, index);
856
+ if (selectedFile?.staged) {
857
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
941
858
  await this.gitManager?.unstage(selectedFile);
942
859
  }
943
- else {
944
- await this.gitManager?.stage(selectedFile);
860
+ }
861
+ }
862
+ async toggleSelected() {
863
+ const index = this.uiState.state.selectedIndex;
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
+ }
945
880
  }
946
881
  }
947
882
  }
@@ -960,6 +895,128 @@ export class App {
960
895
  });
961
896
  this.activeModal.focus();
962
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
+ }
987
+ async openFileFinder() {
988
+ const allPaths = (await this.explorerManager?.getAllFilePaths()) ?? [];
989
+ if (allPaths.length === 0)
990
+ return;
991
+ this.activeModal = new FileFinder(this.screen, allPaths, async (selectedPath) => {
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
+ }
997
+ // Navigate to the selected file in explorer
998
+ const success = await this.explorerManager?.navigateToPath(selectedPath);
999
+ if (success) {
1000
+ // Sync selected index from explorer manager
1001
+ const selectedIndex = this.explorerManager?.state.selectedIndex ?? 0;
1002
+ this.uiState.setExplorerSelectedIndex(selectedIndex);
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
+ }
1012
+ }
1013
+ this.render();
1014
+ }, () => {
1015
+ this.activeModal = null;
1016
+ this.render();
1017
+ });
1018
+ this.activeModal.focus();
1019
+ }
963
1020
  async commit(message) {
964
1021
  await this.gitManager?.commit(message);
965
1022
  }
@@ -970,6 +1027,7 @@ export class App {
970
1027
  const willEnable = !this.uiState.state.mouseEnabled;
971
1028
  this.uiState.toggleMouse();
972
1029
  // Access program for terminal mouse control (not on screen's TS types)
1030
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
973
1031
  const program = this.screen.program;
974
1032
  if (willEnable) {
975
1033
  program.enableMouse();
@@ -979,14 +1037,13 @@ export class App {
979
1037
  }
980
1038
  }
981
1039
  toggleFollow() {
982
- if (this.fileWatcher) {
983
- this.fileWatcher.stop();
984
- this.fileWatcher = null;
985
- this.watcherState = { enabled: false };
986
- }
987
- else {
988
- this.setupFileWatcher();
1040
+ if (!this.followMode) {
1041
+ this.followMode = new FollowMode(this.config.targetFile, () => this.repoPath, {
1042
+ onRepoChange: (newPath, state) => this.handleFollowRepoChange(newPath, state),
1043
+ onFileNavigate: (rawContent) => this.handleFollowFileNavigate(rawContent),
1044
+ });
989
1045
  }
1046
+ this.followMode.toggle();
990
1047
  this.render();
991
1048
  }
992
1049
  focusCommitInput() {
@@ -1013,120 +1070,66 @@ export class App {
1013
1070
  this.updateHeader();
1014
1071
  this.updateTopPane();
1015
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
+ }
1016
1080
  this.updateFooter();
1017
1081
  this.screen.render();
1018
1082
  }
1019
1083
  updateHeader() {
1020
1084
  const gitState = this.gitManager?.state;
1021
1085
  const width = this.screen.width || 80;
1022
- const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, this.watcherState, width);
1086
+ const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width);
1023
1087
  this.layout.headerBox.setContent(content);
1024
1088
  }
1025
1089
  updateTopPane() {
1026
- const gitState = this.gitManager?.state;
1027
- const historyState = this.gitManager?.historyState;
1028
- const compareState = this.gitManager?.compareState;
1029
- const files = gitState?.status?.files ?? [];
1030
1090
  const state = this.uiState.state;
1031
1091
  const width = this.screen.width || 80;
1032
- let content;
1033
- if (state.bottomTab === 'history') {
1034
- const commits = historyState?.commits ?? [];
1035
- content = formatHistoryView(commits, state.historySelectedIndex, state.currentPane === 'history', width, state.historyScrollOffset, this.layout.dimensions.topPaneHeight);
1036
- }
1037
- else if (state.bottomTab === 'compare') {
1038
- const compareDiff = compareState?.compareDiff;
1039
- const commits = compareDiff?.commits ?? [];
1040
- const compareFiles = compareDiff?.files ?? [];
1041
- content = formatCompareListView(commits, compareFiles, this.compareSelection, state.currentPane === 'compare', width, state.compareScrollOffset, this.layout.dimensions.topPaneHeight);
1042
- }
1043
- else if (state.bottomTab === 'explorer') {
1044
- const explorerState = this.explorerManager?.state;
1045
- const items = explorerState?.items ?? [];
1046
- content = formatExplorerView(items, state.explorerSelectedIndex, state.currentPane === 'explorer', width, state.explorerScrollOffset, this.layout.dimensions.topPaneHeight, explorerState?.isLoading ?? false, explorerState?.error ?? null);
1047
- }
1048
- else {
1049
- content = formatFileList(files, state.selectedIndex, state.currentPane === 'files', width, state.fileListScrollOffset, 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);
1050
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);
1051
1098
  this.layout.topPane.setContent(content);
1052
1099
  }
1053
1100
  updateBottomPane() {
1054
- const gitState = this.gitManager?.state;
1055
- const historyState = this.gitManager?.historyState;
1056
- const diff = gitState?.diff ?? null;
1057
1101
  const state = this.uiState.state;
1058
1102
  const width = this.screen.width || 80;
1059
- const files = gitState?.status?.files ?? [];
1103
+ const files = this.gitManager?.state.status?.files ?? [];
1060
1104
  const stagedCount = files.filter((f) => f.staged).length;
1061
1105
  // Update staged count for commit validation
1062
1106
  this.commitFlowState.setStagedCount(stagedCount);
1063
- // Show appropriate content based on tab
1064
- if (state.bottomTab === 'commit') {
1065
- const commitContent = formatCommitPanel(this.commitFlowState.state, stagedCount, width);
1066
- this.layout.bottomPane.setContent(commitContent);
1067
- // Show/hide textarea based on focus
1068
- if (this.commitTextarea) {
1069
- if (this.commitFlowState.state.inputFocused) {
1070
- this.commitTextarea.show();
1071
- }
1072
- else {
1073
- this.commitTextarea.hide();
1074
- }
1075
- }
1076
- }
1077
- else if (state.bottomTab === 'history') {
1078
- // Hide commit textarea when not on commit tab
1079
- if (this.commitTextarea) {
1080
- this.commitTextarea.hide();
1081
- }
1082
- const selectedCommit = historyState?.selectedCommit ?? null;
1083
- const commitDiff = historyState?.commitDiff ?? null;
1084
- const { content, totalRows } = formatHistoryDiff(selectedCommit, commitDiff, width, state.diffScrollOffset, this.layout.dimensions.bottomPaneHeight, this.currentTheme, state.wrapMode);
1085
- this.bottomPaneTotalRows = totalRows;
1086
- this.layout.bottomPane.setContent(content);
1087
- }
1088
- else if (state.bottomTab === 'compare') {
1089
- // Hide commit textarea when not on commit tab
1090
- if (this.commitTextarea) {
1091
- this.commitTextarea.hide();
1092
- }
1093
- const compareSelectionState = this.gitManager?.compareSelectionState;
1094
- const compareDiff = compareSelectionState?.diff ?? null;
1095
- if (compareDiff) {
1096
- const { content, totalRows } = formatDiff(compareDiff, width, state.diffScrollOffset, this.layout.dimensions.bottomPaneHeight, this.currentTheme, state.wrapMode);
1097
- this.bottomPaneTotalRows = totalRows;
1098
- this.layout.bottomPane.setContent(content);
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);
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);
1118
+ this.layout.bottomPane.setContent(content);
1119
+ // Manage commit textarea visibility
1120
+ if (this.commitTextarea) {
1121
+ if (state.bottomTab === 'commit' && this.commitFlowState.state.inputFocused) {
1122
+ this.commitTextarea.show();
1099
1123
  }
1100
1124
  else {
1101
- this.bottomPaneTotalRows = 0;
1102
- this.layout.bottomPane.setContent('{gray-fg}Select a commit or file to view diff{/gray-fg}');
1103
- }
1104
- }
1105
- else if (state.bottomTab === 'explorer') {
1106
- // Hide commit textarea when not on commit tab
1107
- if (this.commitTextarea) {
1108
- this.commitTextarea.hide();
1109
- }
1110
- const explorerState = this.explorerManager?.state;
1111
- const selectedFile = explorerState?.selectedFile ?? null;
1112
- const content = formatExplorerContent(selectedFile?.path ?? null, selectedFile?.content ?? null, width, state.explorerFileScrollOffset, this.layout.dimensions.bottomPaneHeight, selectedFile?.truncated ?? false, state.wrapMode, state.showMiddleDots);
1113
- // TODO: formatExplorerContent should also return totalRows
1114
- this.layout.bottomPane.setContent(content);
1115
- }
1116
- else {
1117
- // Hide commit textarea when not on commit tab
1118
- if (this.commitTextarea) {
1119
1125
  this.commitTextarea.hide();
1120
1126
  }
1121
- const { content, totalRows } = formatDiff(diff, width, state.diffScrollOffset, this.layout.dimensions.bottomPaneHeight, this.currentTheme, state.wrapMode);
1122
- this.bottomPaneTotalRows = totalRows;
1123
- this.layout.bottomPane.setContent(content);
1124
1127
  }
1125
1128
  }
1126
1129
  updateFooter() {
1127
1130
  const state = this.uiState.state;
1128
1131
  const width = this.screen.width || 80;
1129
- const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, state.showMiddleDots, width);
1132
+ const content = formatFooter(state.bottomTab, state.mouseEnabled, state.autoTabEnabled, state.wrapMode, this.followMode?.isEnabled ?? false, this.explorerManager?.showOnlyChanges ?? false, width, state.currentPane);
1130
1133
  this.layout.footerBox.setContent(content);
1131
1134
  }
1132
1135
  /**
@@ -1140,8 +1143,8 @@ export class App {
1140
1143
  if (this.explorerManager) {
1141
1144
  this.explorerManager.dispose();
1142
1145
  }
1143
- if (this.fileWatcher) {
1144
- this.fileWatcher.stop();
1146
+ if (this.followMode) {
1147
+ this.followMode.stop();
1145
1148
  }
1146
1149
  if (this.commandServer) {
1147
1150
  this.commandServer.stop();