diffstalker 0.2.3 → 0.2.4

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 +2 -2
  2. package/dist/App.js +278 -758
  3. package/dist/KeyBindings.js +103 -91
  4. package/dist/ModalController.js +166 -0
  5. package/dist/MouseHandlers.js +37 -30
  6. package/dist/NavigationController.js +290 -0
  7. package/dist/StagingOperations.js +199 -0
  8. package/dist/config.js +39 -0
  9. package/dist/core/CompareManager.js +134 -0
  10. package/dist/core/ExplorerStateManager.js +7 -3
  11. package/dist/core/GitStateManager.js +28 -771
  12. package/dist/core/HistoryManager.js +72 -0
  13. package/dist/core/RemoteOperationManager.js +109 -0
  14. package/dist/core/WorkingTreeManager.js +412 -0
  15. package/dist/index.js +57 -57
  16. package/dist/state/FocusRing.js +40 -0
  17. package/dist/state/UIState.js +82 -48
  18. package/dist/ui/PaneRenderers.js +3 -6
  19. package/dist/ui/modals/BaseBranchPicker.js +4 -7
  20. package/dist/ui/modals/CommitActionConfirm.js +4 -4
  21. package/dist/ui/modals/DiscardConfirm.js +4 -7
  22. package/dist/ui/modals/FileFinder.js +3 -6
  23. package/dist/ui/modals/HotkeysModal.js +17 -21
  24. package/dist/ui/modals/Modal.js +1 -0
  25. package/dist/ui/modals/RepoPicker.js +109 -0
  26. package/dist/ui/modals/ThemePicker.js +4 -7
  27. package/dist/ui/widgets/CommitPanel.js +26 -94
  28. package/dist/ui/widgets/CompareListView.js +1 -11
  29. package/dist/ui/widgets/DiffView.js +2 -27
  30. package/dist/ui/widgets/ExplorerContent.js +1 -4
  31. package/dist/ui/widgets/ExplorerView.js +1 -11
  32. package/dist/ui/widgets/FileList.js +2 -8
  33. package/dist/ui/widgets/Footer.js +1 -0
  34. package/dist/utils/ansi.js +38 -0
  35. package/dist/utils/ansiTruncate.js +1 -5
  36. package/dist/utils/displayRows.js +72 -59
  37. package/dist/utils/fileCategories.js +7 -0
  38. package/dist/utils/fileResolution.js +23 -0
  39. package/dist/utils/languageDetection.js +3 -2
  40. package/dist/utils/logger.js +32 -0
  41. package/metrics/v0.2.4.json +236 -0
  42. package/package.json +1 -1
  43. package/dist/ui/modals/BranchPicker.js +0 -157
  44. package/dist/ui/modals/SoftResetConfirm.js +0 -68
  45. package/dist/ui/modals/StashListModal.js +0 -98
  46. package/dist/utils/layoutCalculations.js +0 -100
package/dist/App.js CHANGED
@@ -3,29 +3,21 @@ import { LayoutManager } from './ui/Layout.js';
3
3
  import { setupKeyBindings } from './KeyBindings.js';
4
4
  import { renderTopPane, renderBottomPane } from './ui/PaneRenderers.js';
5
5
  import { setupMouseHandlers } from './MouseHandlers.js';
6
+ import { NavigationController } from './NavigationController.js';
7
+ import { StagingOperations } from './StagingOperations.js';
8
+ import { ModalController } from './ModalController.js';
6
9
  import { FollowMode } from './FollowMode.js';
7
10
  import { formatHeader } from './ui/widgets/Header.js';
8
11
  import { formatFooter } from './ui/widgets/Footer.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
- import { ThemePicker } from './ui/modals/ThemePicker.js';
14
- import { HotkeysModal } from './ui/modals/HotkeysModal.js';
15
- import { BaseBranchPicker } from './ui/modals/BaseBranchPicker.js';
16
- import { DiscardConfirm } from './ui/modals/DiscardConfirm.js';
17
- import { FileFinder } from './ui/modals/FileFinder.js';
18
- import { StashListModal } from './ui/modals/StashListModal.js';
19
- import { BranchPicker } from './ui/modals/BranchPicker.js';
20
- import { SoftResetConfirm } from './ui/modals/SoftResetConfirm.js';
21
- import { CommitActionConfirm } from './ui/modals/CommitActionConfirm.js';
22
13
  import { CommitFlowState } from './state/CommitFlowState.js';
23
14
  import { UIState } from './state/UIState.js';
24
15
  import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
25
- import { saveConfig } from './config.js';
26
- import { getCategoryForIndex, getIndexForCategoryPosition, } from './utils/fileCategories.js';
16
+ import { saveConfig, addRecentRepo } from './config.js';
17
+ import { getIndexForCategoryPosition } from './utils/fileCategories.js';
27
18
  import { buildFlatFileList, getFlatFileAtIndex, getFlatFileIndexByPath, } from './utils/flatFileList.js';
28
- import { extractHunkPatch } from './git/diff.js';
19
+ import { getFileAtIndex } from './ui/widgets/FileList.js';
20
+ import { resolveFileAtIndex as resolveFile, getFileListMaxIndex as getMaxIndex, } from './utils/fileResolution.js';
29
21
  /**
30
22
  * Main application controller.
31
23
  * Coordinates between GitStateManager, UIState, and blessed widgets.
@@ -39,35 +31,39 @@ export class App {
39
31
  explorerManager = null;
40
32
  config;
41
33
  commandServer;
34
+ navigation;
35
+ staging;
36
+ modals;
42
37
  // Current state
43
38
  repoPath;
44
39
  currentTheme;
40
+ recentRepos;
45
41
  // Commit flow state
46
42
  commitFlowState;
47
43
  commitTextarea = null;
48
- // Active modals
49
- activeModal = null;
50
44
  // Auto-clear timer for remote operation status
51
45
  remoteClearTimer = null;
52
46
  // Cached total rows and hunk info for scroll bounds (single source of truth from render)
53
47
  bottomPaneTotalRows = 0;
54
48
  bottomPaneHunkCount = 0;
55
49
  bottomPaneHunkBoundaries = [];
56
- // Selection anchor: remembers category + position before stage/unstage
57
- pendingSelectionAnchor = null;
50
+ // Auto-tab transition tracking
51
+ prevFileCount = 0;
58
52
  // Flat view mode state
59
53
  cachedFlatFiles = [];
60
- pendingFlatSelectionPath = null;
61
- pendingHunkIndex = null;
62
54
  combinedHunkMapping = [];
63
55
  constructor(options) {
64
56
  this.config = options.config;
65
57
  this.commandServer = options.commandServer ?? null;
66
58
  this.repoPath = options.initialPath ?? process.cwd();
67
59
  this.currentTheme = options.config.theme;
60
+ this.recentRepos = options.config.recentRepos ?? [];
68
61
  // Initialize UI state with config values
69
62
  this.uiState = new UIState({
70
63
  splitRatio: options.config.splitRatio ?? 0.4,
64
+ autoTabEnabled: options.config.autoTabEnabled ?? false,
65
+ wrapMode: options.config.wrapMode ?? false,
66
+ mouseEnabled: options.config.mouseEnabled ?? true,
71
67
  });
72
68
  // Create blessed screen
73
69
  this.screen = blessed.screen({
@@ -95,9 +91,9 @@ export class App {
95
91
  });
96
92
  // Initialize commit flow state
97
93
  this.commitFlowState = new CommitFlowState({
98
- getHeadMessage: () => this.gitManager?.getHeadCommitMessage() ?? Promise.resolve(''),
94
+ getHeadMessage: () => this.gitManager?.history.getHeadCommitMessage() ?? Promise.resolve(''),
99
95
  onCommit: async (message, amend) => {
100
- await this.gitManager?.commit(message, amend);
96
+ await this.gitManager?.workingTree.commit(message, amend);
101
97
  },
102
98
  onSuccess: () => {
103
99
  this.uiState.setTab('diff');
@@ -130,6 +126,50 @@ export class App {
130
126
  this.commitFlowState.setMessage(value);
131
127
  });
132
128
  });
129
+ // Setup navigation controller
130
+ this.navigation = new NavigationController({
131
+ uiState: this.uiState,
132
+ getGitManager: () => this.gitManager,
133
+ getExplorerManager: () => this.explorerManager,
134
+ getTopPaneHeight: () => this.layout.dimensions.topPaneHeight,
135
+ getBottomPaneHeight: () => this.layout.dimensions.bottomPaneHeight,
136
+ getCachedFlatFiles: () => this.cachedFlatFiles,
137
+ getHunkCount: () => this.bottomPaneHunkCount,
138
+ getHunkBoundaries: () => this.bottomPaneHunkBoundaries,
139
+ getRepoPath: () => this.repoPath,
140
+ onError: (message) => this.showError(message),
141
+ resolveFileAtIndex: (index) => resolveFile(index, this.uiState.state.flatViewMode, this.cachedFlatFiles, this.gitManager?.workingTree.state.status?.files ?? []),
142
+ getFileListMaxIndex: () => getMaxIndex(this.uiState.state.flatViewMode, this.cachedFlatFiles, this.gitManager?.workingTree.state.status?.files ?? []),
143
+ });
144
+ // Setup modal controller
145
+ this.modals = new ModalController({
146
+ screen: this.screen,
147
+ uiState: this.uiState,
148
+ getGitManager: () => this.gitManager,
149
+ getExplorerManager: () => this.explorerManager,
150
+ getTopPaneHeight: () => this.layout.dimensions.topPaneHeight,
151
+ getCurrentTheme: () => this.currentTheme,
152
+ setCurrentTheme: (theme) => {
153
+ this.currentTheme = theme;
154
+ },
155
+ getRepoPath: () => this.repoPath,
156
+ getRecentRepos: () => this.recentRepos,
157
+ onRepoSwitch: (repoPath) => this.switchToRepo(repoPath),
158
+ render: () => this.render(),
159
+ });
160
+ // Setup staging operations
161
+ this.staging = new StagingOperations({
162
+ uiState: this.uiState,
163
+ getGitManager: () => this.gitManager,
164
+ getCachedFlatFiles: () => this.cachedFlatFiles,
165
+ getCombinedHunkMapping: () => this.combinedHunkMapping,
166
+ resolveFileAtIndex: (index) => resolveFile(index, this.uiState.state.flatViewMode, this.cachedFlatFiles, this.gitManager?.workingTree.state.status?.files ?? []),
167
+ });
168
+ // If mouse was persisted as disabled, disable it now
169
+ if (!this.uiState.state.mouseEnabled) {
170
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
171
+ this.screen.program.disableMouse();
172
+ }
133
173
  // Setup keyboard handlers
134
174
  this.setupKeyboardHandlers();
135
175
  // Setup mouse handlers
@@ -153,118 +193,95 @@ export class App {
153
193
  // Initial render
154
194
  this.render();
155
195
  }
196
+ /**
197
+ * Display an error in the UI by emitting a state change with the error set.
198
+ */
199
+ showError(message) {
200
+ if (!this.gitManager)
201
+ return;
202
+ const wt = this.gitManager.workingTree;
203
+ wt.emit('state-change', { ...wt.state, error: message });
204
+ }
156
205
  setupKeyboardHandlers() {
157
206
  setupKeyBindings(this.screen, {
158
207
  exit: () => this.exit(),
159
- navigateDown: () => this.navigateDown(),
160
- navigateUp: () => this.navigateUp(),
161
- stageSelected: () => this.stageSelected(),
162
- unstageSelected: () => this.unstageSelected(),
163
- stageAll: () => this.stageAll(),
164
- unstageAll: () => this.unstageAll(),
165
- toggleSelected: () => this.toggleSelected(),
166
- enterExplorerDirectory: () => this.enterExplorerDirectory(),
167
- goExplorerUp: () => this.goExplorerUp(),
168
- openFileFinder: () => this.openFileFinder(),
208
+ navigateDown: () => this.navigation.navigateDown(),
209
+ navigateUp: () => this.navigation.navigateUp(),
210
+ stageSelected: () => this.staging.stageSelected(),
211
+ unstageSelected: () => this.staging.unstageSelected(),
212
+ stageAll: () => this.staging.stageAll(),
213
+ unstageAll: () => this.staging.unstageAll(),
214
+ toggleSelected: () => this.staging.toggleSelected(),
215
+ enterExplorerDirectory: () => this.navigation.enterExplorerDirectory(),
216
+ goExplorerUp: () => this.navigation.goExplorerUp(),
217
+ openFileFinder: () => this.modals.openFileFinder(),
169
218
  focusCommitInput: () => this.focusCommitInput(),
170
219
  unfocusCommitInput: () => this.unfocusCommitInput(),
171
- refresh: () => this.refresh(),
220
+ openRepoPicker: () => this.modals.openRepoPicker(),
221
+ openThemePicker: () => this.modals.openThemePicker(),
222
+ openHotkeysModal: () => this.modals.openHotkeysModal(),
223
+ openBaseBranchPicker: () => this.modals.openBaseBranchPicker(),
224
+ closeActiveModal: () => this.modals.closeActiveModal(),
172
225
  toggleMouseMode: () => this.toggleMouseMode(),
173
226
  toggleFollow: () => this.toggleFollow(),
174
- showDiscardConfirm: (file) => this.showDiscardConfirm(file),
227
+ openDiscardConfirm: (file) => this.modals.openDiscardConfirm(file),
175
228
  render: () => this.render(),
176
- toggleCurrentHunk: () => this.toggleCurrentHunk(),
177
- navigateNextHunk: () => this.navigateNextHunk(),
178
- navigatePrevHunk: () => this.navigatePrevHunk(),
179
- push: () => this.gitManager?.push(),
180
- fetchRemote: () => this.gitManager?.fetchRemote(),
181
- pullRebase: () => this.gitManager?.pullRebase(),
182
- stash: () => this.gitManager?.stash(),
183
- stashPop: () => this.gitManager?.stashPop(),
184
- openStashListModal: () => this.openStashListModal(),
185
- openBranchPicker: () => this.openBranchPicker(),
186
- showSoftResetConfirm: () => this.showSoftResetConfirm(),
187
- cherryPickSelected: () => this.cherryPickSelected(),
188
- revertSelected: () => this.revertSelected(),
229
+ toggleCurrentHunk: () => this.staging.toggleCurrentHunk(),
230
+ navigateNextHunk: () => this.navigation.navigateNextHunk(),
231
+ navigatePrevHunk: () => this.navigation.navigatePrevHunk(),
232
+ openCherryPickConfirm: () => this.modals.openCherryPickConfirm(),
233
+ openRevertConfirm: () => this.modals.openRevertConfirm(),
189
234
  }, {
190
- hasActiveModal: () => this.activeModal !== null,
235
+ hasActiveModal: () => this.modals.hasActiveModal(),
236
+ getActiveModalType: () => this.modals.getActiveModalType(),
191
237
  getBottomTab: () => this.uiState.state.bottomTab,
192
238
  getCurrentPane: () => this.uiState.state.currentPane,
239
+ getFocusedZone: () => this.uiState.state.focusedZone,
193
240
  isCommitInputFocused: () => this.commitFlowState.state.inputFocused,
194
- isRemoteInProgress: () => this.gitManager?.remoteState.inProgress ?? false,
195
- getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
241
+ getStatusFiles: () => this.gitManager?.workingTree.state.status?.files ?? [],
196
242
  getSelectedIndex: () => this.uiState.state.selectedIndex,
197
243
  uiState: this.uiState,
198
244
  getExplorerManager: () => this.explorerManager,
199
245
  commitFlowState: this.commitFlowState,
200
246
  getGitManager: () => this.gitManager,
201
247
  layout: this.layout,
202
- getCachedFlatFiles: () => this.cachedFlatFiles,
248
+ resolveFileAtIndex: (index) => resolveFile(index, this.uiState.state.flatViewMode, this.cachedFlatFiles, this.gitManager?.workingTree.state.status?.files ?? []),
203
249
  });
204
250
  }
205
251
  setupMouseEventHandlers() {
206
252
  setupMouseHandlers(this.layout, {
207
- selectHistoryCommitByIndex: (index) => this.selectHistoryCommitByIndex(index),
208
- selectCompareItem: (selection) => this.selectCompareItem(selection),
209
- selectFileByIndex: (index) => this.selectFileByIndex(index),
210
- toggleFileByIndex: (index) => this.toggleFileByIndex(index),
211
- enterExplorerDirectory: () => this.enterExplorerDirectory(),
253
+ selectHistoryCommitByIndex: (index) => this.navigation.selectHistoryCommitByIndex(index),
254
+ selectCompareItem: (selection) => this.navigation.selectCompareItem(selection),
255
+ selectFileByIndex: (index) => this.navigation.selectFileByIndex(index),
256
+ toggleFileByIndex: (index) => this.staging.toggleFileByIndex(index),
257
+ enterExplorerDirectory: () => this.navigation.enterExplorerDirectory(),
212
258
  toggleMouseMode: () => this.toggleMouseMode(),
213
259
  toggleFollow: () => this.toggleFollow(),
214
- selectHunkAtRow: (row) => this.selectHunkAtRow(row),
260
+ selectHunkAtRow: (row) => this.navigation.selectHunkAtRow(row),
215
261
  focusCommitInput: () => this.focusCommitInput(),
216
- toggleAmend: () => {
217
- this.commitFlowState.toggleAmend();
218
- this.render();
219
- },
262
+ openHotkeysModal: () => this.modals.openHotkeysModal(),
220
263
  render: () => this.render(),
221
264
  }, {
222
265
  uiState: this.uiState,
223
266
  getExplorerManager: () => this.explorerManager,
224
- getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
225
- getHistoryCommitCount: () => this.gitManager?.historyState.commits.length ?? 0,
226
- getCompareCommits: () => this.gitManager?.compareState?.compareDiff?.commits ?? [],
227
- getCompareFiles: () => this.gitManager?.compareState?.compareDiff?.files ?? [],
267
+ getStatusFiles: () => this.gitManager?.workingTree.state.status?.files ?? [],
268
+ getHistoryCommitCount: () => this.gitManager?.history.historyState.commits.length ?? 0,
269
+ getCompareCommits: () => this.gitManager?.compare.compareState?.compareDiff?.commits ?? [],
270
+ getCompareFiles: () => this.gitManager?.compare.compareState?.compareDiff?.files ?? [],
228
271
  getBottomPaneTotalRows: () => this.bottomPaneTotalRows,
229
272
  getScreenWidth: () => this.screen.width || 80,
230
273
  getCachedFlatFiles: () => this.cachedFlatFiles,
231
274
  });
232
275
  }
233
- /**
234
- * Toggle staging for a flat file entry (stage if unstaged/partial, unstage if fully staged).
235
- */
236
- async toggleFlatEntry(entry) {
237
- this.pendingFlatSelectionPath = entry.path;
238
- if (entry.stagingState === 'staged') {
239
- if (entry.stagedEntry)
240
- await this.gitManager?.unstage(entry.stagedEntry);
241
- }
242
- else {
243
- if (entry.unstagedEntry)
244
- await this.gitManager?.stage(entry.unstagedEntry);
245
- }
246
- }
247
- async toggleFileByIndex(index) {
248
- if (this.uiState.state.flatViewMode) {
249
- const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
250
- if (flatEntry)
251
- await this.toggleFlatEntry(flatEntry);
252
- }
253
- else {
254
- const files = this.gitManager?.state.status?.files ?? [];
255
- const file = getFileAtIndex(files, index);
256
- if (file) {
257
- this.pendingSelectionAnchor = getCategoryForIndex(files, this.uiState.state.selectedIndex);
258
- if (file.staged) {
259
- await this.gitManager?.unstage(file);
260
- }
261
- else {
262
- await this.gitManager?.stage(file);
263
- }
264
- }
265
- }
266
- }
267
276
  setupStateListeners() {
277
+ // Apply auto-tab logic when toggled on
278
+ let prevAutoTab = this.uiState.state.autoTabEnabled;
279
+ this.uiState.on('change', (state) => {
280
+ if (state.autoTabEnabled && !prevAutoTab) {
281
+ this.applyAutoTab();
282
+ }
283
+ prevAutoTab = state.autoTabEnabled;
284
+ });
268
285
  // Update footer when UI state changes
269
286
  this.uiState.on('change', () => {
270
287
  this.render();
@@ -276,10 +293,10 @@ export class App {
276
293
  this.uiState.setSelectedHunkIndex(0);
277
294
  }
278
295
  if (tab === 'history') {
279
- this.gitManager?.loadHistory();
296
+ this.loadHistory();
280
297
  }
281
298
  else if (tab === 'compare') {
282
- this.gitManager?.refreshCompareDiff(this.uiState.state.includeUncommitted);
299
+ this.gitManager?.compare.refreshCompareDiff(this.uiState.state.includeUncommitted);
283
300
  }
284
301
  else if (tab === 'explorer') {
285
302
  // Explorer is already loaded on init, but refresh if needed
@@ -287,68 +304,24 @@ export class App {
287
304
  this.explorerManager?.loadDirectory('');
288
305
  }
289
306
  }
290
- else if (tab === 'commit') {
291
- this.gitManager?.loadStashList();
292
- // Also load history if needed for HEAD commit display
293
- if (!this.gitManager?.historyState.commits.length) {
294
- this.gitManager?.loadHistory();
295
- }
296
- }
297
- });
298
- // Handle modal opening/closing
299
- this.uiState.on('modal-change', (modal) => {
300
- // Close any existing modal
301
- if (this.activeModal) {
302
- this.activeModal = null;
303
- }
304
- // Open new modal if requested
305
- if (modal === 'theme') {
306
- this.activeModal = new ThemePicker(this.screen, this.currentTheme, (theme) => {
307
- this.currentTheme = theme;
308
- saveConfig({ theme });
309
- this.activeModal = null;
310
- this.uiState.closeModal();
311
- this.render();
312
- }, () => {
313
- this.activeModal = null;
314
- this.uiState.closeModal();
315
- });
316
- this.activeModal.focus();
317
- }
318
- else if (modal === 'hotkeys') {
319
- this.activeModal = new HotkeysModal(this.screen, () => {
320
- this.activeModal = null;
321
- this.uiState.closeModal();
322
- });
323
- this.activeModal.focus();
324
- }
325
- else if (modal === 'baseBranch') {
326
- // Load candidate branches and show picker
327
- this.gitManager?.getCandidateBaseBranches().then((branches) => {
328
- const currentBranch = this.gitManager?.compareState.compareBaseBranch ?? null;
329
- this.activeModal = new BaseBranchPicker(this.screen, branches, currentBranch, (branch) => {
330
- this.activeModal = null;
331
- this.uiState.closeModal();
332
- // Set base branch and refresh compare view
333
- const includeUncommitted = this.uiState.state.includeUncommitted;
334
- this.gitManager?.setCompareBaseBranch(branch, includeUncommitted);
335
- }, () => {
336
- this.activeModal = null;
337
- this.uiState.closeModal();
338
- });
339
- this.activeModal.focus();
340
- });
341
- }
342
307
  });
343
- // Save split ratio to config when it changes
308
+ // Persist UI state to config when toggles or split ratio change
344
309
  let saveTimer = null;
345
310
  this.uiState.on('change', (state) => {
346
311
  if (saveTimer)
347
312
  clearTimeout(saveTimer);
348
313
  saveTimer = setTimeout(() => {
349
- if (state.splitRatio !== this.config.splitRatio) {
350
- saveConfig({ splitRatio: state.splitRatio });
351
- }
314
+ const updates = {};
315
+ if (state.splitRatio !== this.config.splitRatio)
316
+ updates.splitRatio = state.splitRatio;
317
+ if (state.autoTabEnabled !== this.config.autoTabEnabled)
318
+ updates.autoTabEnabled = state.autoTabEnabled;
319
+ if (state.wrapMode !== this.config.wrapMode)
320
+ updates.wrapMode = state.wrapMode;
321
+ if (state.mouseEnabled !== this.config.mouseEnabled)
322
+ updates.mouseEnabled = state.mouseEnabled;
323
+ if (Object.keys(updates).length > 0)
324
+ saveConfig(updates);
352
325
  }, 500);
353
326
  });
354
327
  }
@@ -361,108 +334,160 @@ export class App {
361
334
  this.render();
362
335
  }
363
336
  handleFollowFileNavigate(rawContent) {
364
- this.navigateToFile(rawContent);
337
+ this.navigation.navigateToFile(rawContent);
338
+ this.render();
339
+ }
340
+ recordCurrentRepo() {
341
+ const max = this.config.maxRecentRepos ?? 10;
342
+ const normalized = this.repoPath.replace(/\/$/, '');
343
+ this.recentRepos = [
344
+ normalized,
345
+ ...this.recentRepos.map((r) => r.replace(/\/$/, '')).filter((r) => r !== normalized),
346
+ ].slice(0, max);
347
+ addRecentRepo(this.repoPath, max);
348
+ }
349
+ switchToRepo(newPath) {
350
+ if (newPath === this.repoPath)
351
+ return;
352
+ if (this.followMode?.isEnabled)
353
+ this.followMode.stop();
354
+ const oldRepoPath = this.repoPath;
355
+ this.repoPath = newPath;
356
+ this.initGitManager(oldRepoPath);
357
+ this.resetRepoSpecificState();
358
+ this.loadCurrentTabData();
365
359
  this.render();
366
360
  }
367
361
  initGitManager(oldRepoPath) {
368
- // Clean up existing manager
362
+ // Clean up existing manager's event listeners
369
363
  if (this.gitManager) {
370
- this.gitManager.removeAllListeners();
364
+ this.gitManager.workingTree.removeAllListeners();
365
+ this.gitManager.history.removeAllListeners();
366
+ this.gitManager.compare.removeAllListeners();
367
+ this.gitManager.remote.removeAllListeners();
371
368
  // Use oldRepoPath if provided (when switching repos), otherwise use current path
372
369
  removeManagerForRepo(oldRepoPath ?? this.repoPath);
373
370
  }
374
371
  // Get or create manager for this repo
375
372
  this.gitManager = getManagerForRepo(this.repoPath);
376
- // Listen to state changes
377
- this.gitManager.on('state-change', () => {
373
+ // Listen to working tree state changes
374
+ this.gitManager.workingTree.on('state-change', () => {
378
375
  // Skip reconciliation while loading — the pending anchor must wait
379
376
  // for the new status to arrive before being consumed
380
- if (!this.gitManager?.state.isLoading) {
377
+ if (!this.gitManager?.workingTree.state.isLoading) {
381
378
  this.reconcileSelectionAfterStateChange();
379
+ this.applyAutoTab();
382
380
  }
383
381
  this.updateExplorerGitStatus();
384
382
  this.render();
385
383
  });
386
- this.gitManager.on('history-state-change', (historyState) => {
384
+ // Listen to history state changes
385
+ this.gitManager.history.on('history-state-change', (historyState) => {
387
386
  // Auto-select first commit when history loads
388
387
  if (historyState.commits.length > 0 && !historyState.selectedCommit) {
389
388
  const state = this.uiState.state;
390
389
  if (state.bottomTab === 'history') {
391
- this.selectHistoryCommitByIndex(state.historySelectedIndex);
390
+ this.navigation.selectHistoryCommitByIndex(state.historySelectedIndex);
392
391
  }
393
392
  }
394
393
  this.render();
395
394
  });
396
- this.gitManager.on('compare-state-change', () => {
395
+ // Listen to compare state changes
396
+ this.gitManager.compare.on('compare-state-change', () => {
397
397
  this.render();
398
398
  });
399
- this.gitManager.on('compare-selection-change', () => {
399
+ this.gitManager.compare.on('compare-selection-change', () => {
400
400
  this.render();
401
401
  });
402
- this.gitManager.on('remote-state-change', (remoteState) => {
402
+ // Listen to remote operation state changes
403
+ this.gitManager.remote.on('remote-state-change', (remoteState) => {
403
404
  // Auto-clear success after 3s, error after 5s
404
405
  if (this.remoteClearTimer)
405
406
  clearTimeout(this.remoteClearTimer);
406
407
  if (remoteState.lastResult && !remoteState.inProgress) {
407
408
  this.remoteClearTimer = setTimeout(() => {
408
- this.gitManager?.clearRemoteState();
409
+ this.gitManager?.remote.clearRemoteState();
409
410
  }, 3000);
410
411
  }
411
412
  else if (remoteState.error) {
412
413
  this.remoteClearTimer = setTimeout(() => {
413
- this.gitManager?.clearRemoteState();
414
+ this.gitManager?.remote.clearRemoteState();
414
415
  }, 5000);
415
416
  }
416
417
  this.render();
417
418
  });
418
419
  // Start watching and do initial refresh
419
- this.gitManager.startWatching();
420
- this.gitManager.refresh();
420
+ this.gitManager.workingTree.startWatching();
421
+ this.gitManager.workingTree.refresh();
421
422
  // Initialize explorer manager
422
423
  this.initExplorerManager();
424
+ // Record this repo in recent repos list
425
+ this.recordCurrentRepo();
426
+ }
427
+ /**
428
+ * Load history with error handling (moved from facade).
429
+ */
430
+ loadHistory(count = 100) {
431
+ this.gitManager?.history.loadHistory(count).catch((err) => {
432
+ this.showError(`Failed to load history: ${err instanceof Error ? err.message : String(err)}`);
433
+ });
423
434
  }
424
435
  /**
425
436
  * After git state changes, reconcile the selected file index.
426
437
  * Handles both flat mode (path-based anchoring) and categorized mode (category-based anchoring).
427
438
  */
428
439
  reconcileSelectionAfterStateChange() {
429
- const files = this.gitManager?.state.status?.files ?? [];
430
- if (this.uiState.state.flatViewMode && this.pendingFlatSelectionPath) {
431
- const flatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
432
- const targetPath = this.pendingFlatSelectionPath;
433
- this.pendingFlatSelectionPath = null;
434
- const newIndex = getFlatFileIndexByPath(flatFiles, targetPath);
440
+ const files = this.gitManager?.workingTree.state.status?.files ?? [];
441
+ const pendingFlatPath = this.staging.consumePendingFlatSelectionPath();
442
+ if (this.uiState.state.flatViewMode && pendingFlatPath) {
443
+ const flatFiles = buildFlatFileList(files, this.gitManager?.workingTree.state.hunkCounts ?? null);
444
+ const newIndex = getFlatFileIndexByPath(flatFiles, pendingFlatPath);
435
445
  if (newIndex >= 0) {
436
446
  this.uiState.setSelectedIndex(newIndex);
437
- this.selectFileByIndex(newIndex);
447
+ this.navigation.selectFileByIndex(newIndex);
438
448
  }
439
449
  else if (flatFiles.length > 0) {
440
450
  const clamped = Math.min(this.uiState.state.selectedIndex, flatFiles.length - 1);
441
451
  this.uiState.setSelectedIndex(clamped);
442
- this.selectFileByIndex(clamped);
452
+ this.navigation.selectFileByIndex(clamped);
443
453
  }
444
454
  return;
445
455
  }
446
- if (this.pendingSelectionAnchor) {
447
- const anchor = this.pendingSelectionAnchor;
448
- this.pendingSelectionAnchor = null;
456
+ const anchor = this.staging.consumePendingSelectionAnchor();
457
+ if (anchor) {
449
458
  const newIndex = getIndexForCategoryPosition(files, anchor.category, anchor.categoryIndex);
450
459
  this.uiState.setSelectedIndex(newIndex);
451
- this.selectFileByIndex(newIndex);
460
+ this.navigation.selectFileByIndex(newIndex);
452
461
  return;
453
462
  }
454
- // No pending anchor — just clamp to valid range
463
+ // No pending anchor — clamp to valid range and sync diff if file changed
464
+ const currentSelected = this.gitManager?.workingTree.state.selectedFile ?? null;
455
465
  if (this.uiState.state.flatViewMode) {
456
- const flatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
466
+ const flatFiles = buildFlatFileList(files, this.gitManager?.workingTree.state.hunkCounts ?? null);
457
467
  const maxIndex = flatFiles.length - 1;
458
- if (maxIndex >= 0 && this.uiState.state.selectedIndex > maxIndex) {
459
- this.uiState.setSelectedIndex(maxIndex);
468
+ let idx = this.uiState.state.selectedIndex;
469
+ if (maxIndex >= 0 && idx > maxIndex) {
470
+ idx = maxIndex;
471
+ this.uiState.setSelectedIndex(idx);
472
+ }
473
+ const flatEntry = getFlatFileAtIndex(flatFiles, idx);
474
+ const fileAtIdx = flatEntry?.unstagedEntry ?? flatEntry?.stagedEntry ?? null;
475
+ if (fileAtIdx &&
476
+ (fileAtIdx.path !== currentSelected?.path || fileAtIdx.staged !== currentSelected?.staged)) {
477
+ this.navigation.selectFileByIndex(idx);
460
478
  }
461
479
  }
462
480
  else if (files.length > 0) {
463
481
  const maxIndex = files.length - 1;
464
- if (this.uiState.state.selectedIndex > maxIndex) {
465
- this.uiState.setSelectedIndex(maxIndex);
482
+ let idx = this.uiState.state.selectedIndex;
483
+ if (idx > maxIndex) {
484
+ idx = maxIndex;
485
+ this.uiState.setSelectedIndex(idx);
486
+ }
487
+ const fileAtIdx = getFileAtIndex(files, idx);
488
+ if (fileAtIdx &&
489
+ (fileAtIdx.path !== currentSelected?.path || fileAtIdx.staged !== currentSelected?.staged)) {
490
+ this.navigation.selectFileByIndex(idx);
466
491
  }
467
492
  }
468
493
  }
@@ -495,7 +520,7 @@ export class App {
495
520
  updateExplorerGitStatus() {
496
521
  if (!this.explorerManager || !this.gitManager)
497
522
  return;
498
- const files = this.gitManager.state.status?.files ?? [];
523
+ const files = this.gitManager.workingTree.state.status?.files ?? [];
499
524
  const statusMap = {
500
525
  files: new Map(),
501
526
  directories: new Set(),
@@ -519,8 +544,8 @@ export class App {
519
544
  * Called when switching to a new repo via file watcher.
520
545
  */
521
546
  resetRepoSpecificState() {
522
- // Reset compare selection (App-level state)
523
- this.compareSelection = null;
547
+ // Reset compare selection (owned by NavigationController)
548
+ this.navigation.compareSelection = null;
524
549
  // Reset UI state scroll offsets and selections
525
550
  this.uiState.resetForNewRepo();
526
551
  }
@@ -531,26 +556,26 @@ export class App {
531
556
  loadCurrentTabData() {
532
557
  const tab = this.uiState.state.bottomTab;
533
558
  if (tab === 'history') {
534
- this.gitManager?.loadHistory();
559
+ this.loadHistory();
535
560
  }
536
561
  else if (tab === 'compare') {
537
- this.gitManager?.refreshCompareDiff(this.uiState.state.includeUncommitted);
562
+ this.gitManager?.compare.refreshCompareDiff(this.uiState.state.includeUncommitted);
538
563
  }
539
- // Diff tab data is loaded by gitManager.refresh() in initGitManager
564
+ // Diff tab data is loaded by gitManager.workingTree.refresh() in initGitManager
540
565
  // Explorer data is loaded by initExplorerManager()
541
566
  }
542
567
  setupCommandHandler() {
543
568
  if (!this.commandServer)
544
569
  return;
545
570
  const handler = {
546
- navigateUp: () => this.navigateUp(),
547
- navigateDown: () => this.navigateDown(),
571
+ navigateUp: () => this.navigation.navigateUp(),
572
+ navigateDown: () => this.navigation.navigateDown(),
548
573
  switchTab: (tab) => this.uiState.setTab(tab),
549
574
  togglePane: () => this.uiState.togglePane(),
550
- stage: async () => this.stageSelected(),
551
- unstage: async () => this.unstageSelected(),
552
- stageAll: async () => this.stageAll(),
553
- unstageAll: async () => this.unstageAll(),
575
+ stage: async () => this.staging.stageSelected(),
576
+ unstage: async () => this.staging.unstageSelected(),
577
+ stageAll: async () => this.staging.stageAll(),
578
+ unstageAll: async () => this.staging.unstageAll(),
554
579
  commit: async (message) => this.commit(message),
555
580
  refresh: async () => this.refresh(),
556
581
  getState: () => this.getAppState(),
@@ -561,8 +586,8 @@ export class App {
561
586
  }
562
587
  getAppState() {
563
588
  const state = this.uiState.state;
564
- const gitState = this.gitManager?.state;
565
- const historyState = this.gitManager?.historyState;
589
+ const gitState = this.gitManager?.workingTree.state;
590
+ const historyState = this.gitManager?.history.historyState;
566
591
  const files = gitState?.status?.files ?? [];
567
592
  const commits = historyState?.commits ?? [];
568
593
  return {
@@ -589,551 +614,11 @@ export class App {
589
614
  autoTabEnabled: state.autoTabEnabled,
590
615
  };
591
616
  }
592
- // Navigation methods
593
- /**
594
- * Scroll the content pane (diff or explorer file content) by delta lines.
595
- */
596
- scrollActiveDiffPane(delta) {
597
- const state = this.uiState.state;
598
- if (state.bottomTab === 'explorer') {
599
- const newOffset = Math.max(0, state.explorerFileScrollOffset + delta);
600
- this.uiState.setExplorerFileScrollOffset(newOffset);
601
- }
602
- else {
603
- const newOffset = Math.max(0, state.diffScrollOffset + delta);
604
- this.uiState.setDiffScrollOffset(newOffset);
605
- }
606
- }
607
- /**
608
- * Navigate the file list by one item and keep selection visible.
609
- */
610
- navigateFileList(direction) {
611
- const state = this.uiState.state;
612
- const files = this.gitManager?.state.status?.files ?? [];
613
- // Determine max index based on view mode
614
- const maxIndex = state.flatViewMode ? this.cachedFlatFiles.length - 1 : files.length - 1;
615
- if (maxIndex < 0)
616
- return;
617
- const newIndex = direction === -1
618
- ? Math.max(0, state.selectedIndex - 1)
619
- : Math.min(maxIndex, state.selectedIndex + 1);
620
- this.uiState.setSelectedIndex(newIndex);
621
- this.selectFileByIndex(newIndex);
622
- // In flat mode row === index + 1 (header row); in categorized mode account for headers/spacers
623
- const row = state.flatViewMode ? newIndex + 1 : getRowFromFileIndex(newIndex, files);
624
- this.scrollToKeepRowVisible(row, direction, state.fileListScrollOffset);
625
- }
626
- /**
627
- * Scroll the file list to keep a given row visible.
628
- */
629
- scrollToKeepRowVisible(row, direction, currentOffset) {
630
- if (direction === -1 && row < currentOffset) {
631
- this.uiState.setFileListScrollOffset(row);
632
- }
633
- else if (direction === 1) {
634
- const visibleEnd = currentOffset + this.layout.dimensions.topPaneHeight - 1;
635
- if (row >= visibleEnd) {
636
- this.uiState.setFileListScrollOffset(currentOffset + (row - visibleEnd + 1));
637
- }
638
- }
639
- }
640
- /**
641
- * Navigate the active list pane by one item in the given direction.
642
- */
643
- navigateActiveList(direction) {
644
- const tab = this.uiState.state.bottomTab;
645
- if (tab === 'history') {
646
- if (direction === -1)
647
- this.navigateHistoryUp();
648
- else
649
- this.navigateHistoryDown();
650
- }
651
- else if (tab === 'compare') {
652
- if (direction === -1)
653
- this.navigateCompareUp();
654
- else
655
- this.navigateCompareDown();
656
- }
657
- else if (tab === 'explorer') {
658
- if (direction === -1)
659
- this.navigateExplorerUp();
660
- else
661
- this.navigateExplorerDown();
662
- }
663
- else {
664
- this.navigateFileList(direction);
665
- }
666
- }
667
- navigateUp() {
668
- const state = this.uiState.state;
669
- const isListPane = state.currentPane !== 'diff';
670
- if (isListPane) {
671
- this.navigateActiveList(-1);
672
- }
673
- else {
674
- this.scrollActiveDiffPane(-3);
675
- }
676
- }
677
- navigateDown() {
678
- const state = this.uiState.state;
679
- const isListPane = state.currentPane !== 'diff';
680
- if (isListPane) {
681
- this.navigateActiveList(1);
682
- }
683
- else {
684
- this.scrollActiveDiffPane(3);
685
- }
686
- }
687
- navigateHistoryUp() {
688
- const state = this.uiState.state;
689
- const newIndex = Math.max(0, state.historySelectedIndex - 1);
690
- if (newIndex !== state.historySelectedIndex) {
691
- this.uiState.setHistorySelectedIndex(newIndex);
692
- // Keep selection visible
693
- if (newIndex < state.historyScrollOffset) {
694
- this.uiState.setHistoryScrollOffset(newIndex);
695
- }
696
- this.selectHistoryCommitByIndex(newIndex);
697
- }
698
- }
699
- navigateHistoryDown() {
700
- const state = this.uiState.state;
701
- const commits = this.gitManager?.historyState.commits ?? [];
702
- const newIndex = Math.min(commits.length - 1, state.historySelectedIndex + 1);
703
- if (newIndex !== state.historySelectedIndex) {
704
- this.uiState.setHistorySelectedIndex(newIndex);
705
- // Keep selection visible
706
- const visibleEnd = state.historyScrollOffset + this.layout.dimensions.topPaneHeight - 1;
707
- if (newIndex >= visibleEnd) {
708
- this.uiState.setHistoryScrollOffset(state.historyScrollOffset + 1);
709
- }
710
- this.selectHistoryCommitByIndex(newIndex);
711
- }
712
- }
713
- selectHistoryCommitByIndex(index) {
714
- const commits = this.gitManager?.historyState.commits ?? [];
715
- const commit = getCommitAtIndex(commits, index);
716
- if (commit) {
717
- this.uiState.setDiffScrollOffset(0);
718
- this.gitManager?.selectHistoryCommit(commit);
719
- }
720
- }
721
- // Compare navigation
722
- compareSelection = null;
723
- navigateCompareUp() {
724
- const compareState = this.gitManager?.compareState;
725
- const commits = compareState?.compareDiff?.commits ?? [];
726
- const files = compareState?.compareDiff?.files ?? [];
727
- if (commits.length === 0 && files.length === 0)
728
- return;
729
- const next = getNextCompareSelection(this.compareSelection, commits, files, 'up');
730
- if (next &&
731
- (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
732
- this.selectCompareItem(next);
733
- // Keep selection visible - scroll up if needed
734
- const state = this.uiState.state;
735
- const row = getRowFromCompareSelection(next, commits, files);
736
- if (row < state.compareScrollOffset) {
737
- this.uiState.setCompareScrollOffset(row);
738
- }
739
- }
740
- }
741
- navigateCompareDown() {
742
- const compareState = this.gitManager?.compareState;
743
- const commits = compareState?.compareDiff?.commits ?? [];
744
- const files = compareState?.compareDiff?.files ?? [];
745
- if (commits.length === 0 && files.length === 0)
746
- return;
747
- // Auto-select first item if nothing selected
748
- if (!this.compareSelection) {
749
- // Select first commit if available, otherwise first file
750
- if (commits.length > 0) {
751
- this.selectCompareItem({ type: 'commit', index: 0 });
752
- }
753
- else if (files.length > 0) {
754
- this.selectCompareItem({ type: 'file', index: 0 });
755
- }
756
- return;
757
- }
758
- const next = getNextCompareSelection(this.compareSelection, commits, files, 'down');
759
- if (next &&
760
- (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
761
- this.selectCompareItem(next);
762
- // Keep selection visible - scroll down if needed
763
- const state = this.uiState.state;
764
- const row = getRowFromCompareSelection(next, commits, files);
765
- const visibleEnd = state.compareScrollOffset + this.layout.dimensions.topPaneHeight - 1;
766
- if (row >= visibleEnd) {
767
- this.uiState.setCompareScrollOffset(state.compareScrollOffset + (row - visibleEnd + 1));
768
- }
769
- }
770
- }
771
- selectCompareItem(selection) {
772
- this.compareSelection = selection;
773
- this.uiState.setDiffScrollOffset(0);
774
- if (selection.type === 'commit') {
775
- this.gitManager?.selectCompareCommit(selection.index);
776
- }
777
- else {
778
- this.gitManager?.selectCompareFile(selection.index);
779
- }
780
- }
781
- // Explorer navigation
782
- navigateExplorerUp() {
783
- const state = this.uiState.state;
784
- const rows = this.explorerManager?.state.displayRows ?? [];
785
- if (rows.length === 0)
786
- return;
787
- const newScrollOffset = this.explorerManager?.navigateUp(state.explorerScrollOffset);
788
- if (newScrollOffset !== null && newScrollOffset !== undefined) {
789
- this.uiState.setExplorerScrollOffset(newScrollOffset);
790
- }
791
- this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
792
- }
793
- navigateExplorerDown() {
794
- const state = this.uiState.state;
795
- const rows = this.explorerManager?.state.displayRows ?? [];
796
- if (rows.length === 0)
797
- return;
798
- const visibleHeight = this.layout.dimensions.topPaneHeight;
799
- const newScrollOffset = this.explorerManager?.navigateDown(state.explorerScrollOffset, visibleHeight);
800
- if (newScrollOffset !== null && newScrollOffset !== undefined) {
801
- this.uiState.setExplorerScrollOffset(newScrollOffset);
802
- }
803
- this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
804
- }
805
- async enterExplorerDirectory() {
806
- await this.explorerManager?.enterDirectory();
807
- // Reset file content scroll when expanding/collapsing
808
- this.uiState.setExplorerFileScrollOffset(0);
809
- // Sync selected index from explorer manager (it maintains selection by path)
810
- this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
811
- }
812
- async goExplorerUp() {
813
- await this.explorerManager?.goUp();
814
- // Reset file content scroll when collapsing
815
- this.uiState.setExplorerFileScrollOffset(0);
816
- // Sync selected index from explorer manager
817
- this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
818
- }
819
- selectFileByIndex(index) {
820
- if (this.uiState.state.flatViewMode) {
821
- const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
822
- if (flatEntry) {
823
- // Prefer unstaged entry (shows unstaged diff for partial files), fallback to staged
824
- const file = flatEntry.unstagedEntry ?? flatEntry.stagedEntry;
825
- if (file) {
826
- this.uiState.setDiffScrollOffset(0);
827
- this.uiState.setSelectedHunkIndex(0);
828
- this.gitManager?.selectFile(file);
829
- }
830
- }
831
- }
832
- else {
833
- const files = this.gitManager?.state.status?.files ?? [];
834
- const file = getFileAtIndex(files, index);
835
- if (file) {
836
- this.uiState.setDiffScrollOffset(0);
837
- this.uiState.setSelectedHunkIndex(0);
838
- this.gitManager?.selectFile(file);
839
- }
840
- }
841
- }
842
- /**
843
- * Navigate to a file given its absolute path.
844
- * Extracts the relative path and finds the file in the current file list.
845
- */
846
- navigateToFile(absolutePath) {
847
- if (!absolutePath || !this.repoPath)
848
- return;
849
- // Check if the path is within the current repo
850
- const repoPrefix = this.repoPath.endsWith('/') ? this.repoPath : this.repoPath + '/';
851
- if (!absolutePath.startsWith(repoPrefix))
852
- return;
853
- // Extract relative path
854
- const relativePath = absolutePath.slice(repoPrefix.length);
855
- if (!relativePath)
856
- return;
857
- // Find the file in the list
858
- const files = this.gitManager?.state.status?.files ?? [];
859
- const fileIndex = files.findIndex((f) => f.path === relativePath);
860
- if (fileIndex >= 0) {
861
- this.uiState.setSelectedIndex(fileIndex);
862
- this.selectFileByIndex(fileIndex);
863
- }
864
- }
865
- // Git operations
866
- async stageSelected() {
867
- const files = this.gitManager?.state.status?.files ?? [];
868
- const index = this.uiState.state.selectedIndex;
869
- if (this.uiState.state.flatViewMode) {
870
- const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
871
- if (!flatEntry)
872
- return;
873
- // Stage: operate on the unstaged entry if available
874
- const file = flatEntry.unstagedEntry;
875
- if (file) {
876
- this.pendingFlatSelectionPath = flatEntry.path;
877
- await this.gitManager?.stage(file);
878
- }
879
- }
880
- else {
881
- const selectedFile = getFileAtIndex(files, index);
882
- if (selectedFile && !selectedFile.staged) {
883
- this.pendingSelectionAnchor = getCategoryForIndex(files, index);
884
- await this.gitManager?.stage(selectedFile);
885
- }
886
- }
887
- }
888
- async unstageSelected() {
889
- const files = this.gitManager?.state.status?.files ?? [];
890
- const index = this.uiState.state.selectedIndex;
891
- if (this.uiState.state.flatViewMode) {
892
- const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
893
- if (!flatEntry)
894
- return;
895
- const file = flatEntry.stagedEntry;
896
- if (file) {
897
- this.pendingFlatSelectionPath = flatEntry.path;
898
- await this.gitManager?.unstage(file);
899
- }
900
- }
901
- else {
902
- const selectedFile = getFileAtIndex(files, index);
903
- if (selectedFile?.staged) {
904
- this.pendingSelectionAnchor = getCategoryForIndex(files, index);
905
- await this.gitManager?.unstage(selectedFile);
906
- }
907
- }
908
- }
909
- async toggleSelected() {
910
- const index = this.uiState.state.selectedIndex;
911
- if (this.uiState.state.flatViewMode) {
912
- const flatEntry = getFlatFileAtIndex(this.cachedFlatFiles, index);
913
- if (flatEntry)
914
- await this.toggleFlatEntry(flatEntry);
915
- }
916
- else {
917
- const files = this.gitManager?.state.status?.files ?? [];
918
- const selectedFile = getFileAtIndex(files, index);
919
- if (selectedFile) {
920
- this.pendingSelectionAnchor = getCategoryForIndex(files, index);
921
- if (selectedFile.staged) {
922
- await this.gitManager?.unstage(selectedFile);
923
- }
924
- else {
925
- await this.gitManager?.stage(selectedFile);
926
- }
927
- }
928
- }
929
- }
930
- async stageAll() {
931
- await this.gitManager?.stageAll();
932
- }
933
- async unstageAll() {
934
- await this.gitManager?.unstageAll();
935
- }
936
- showDiscardConfirm(file) {
937
- this.activeModal = new DiscardConfirm(this.screen, file.path, async () => {
938
- this.activeModal = null;
939
- await this.gitManager?.discard(file);
940
- }, () => {
941
- this.activeModal = null;
942
- });
943
- this.activeModal.focus();
944
- }
945
- // Hunk navigation and staging
946
- navigateNextHunk() {
947
- const current = this.uiState.state.selectedHunkIndex;
948
- if (this.bottomPaneHunkCount > 0 && current < this.bottomPaneHunkCount - 1) {
949
- this.uiState.setSelectedHunkIndex(current + 1);
950
- this.scrollHunkIntoView(current + 1);
951
- }
952
- }
953
- navigatePrevHunk() {
954
- const current = this.uiState.state.selectedHunkIndex;
955
- if (current > 0) {
956
- this.uiState.setSelectedHunkIndex(current - 1);
957
- this.scrollHunkIntoView(current - 1);
958
- }
959
- }
960
- scrollHunkIntoView(hunkIndex) {
961
- const boundary = this.bottomPaneHunkBoundaries[hunkIndex];
962
- if (!boundary)
963
- return;
964
- const scrollOffset = this.uiState.state.diffScrollOffset;
965
- const visibleHeight = this.layout.dimensions.bottomPaneHeight;
966
- // If hunk header is outside the visible area, scroll so it's at top
967
- if (boundary.startRow < scrollOffset || boundary.startRow >= scrollOffset + visibleHeight) {
968
- this.uiState.setDiffScrollOffset(boundary.startRow);
969
- }
970
- }
971
- selectHunkAtRow(visualRow) {
972
- if (this.uiState.state.bottomTab !== 'diff')
973
- return;
974
- if (this.bottomPaneHunkBoundaries.length === 0)
975
- return;
976
- // Focus the diff pane so the hunk gutter appears
977
- this.uiState.setPane('diff');
978
- const absoluteRow = this.uiState.state.diffScrollOffset + visualRow;
979
- for (let i = 0; i < this.bottomPaneHunkBoundaries.length; i++) {
980
- const b = this.bottomPaneHunkBoundaries[i];
981
- if (absoluteRow >= b.startRow && absoluteRow < b.endRow) {
982
- this.uiState.setSelectedHunkIndex(i);
983
- return;
984
- }
985
- }
986
- }
987
- async toggleCurrentHunk() {
988
- const selectedFile = this.gitManager?.state.selectedFile;
989
- if (!selectedFile || selectedFile.status === 'untracked')
990
- return;
991
- if (this.uiState.state.flatViewMode) {
992
- await this.toggleCurrentHunkFlat();
993
- }
994
- else {
995
- await this.toggleCurrentHunkCategorized(selectedFile);
996
- }
997
- }
998
- async toggleCurrentHunkFlat() {
999
- const mapping = this.combinedHunkMapping[this.uiState.state.selectedHunkIndex];
1000
- if (!mapping)
1001
- return;
1002
- const combined = this.gitManager?.state.combinedFileDiffs;
1003
- if (!combined)
1004
- return;
1005
- const rawDiff = mapping.source === 'unstaged' ? combined.unstaged.raw : combined.staged.raw;
1006
- const patch = extractHunkPatch(rawDiff, mapping.hunkIndex);
1007
- if (!patch)
1008
- return;
1009
- // Preserve hunk index across refresh — file stays selected via path-only fallback
1010
- this.pendingHunkIndex = this.uiState.state.selectedHunkIndex;
1011
- if (mapping.source === 'staged') {
1012
- await this.gitManager?.unstageHunk(patch);
1013
- }
1014
- else {
1015
- await this.gitManager?.stageHunk(patch);
1016
- }
1017
- }
1018
- async toggleCurrentHunkCategorized(selectedFile) {
1019
- const rawDiff = this.gitManager?.state.diff?.raw;
1020
- if (!rawDiff)
1021
- return;
1022
- const patch = extractHunkPatch(rawDiff, this.uiState.state.selectedHunkIndex);
1023
- if (!patch)
1024
- return;
1025
- const files = this.gitManager?.state.status?.files ?? [];
1026
- this.pendingSelectionAnchor = getCategoryForIndex(files, this.uiState.state.selectedIndex);
1027
- if (selectedFile.staged) {
1028
- await this.gitManager?.unstageHunk(patch);
1029
- }
1030
- else {
1031
- await this.gitManager?.stageHunk(patch);
1032
- }
1033
- }
1034
- async openFileFinder() {
1035
- let allPaths = this.explorerManager?.getCachedFilePaths() ?? [];
1036
- if (allPaths.length === 0) {
1037
- // First open or cache not yet loaded — wait for it
1038
- await this.explorerManager?.loadFilePaths();
1039
- allPaths = this.explorerManager?.getCachedFilePaths() ?? [];
1040
- }
1041
- if (allPaths.length === 0)
1042
- return;
1043
- this.activeModal = new FileFinder(this.screen, allPaths, async (selectedPath) => {
1044
- this.activeModal = null;
1045
- // Switch to explorer tab if not already there
1046
- if (this.uiState.state.bottomTab !== 'explorer') {
1047
- this.uiState.setTab('explorer');
1048
- }
1049
- // Navigate to the selected file in explorer
1050
- const success = await this.explorerManager?.navigateToPath(selectedPath);
1051
- if (success) {
1052
- // Sync selected index from explorer manager
1053
- const selectedIndex = this.explorerManager?.state.selectedIndex ?? 0;
1054
- this.uiState.setExplorerSelectedIndex(selectedIndex);
1055
- this.uiState.setExplorerFileScrollOffset(0);
1056
- // Scroll to make selected file visible
1057
- const visibleHeight = this.layout.dimensions.topPaneHeight;
1058
- if (selectedIndex >= visibleHeight) {
1059
- this.uiState.setExplorerScrollOffset(selectedIndex - Math.floor(visibleHeight / 2));
1060
- }
1061
- else {
1062
- this.uiState.setExplorerScrollOffset(0);
1063
- }
1064
- }
1065
- this.render();
1066
- }, () => {
1067
- this.activeModal = null;
1068
- this.render();
1069
- });
1070
- this.activeModal.focus();
1071
- }
1072
- openStashListModal() {
1073
- const entries = this.gitManager?.state.stashList ?? [];
1074
- this.activeModal = new StashListModal(this.screen, entries, (index) => {
1075
- this.activeModal = null;
1076
- this.gitManager?.stashPop(index);
1077
- }, () => {
1078
- this.activeModal = null;
1079
- });
1080
- this.activeModal.focus();
1081
- }
1082
- openBranchPicker() {
1083
- this.gitManager?.getLocalBranches().then((branches) => {
1084
- this.activeModal = new BranchPicker(this.screen, branches, (name) => {
1085
- this.activeModal = null;
1086
- this.gitManager?.switchBranch(name);
1087
- }, (name) => {
1088
- this.activeModal = null;
1089
- this.gitManager?.createBranch(name);
1090
- }, () => {
1091
- this.activeModal = null;
1092
- });
1093
- this.activeModal.focus();
1094
- });
1095
- }
1096
- showSoftResetConfirm() {
1097
- const headCommit = this.gitManager?.historyState.commits[0];
1098
- if (!headCommit)
1099
- return;
1100
- this.activeModal = new SoftResetConfirm(this.screen, headCommit, () => {
1101
- this.activeModal = null;
1102
- this.gitManager?.softReset();
1103
- }, () => {
1104
- this.activeModal = null;
1105
- });
1106
- this.activeModal.focus();
1107
- }
1108
- cherryPickSelected() {
1109
- const commit = this.gitManager?.historyState.selectedCommit;
1110
- if (!commit)
1111
- return;
1112
- this.activeModal = new CommitActionConfirm(this.screen, 'Cherry-pick', commit, () => {
1113
- this.activeModal = null;
1114
- this.gitManager?.cherryPick(commit.hash);
1115
- }, () => {
1116
- this.activeModal = null;
1117
- });
1118
- this.activeModal.focus();
1119
- }
1120
- revertSelected() {
1121
- const commit = this.gitManager?.historyState.selectedCommit;
1122
- if (!commit)
1123
- return;
1124
- this.activeModal = new CommitActionConfirm(this.screen, 'Revert', commit, () => {
1125
- this.activeModal = null;
1126
- this.gitManager?.revertCommit(commit.hash);
1127
- }, () => {
1128
- this.activeModal = null;
1129
- });
1130
- this.activeModal.focus();
1131
- }
1132
617
  async commit(message) {
1133
- await this.gitManager?.commit(message);
618
+ await this.gitManager?.workingTree.commit(message);
1134
619
  }
1135
620
  async refresh() {
1136
- await this.gitManager?.refresh();
621
+ await this.gitManager?.workingTree.refresh();
1137
622
  }
1138
623
  toggleMouseMode() {
1139
624
  const willEnable = !this.uiState.state.mouseEnabled;
@@ -1148,6 +633,29 @@ export class App {
1148
633
  program.disableMouse();
1149
634
  }
1150
635
  }
636
+ /**
637
+ * When auto-tab is enabled, switch tabs based on file count transitions:
638
+ * - Files disappear (prev > 0, current === 0): switch to history
639
+ * - Files appear (prev === 0, current > 0): switch to diff
640
+ * Always updates prevFileCount so enabling doesn't trigger on stale state.
641
+ */
642
+ applyAutoTab() {
643
+ const files = this.gitManager?.workingTree.state.status?.files ?? [];
644
+ const currentCount = files.length;
645
+ const prev = this.prevFileCount;
646
+ this.prevFileCount = currentCount;
647
+ if (!this.uiState.state.autoTabEnabled)
648
+ return;
649
+ const tab = this.uiState.state.bottomTab;
650
+ if (prev > 0 && currentCount === 0 && (tab === 'diff' || tab === 'commit')) {
651
+ this.uiState.setHistorySelectedIndex(0);
652
+ this.uiState.setHistoryScrollOffset(0);
653
+ this.uiState.setTab('history');
654
+ }
655
+ else if (prev === 0 && currentCount > 0 && tab === 'history') {
656
+ this.uiState.setTab('diff');
657
+ }
658
+ }
1151
659
  toggleFollow() {
1152
660
  if (!this.followMode) {
1153
661
  this.followMode = new FollowMode(this.config.targetFile, () => this.repoPath, {
@@ -1183,44 +691,56 @@ export class App {
1183
691
  this.updateTopPane();
1184
692
  this.updateBottomPane();
1185
693
  // Restore hunk index after diff refresh (e.g. after hunk toggle in flat mode)
1186
- if (this.pendingHunkIndex !== null && this.bottomPaneHunkCount > 0) {
1187
- const restored = Math.min(this.pendingHunkIndex, this.bottomPaneHunkCount - 1);
1188
- this.pendingHunkIndex = null;
694
+ const pendingHunk = this.staging.consumePendingHunkIndex();
695
+ if (pendingHunk !== null && this.bottomPaneHunkCount > 0) {
696
+ const restored = Math.min(pendingHunk, this.bottomPaneHunkCount - 1);
1189
697
  this.uiState.setSelectedHunkIndex(restored);
1190
698
  this.updateBottomPane(); // Re-render with correct hunk selection
1191
699
  }
700
+ this.updateSeparators();
1192
701
  this.updateFooter();
1193
702
  this.screen.render();
1194
703
  }
704
+ updateSeparators() {
705
+ const zone = this.uiState.state.focusedZone;
706
+ // Top-pane zones: fileList, historyList, compareList, explorerTree
707
+ const isTopPaneZone = zone === 'fileList' ||
708
+ zone === 'historyList' ||
709
+ zone === 'compareList' ||
710
+ zone === 'explorerTree';
711
+ this.layout.middleSeparator.style.fg = isTopPaneZone ? 'cyan' : 'gray';
712
+ }
1195
713
  updateHeader() {
1196
- const gitState = this.gitManager?.state;
714
+ const gitState = this.gitManager?.workingTree.state;
1197
715
  const width = this.screen.width || 80;
1198
- const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width, this.gitManager?.remoteState ?? null);
716
+ const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width, this.gitManager?.remote.remoteState ?? null);
1199
717
  this.layout.headerBox.setContent(content);
1200
718
  }
1201
719
  updateTopPane() {
1202
720
  const state = this.uiState.state;
1203
721
  const width = this.screen.width || 80;
1204
- const files = this.gitManager?.state.status?.files ?? [];
722
+ const files = this.gitManager?.workingTree.state.status?.files ?? [];
1205
723
  // Build and cache flat file list when in flat mode
1206
724
  if (state.flatViewMode) {
1207
- this.cachedFlatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
725
+ this.cachedFlatFiles = buildFlatFileList(files, this.gitManager?.workingTree.state.hunkCounts ?? null);
1208
726
  }
1209
- const content = renderTopPane(state, files, this.gitManager?.historyState?.commits ?? [], this.gitManager?.compareState?.compareDiff ?? null, this.compareSelection, this.explorerManager?.state, width, this.layout.dimensions.topPaneHeight, this.gitManager?.state.hunkCounts, state.flatViewMode ? this.cachedFlatFiles : undefined);
727
+ const content = renderTopPane(state, files, this.gitManager?.history.historyState?.commits ?? [], this.gitManager?.compare.compareState?.compareDiff ?? null, this.navigation.compareSelection, this.explorerManager?.state, width, this.layout.dimensions.topPaneHeight, this.gitManager?.workingTree.state.hunkCounts, state.flatViewMode ? this.cachedFlatFiles : undefined);
1210
728
  this.layout.topPane.setContent(content);
1211
729
  }
1212
730
  updateBottomPane() {
1213
731
  const state = this.uiState.state;
1214
732
  const width = this.screen.width || 80;
1215
- const files = this.gitManager?.state.status?.files ?? [];
733
+ const files = this.gitManager?.workingTree.state.status?.files ?? [];
1216
734
  const stagedCount = files.filter((f) => f.staged).length;
1217
735
  // Update staged count for commit validation
1218
736
  this.commitFlowState.setStagedCount(stagedCount);
1219
737
  // Pass selectedHunkIndex and staged status only when diff pane is focused on diff tab
1220
738
  const diffPaneFocused = state.bottomTab === 'diff' && state.currentPane === 'diff';
1221
739
  const hunkIndex = diffPaneFocused ? state.selectedHunkIndex : undefined;
1222
- const isFileStaged = diffPaneFocused ? this.gitManager?.state.selectedFile?.staged : undefined;
1223
- const { content, totalRows, hunkCount, hunkBoundaries, hunkMapping } = renderBottomPane(state, this.gitManager?.state.diff ?? null, this.gitManager?.historyState, this.gitManager?.compareSelectionState, this.explorerManager?.state?.selectedFile ?? null, this.commitFlowState.state, stagedCount, this.currentTheme, width, this.layout.dimensions.bottomPaneHeight, hunkIndex, isFileStaged, state.flatViewMode ? this.gitManager?.state.combinedFileDiffs : undefined, this.gitManager?.state.status?.branch ?? null, this.gitManager?.remoteState ?? null, this.gitManager?.state.stashList, this.gitManager?.historyState.commits[0] ?? null);
740
+ const isFileStaged = diffPaneFocused
741
+ ? this.gitManager?.workingTree.state.selectedFile?.staged
742
+ : undefined;
743
+ const { content, totalRows, hunkCount, hunkBoundaries, hunkMapping } = renderBottomPane(state, this.gitManager?.workingTree.state.diff ?? null, this.gitManager?.history.historyState, this.gitManager?.compare.compareSelectionState, this.explorerManager?.state?.selectedFile ?? null, this.commitFlowState.state, stagedCount, this.currentTheme, width, this.layout.dimensions.bottomPaneHeight, hunkIndex, isFileStaged, state.flatViewMode ? this.gitManager?.workingTree.state.combinedFileDiffs : undefined, state.focusedZone);
1224
744
  this.bottomPaneTotalRows = totalRows;
1225
745
  this.bottomPaneHunkCount = hunkCount;
1226
746
  this.bottomPaneHunkBoundaries = hunkBoundaries;