diffstalker 0.2.2 → 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 (47) hide show
  1. package/.dependency-cruiser.cjs +2 -2
  2. package/dist/App.js +299 -664
  3. package/dist/KeyBindings.js +125 -39
  4. package/dist/ModalController.js +166 -0
  5. package/dist/MouseHandlers.js +43 -25
  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 +27 -40
  11. package/dist/core/GitStateManager.js +28 -630
  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/git/status.js +95 -0
  16. package/dist/index.js +59 -54
  17. package/dist/state/FocusRing.js +40 -0
  18. package/dist/state/UIState.js +82 -48
  19. package/dist/types/remote.js +5 -0
  20. package/dist/ui/PaneRenderers.js +11 -4
  21. package/dist/ui/modals/BaseBranchPicker.js +4 -7
  22. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  23. package/dist/ui/modals/DiscardConfirm.js +4 -7
  24. package/dist/ui/modals/FileFinder.js +33 -27
  25. package/dist/ui/modals/HotkeysModal.js +32 -13
  26. package/dist/ui/modals/Modal.js +1 -0
  27. package/dist/ui/modals/RepoPicker.js +109 -0
  28. package/dist/ui/modals/ThemePicker.js +4 -7
  29. package/dist/ui/widgets/CommitPanel.js +52 -14
  30. package/dist/ui/widgets/CompareListView.js +1 -11
  31. package/dist/ui/widgets/DiffView.js +2 -27
  32. package/dist/ui/widgets/ExplorerContent.js +1 -4
  33. package/dist/ui/widgets/ExplorerView.js +1 -11
  34. package/dist/ui/widgets/FileList.js +2 -8
  35. package/dist/ui/widgets/Footer.js +1 -0
  36. package/dist/ui/widgets/Header.js +37 -3
  37. package/dist/utils/ansi.js +38 -0
  38. package/dist/utils/ansiTruncate.js +1 -5
  39. package/dist/utils/displayRows.js +72 -59
  40. package/dist/utils/fileCategories.js +7 -0
  41. package/dist/utils/fileResolution.js +23 -0
  42. package/dist/utils/languageDetection.js +3 -2
  43. package/dist/utils/logger.js +32 -0
  44. package/metrics/v0.2.3.json +243 -0
  45. package/metrics/v0.2.4.json +236 -0
  46. package/package.json +5 -2
  47. package/dist/utils/layoutCalculations.js +0 -100
package/dist/App.js CHANGED
@@ -3,25 +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
13
  import { CommitFlowState } from './state/CommitFlowState.js';
19
14
  import { UIState } from './state/UIState.js';
20
15
  import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
21
- import { saveConfig } from './config.js';
22
- import { getCategoryForIndex, getIndexForCategoryPosition, } from './utils/fileCategories.js';
16
+ import { saveConfig, addRecentRepo } from './config.js';
17
+ import { getIndexForCategoryPosition } from './utils/fileCategories.js';
23
18
  import { buildFlatFileList, getFlatFileAtIndex, getFlatFileIndexByPath, } from './utils/flatFileList.js';
24
- 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';
25
21
  /**
26
22
  * Main application controller.
27
23
  * Coordinates between GitStateManager, UIState, and blessed widgets.
@@ -35,33 +31,39 @@ export class App {
35
31
  explorerManager = null;
36
32
  config;
37
33
  commandServer;
34
+ navigation;
35
+ staging;
36
+ modals;
38
37
  // Current state
39
38
  repoPath;
40
39
  currentTheme;
40
+ recentRepos;
41
41
  // Commit flow state
42
42
  commitFlowState;
43
43
  commitTextarea = null;
44
- // Active modals
45
- activeModal = null;
44
+ // Auto-clear timer for remote operation status
45
+ remoteClearTimer = null;
46
46
  // Cached total rows and hunk info for scroll bounds (single source of truth from render)
47
47
  bottomPaneTotalRows = 0;
48
48
  bottomPaneHunkCount = 0;
49
49
  bottomPaneHunkBoundaries = [];
50
- // Selection anchor: remembers category + position before stage/unstage
51
- pendingSelectionAnchor = null;
50
+ // Auto-tab transition tracking
51
+ prevFileCount = 0;
52
52
  // Flat view mode state
53
53
  cachedFlatFiles = [];
54
- pendingFlatSelectionPath = null;
55
- pendingHunkIndex = null;
56
54
  combinedHunkMapping = [];
57
55
  constructor(options) {
58
56
  this.config = options.config;
59
57
  this.commandServer = options.commandServer ?? null;
60
58
  this.repoPath = options.initialPath ?? process.cwd();
61
59
  this.currentTheme = options.config.theme;
60
+ this.recentRepos = options.config.recentRepos ?? [];
62
61
  // Initialize UI state with config values
63
62
  this.uiState = new UIState({
64
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,
65
67
  });
66
68
  // Create blessed screen
67
69
  this.screen = blessed.screen({
@@ -89,9 +91,9 @@ export class App {
89
91
  });
90
92
  // Initialize commit flow state
91
93
  this.commitFlowState = new CommitFlowState({
92
- getHeadMessage: () => this.gitManager?.getHeadCommitMessage() ?? Promise.resolve(''),
94
+ getHeadMessage: () => this.gitManager?.history.getHeadCommitMessage() ?? Promise.resolve(''),
93
95
  onCommit: async (message, amend) => {
94
- await this.gitManager?.commit(message, amend);
96
+ await this.gitManager?.workingTree.commit(message, amend);
95
97
  },
96
98
  onSuccess: () => {
97
99
  this.uiState.setTab('diff');
@@ -124,6 +126,50 @@ export class App {
124
126
  this.commitFlowState.setMessage(value);
125
127
  });
126
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
+ }
127
173
  // Setup keyboard handlers
128
174
  this.setupKeyboardHandlers();
129
175
  // Setup mouse handlers
@@ -147,102 +193,95 @@ export class App {
147
193
  // Initial render
148
194
  this.render();
149
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
+ }
150
205
  setupKeyboardHandlers() {
151
206
  setupKeyBindings(this.screen, {
152
207
  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(),
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(),
163
218
  focusCommitInput: () => this.focusCommitInput(),
164
219
  unfocusCommitInput: () => this.unfocusCommitInput(),
165
- 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(),
166
225
  toggleMouseMode: () => this.toggleMouseMode(),
167
226
  toggleFollow: () => this.toggleFollow(),
168
- showDiscardConfirm: (file) => this.showDiscardConfirm(file),
227
+ openDiscardConfirm: (file) => this.modals.openDiscardConfirm(file),
169
228
  render: () => this.render(),
170
- toggleCurrentHunk: () => this.toggleCurrentHunk(),
171
- navigateNextHunk: () => this.navigateNextHunk(),
172
- navigatePrevHunk: () => this.navigatePrevHunk(),
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(),
173
234
  }, {
174
- hasActiveModal: () => this.activeModal !== null,
235
+ hasActiveModal: () => this.modals.hasActiveModal(),
236
+ getActiveModalType: () => this.modals.getActiveModalType(),
175
237
  getBottomTab: () => this.uiState.state.bottomTab,
176
238
  getCurrentPane: () => this.uiState.state.currentPane,
239
+ getFocusedZone: () => this.uiState.state.focusedZone,
177
240
  isCommitInputFocused: () => this.commitFlowState.state.inputFocused,
178
- getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
241
+ getStatusFiles: () => this.gitManager?.workingTree.state.status?.files ?? [],
179
242
  getSelectedIndex: () => this.uiState.state.selectedIndex,
180
243
  uiState: this.uiState,
181
244
  getExplorerManager: () => this.explorerManager,
182
245
  commitFlowState: this.commitFlowState,
183
246
  getGitManager: () => this.gitManager,
184
247
  layout: this.layout,
185
- getCachedFlatFiles: () => this.cachedFlatFiles,
248
+ resolveFileAtIndex: (index) => resolveFile(index, this.uiState.state.flatViewMode, this.cachedFlatFiles, this.gitManager?.workingTree.state.status?.files ?? []),
186
249
  });
187
250
  }
188
251
  setupMouseEventHandlers() {
189
252
  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(),
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(),
195
258
  toggleMouseMode: () => this.toggleMouseMode(),
196
259
  toggleFollow: () => this.toggleFollow(),
197
- selectHunkAtRow: (row) => this.selectHunkAtRow(row),
260
+ selectHunkAtRow: (row) => this.navigation.selectHunkAtRow(row),
261
+ focusCommitInput: () => this.focusCommitInput(),
262
+ openHotkeysModal: () => this.modals.openHotkeysModal(),
198
263
  render: () => this.render(),
199
264
  }, {
200
265
  uiState: this.uiState,
201
266
  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 ?? [],
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 ?? [],
206
271
  getBottomPaneTotalRows: () => this.bottomPaneTotalRows,
207
272
  getScreenWidth: () => this.screen.width || 80,
208
273
  getCachedFlatFiles: () => this.cachedFlatFiles,
209
274
  });
210
275
  }
211
- /**
212
- * Toggle staging for a flat file entry (stage if unstaged/partial, unstage if fully staged).
213
- */
214
- async toggleFlatEntry(entry) {
215
- this.pendingFlatSelectionPath = entry.path;
216
- if (entry.stagingState === 'staged') {
217
- if (entry.stagedEntry)
218
- await this.gitManager?.unstage(entry.stagedEntry);
219
- }
220
- else {
221
- if (entry.unstagedEntry)
222
- await this.gitManager?.stage(entry.unstagedEntry);
223
- }
224
- }
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);
230
- }
231
- else {
232
- const files = this.gitManager?.state.status?.files ?? [];
233
- const file = getFileAtIndex(files, index);
234
- if (file) {
235
- this.pendingSelectionAnchor = getCategoryForIndex(files, this.uiState.state.selectedIndex);
236
- if (file.staged) {
237
- await this.gitManager?.unstage(file);
238
- }
239
- else {
240
- await this.gitManager?.stage(file);
241
- }
242
- }
243
- }
244
- }
245
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
+ });
246
285
  // Update footer when UI state changes
247
286
  this.uiState.on('change', () => {
248
287
  this.render();
@@ -254,10 +293,10 @@ export class App {
254
293
  this.uiState.setSelectedHunkIndex(0);
255
294
  }
256
295
  if (tab === 'history') {
257
- this.gitManager?.loadHistory();
296
+ this.loadHistory();
258
297
  }
259
298
  else if (tab === 'compare') {
260
- this.gitManager?.refreshCompareDiff(this.uiState.state.includeUncommitted);
299
+ this.gitManager?.compare.refreshCompareDiff(this.uiState.state.includeUncommitted);
261
300
  }
262
301
  else if (tab === 'explorer') {
263
302
  // Explorer is already loaded on init, but refresh if needed
@@ -266,60 +305,23 @@ export class App {
266
305
  }
267
306
  }
268
307
  });
269
- // Handle modal opening/closing
270
- this.uiState.on('modal-change', (modal) => {
271
- // Close any existing modal
272
- if (this.activeModal) {
273
- this.activeModal = null;
274
- }
275
- // Open new modal if requested
276
- if (modal === 'theme') {
277
- this.activeModal = new ThemePicker(this.screen, this.currentTheme, (theme) => {
278
- this.currentTheme = theme;
279
- saveConfig({ theme });
280
- this.activeModal = null;
281
- this.uiState.closeModal();
282
- this.render();
283
- }, () => {
284
- this.activeModal = null;
285
- this.uiState.closeModal();
286
- });
287
- this.activeModal.focus();
288
- }
289
- else if (modal === 'hotkeys') {
290
- this.activeModal = new HotkeysModal(this.screen, () => {
291
- this.activeModal = null;
292
- this.uiState.closeModal();
293
- });
294
- this.activeModal.focus();
295
- }
296
- else if (modal === 'baseBranch') {
297
- // Load candidate branches and show picker
298
- this.gitManager?.getCandidateBaseBranches().then((branches) => {
299
- const currentBranch = this.gitManager?.compareState.compareBaseBranch ?? null;
300
- this.activeModal = new BaseBranchPicker(this.screen, branches, currentBranch, (branch) => {
301
- this.activeModal = null;
302
- this.uiState.closeModal();
303
- // Set base branch and refresh compare view
304
- const includeUncommitted = this.uiState.state.includeUncommitted;
305
- this.gitManager?.setCompareBaseBranch(branch, includeUncommitted);
306
- }, () => {
307
- this.activeModal = null;
308
- this.uiState.closeModal();
309
- });
310
- this.activeModal.focus();
311
- });
312
- }
313
- });
314
- // Save split ratio to config when it changes
308
+ // Persist UI state to config when toggles or split ratio change
315
309
  let saveTimer = null;
316
310
  this.uiState.on('change', (state) => {
317
311
  if (saveTimer)
318
312
  clearTimeout(saveTimer);
319
313
  saveTimer = setTimeout(() => {
320
- if (state.splitRatio !== this.config.splitRatio) {
321
- saveConfig({ splitRatio: state.splitRatio });
322
- }
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);
323
325
  }, 500);
324
326
  });
325
327
  }
@@ -332,92 +334,160 @@ export class App {
332
334
  this.render();
333
335
  }
334
336
  handleFollowFileNavigate(rawContent) {
335
- 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();
336
359
  this.render();
337
360
  }
338
361
  initGitManager(oldRepoPath) {
339
- // Clean up existing manager
362
+ // Clean up existing manager's event listeners
340
363
  if (this.gitManager) {
341
- this.gitManager.removeAllListeners();
364
+ this.gitManager.workingTree.removeAllListeners();
365
+ this.gitManager.history.removeAllListeners();
366
+ this.gitManager.compare.removeAllListeners();
367
+ this.gitManager.remote.removeAllListeners();
342
368
  // Use oldRepoPath if provided (when switching repos), otherwise use current path
343
369
  removeManagerForRepo(oldRepoPath ?? this.repoPath);
344
370
  }
345
371
  // Get or create manager for this repo
346
372
  this.gitManager = getManagerForRepo(this.repoPath);
347
- // Listen to state changes
348
- this.gitManager.on('state-change', () => {
373
+ // Listen to working tree state changes
374
+ this.gitManager.workingTree.on('state-change', () => {
349
375
  // Skip reconciliation while loading — the pending anchor must wait
350
376
  // for the new status to arrive before being consumed
351
- if (!this.gitManager?.state.isLoading) {
377
+ if (!this.gitManager?.workingTree.state.isLoading) {
352
378
  this.reconcileSelectionAfterStateChange();
379
+ this.applyAutoTab();
353
380
  }
354
381
  this.updateExplorerGitStatus();
355
382
  this.render();
356
383
  });
357
- this.gitManager.on('history-state-change', (historyState) => {
384
+ // Listen to history state changes
385
+ this.gitManager.history.on('history-state-change', (historyState) => {
358
386
  // Auto-select first commit when history loads
359
387
  if (historyState.commits.length > 0 && !historyState.selectedCommit) {
360
388
  const state = this.uiState.state;
361
389
  if (state.bottomTab === 'history') {
362
- this.selectHistoryCommitByIndex(state.historySelectedIndex);
390
+ this.navigation.selectHistoryCommitByIndex(state.historySelectedIndex);
363
391
  }
364
392
  }
365
393
  this.render();
366
394
  });
367
- this.gitManager.on('compare-state-change', () => {
395
+ // Listen to compare state changes
396
+ this.gitManager.compare.on('compare-state-change', () => {
397
+ this.render();
398
+ });
399
+ this.gitManager.compare.on('compare-selection-change', () => {
368
400
  this.render();
369
401
  });
370
- this.gitManager.on('compare-selection-change', () => {
402
+ // Listen to remote operation state changes
403
+ this.gitManager.remote.on('remote-state-change', (remoteState) => {
404
+ // Auto-clear success after 3s, error after 5s
405
+ if (this.remoteClearTimer)
406
+ clearTimeout(this.remoteClearTimer);
407
+ if (remoteState.lastResult && !remoteState.inProgress) {
408
+ this.remoteClearTimer = setTimeout(() => {
409
+ this.gitManager?.remote.clearRemoteState();
410
+ }, 3000);
411
+ }
412
+ else if (remoteState.error) {
413
+ this.remoteClearTimer = setTimeout(() => {
414
+ this.gitManager?.remote.clearRemoteState();
415
+ }, 5000);
416
+ }
371
417
  this.render();
372
418
  });
373
419
  // Start watching and do initial refresh
374
- this.gitManager.startWatching();
375
- this.gitManager.refresh();
420
+ this.gitManager.workingTree.startWatching();
421
+ this.gitManager.workingTree.refresh();
376
422
  // Initialize explorer manager
377
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
+ });
378
434
  }
379
435
  /**
380
436
  * After git state changes, reconcile the selected file index.
381
437
  * Handles both flat mode (path-based anchoring) and categorized mode (category-based anchoring).
382
438
  */
383
439
  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);
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);
390
445
  if (newIndex >= 0) {
391
446
  this.uiState.setSelectedIndex(newIndex);
392
- this.selectFileByIndex(newIndex);
447
+ this.navigation.selectFileByIndex(newIndex);
393
448
  }
394
449
  else if (flatFiles.length > 0) {
395
450
  const clamped = Math.min(this.uiState.state.selectedIndex, flatFiles.length - 1);
396
451
  this.uiState.setSelectedIndex(clamped);
397
- this.selectFileByIndex(clamped);
452
+ this.navigation.selectFileByIndex(clamped);
398
453
  }
399
454
  return;
400
455
  }
401
- if (this.pendingSelectionAnchor) {
402
- const anchor = this.pendingSelectionAnchor;
403
- this.pendingSelectionAnchor = null;
456
+ const anchor = this.staging.consumePendingSelectionAnchor();
457
+ if (anchor) {
404
458
  const newIndex = getIndexForCategoryPosition(files, anchor.category, anchor.categoryIndex);
405
459
  this.uiState.setSelectedIndex(newIndex);
406
- this.selectFileByIndex(newIndex);
460
+ this.navigation.selectFileByIndex(newIndex);
407
461
  return;
408
462
  }
409
- // 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;
410
465
  if (this.uiState.state.flatViewMode) {
411
- const flatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
466
+ const flatFiles = buildFlatFileList(files, this.gitManager?.workingTree.state.hunkCounts ?? null);
412
467
  const maxIndex = flatFiles.length - 1;
413
- if (maxIndex >= 0 && this.uiState.state.selectedIndex > maxIndex) {
414
- 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);
415
478
  }
416
479
  }
417
480
  else if (files.length > 0) {
418
481
  const maxIndex = files.length - 1;
419
- if (this.uiState.state.selectedIndex > maxIndex) {
420
- 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);
421
491
  }
422
492
  }
423
493
  }
@@ -439,6 +509,8 @@ export class App {
439
509
  });
440
510
  // Load root directory
441
511
  this.explorerManager.loadDirectory('');
512
+ // Pre-load file paths for file finder (runs in background)
513
+ this.explorerManager.loadFilePaths();
442
514
  // Update git status after tree is loaded
443
515
  this.updateExplorerGitStatus();
444
516
  }
@@ -448,7 +520,7 @@ export class App {
448
520
  updateExplorerGitStatus() {
449
521
  if (!this.explorerManager || !this.gitManager)
450
522
  return;
451
- const files = this.gitManager.state.status?.files ?? [];
523
+ const files = this.gitManager.workingTree.state.status?.files ?? [];
452
524
  const statusMap = {
453
525
  files: new Map(),
454
526
  directories: new Set(),
@@ -472,8 +544,8 @@ export class App {
472
544
  * Called when switching to a new repo via file watcher.
473
545
  */
474
546
  resetRepoSpecificState() {
475
- // Reset compare selection (App-level state)
476
- this.compareSelection = null;
547
+ // Reset compare selection (owned by NavigationController)
548
+ this.navigation.compareSelection = null;
477
549
  // Reset UI state scroll offsets and selections
478
550
  this.uiState.resetForNewRepo();
479
551
  }
@@ -484,26 +556,26 @@ export class App {
484
556
  loadCurrentTabData() {
485
557
  const tab = this.uiState.state.bottomTab;
486
558
  if (tab === 'history') {
487
- this.gitManager?.loadHistory();
559
+ this.loadHistory();
488
560
  }
489
561
  else if (tab === 'compare') {
490
- this.gitManager?.refreshCompareDiff(this.uiState.state.includeUncommitted);
562
+ this.gitManager?.compare.refreshCompareDiff(this.uiState.state.includeUncommitted);
491
563
  }
492
- // Diff tab data is loaded by gitManager.refresh() in initGitManager
564
+ // Diff tab data is loaded by gitManager.workingTree.refresh() in initGitManager
493
565
  // Explorer data is loaded by initExplorerManager()
494
566
  }
495
567
  setupCommandHandler() {
496
568
  if (!this.commandServer)
497
569
  return;
498
570
  const handler = {
499
- navigateUp: () => this.navigateUp(),
500
- navigateDown: () => this.navigateDown(),
571
+ navigateUp: () => this.navigation.navigateUp(),
572
+ navigateDown: () => this.navigation.navigateDown(),
501
573
  switchTab: (tab) => this.uiState.setTab(tab),
502
574
  togglePane: () => this.uiState.togglePane(),
503
- stage: async () => this.stageSelected(),
504
- unstage: async () => this.unstageSelected(),
505
- stageAll: async () => this.stageAll(),
506
- 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(),
507
579
  commit: async (message) => this.commit(message),
508
580
  refresh: async () => this.refresh(),
509
581
  getState: () => this.getAppState(),
@@ -514,8 +586,8 @@ export class App {
514
586
  }
515
587
  getAppState() {
516
588
  const state = this.uiState.state;
517
- const gitState = this.gitManager?.state;
518
- const historyState = this.gitManager?.historyState;
589
+ const gitState = this.gitManager?.workingTree.state;
590
+ const historyState = this.gitManager?.history.historyState;
519
591
  const files = gitState?.status?.files ?? [];
520
592
  const commits = historyState?.commits ?? [];
521
593
  return {
@@ -542,486 +614,11 @@ export class App {
542
614
  autoTabEnabled: state.autoTabEnabled,
543
615
  };
544
616
  }
545
- // Navigation methods
546
- /**
547
- * Scroll the content pane (diff or explorer file content) by delta lines.
548
- */
549
- scrollActiveDiffPane(delta) {
550
- const state = this.uiState.state;
551
- if (state.bottomTab === 'explorer') {
552
- const newOffset = Math.max(0, state.explorerFileScrollOffset + delta);
553
- this.uiState.setExplorerFileScrollOffset(newOffset);
554
- }
555
- else {
556
- const newOffset = Math.max(0, state.diffScrollOffset + delta);
557
- this.uiState.setDiffScrollOffset(newOffset);
558
- }
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)
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);
585
- }
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));
590
- }
591
- }
592
- }
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
602
- this.navigateHistoryDown();
603
- }
604
- else if (tab === 'compare') {
605
- if (direction === -1)
606
- this.navigateCompareUp();
607
- else
608
- this.navigateCompareDown();
609
- }
610
- else if (tab === 'explorer') {
611
- if (direction === -1)
612
- this.navigateExplorerUp();
613
- else
614
- this.navigateExplorerDown();
615
- }
616
- else {
617
- this.navigateFileList(direction);
618
- }
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);
638
- }
639
- }
640
- navigateHistoryUp() {
641
- const state = this.uiState.state;
642
- const newIndex = Math.max(0, state.historySelectedIndex - 1);
643
- if (newIndex !== state.historySelectedIndex) {
644
- this.uiState.setHistorySelectedIndex(newIndex);
645
- // Keep selection visible
646
- if (newIndex < state.historyScrollOffset) {
647
- this.uiState.setHistoryScrollOffset(newIndex);
648
- }
649
- this.selectHistoryCommitByIndex(newIndex);
650
- }
651
- }
652
- navigateHistoryDown() {
653
- const state = this.uiState.state;
654
- const commits = this.gitManager?.historyState.commits ?? [];
655
- const newIndex = Math.min(commits.length - 1, state.historySelectedIndex + 1);
656
- if (newIndex !== state.historySelectedIndex) {
657
- this.uiState.setHistorySelectedIndex(newIndex);
658
- // Keep selection visible
659
- const visibleEnd = state.historyScrollOffset + this.layout.dimensions.topPaneHeight - 1;
660
- if (newIndex >= visibleEnd) {
661
- this.uiState.setHistoryScrollOffset(state.historyScrollOffset + 1);
662
- }
663
- this.selectHistoryCommitByIndex(newIndex);
664
- }
665
- }
666
- selectHistoryCommitByIndex(index) {
667
- const commits = this.gitManager?.historyState.commits ?? [];
668
- const commit = getCommitAtIndex(commits, index);
669
- if (commit) {
670
- this.uiState.setDiffScrollOffset(0);
671
- this.gitManager?.selectHistoryCommit(commit);
672
- }
673
- }
674
- // Compare navigation
675
- compareSelection = null;
676
- navigateCompareUp() {
677
- const compareState = this.gitManager?.compareState;
678
- const commits = compareState?.compareDiff?.commits ?? [];
679
- const files = compareState?.compareDiff?.files ?? [];
680
- if (commits.length === 0 && files.length === 0)
681
- return;
682
- const next = getNextCompareSelection(this.compareSelection, commits, files, 'up');
683
- if (next &&
684
- (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
685
- this.selectCompareItem(next);
686
- // Keep selection visible - scroll up if needed
687
- const state = this.uiState.state;
688
- const row = getRowFromCompareSelection(next, commits, files);
689
- if (row < state.compareScrollOffset) {
690
- this.uiState.setCompareScrollOffset(row);
691
- }
692
- }
693
- }
694
- navigateCompareDown() {
695
- const compareState = this.gitManager?.compareState;
696
- const commits = compareState?.compareDiff?.commits ?? [];
697
- const files = compareState?.compareDiff?.files ?? [];
698
- if (commits.length === 0 && files.length === 0)
699
- return;
700
- // Auto-select first item if nothing selected
701
- if (!this.compareSelection) {
702
- // Select first commit if available, otherwise first file
703
- if (commits.length > 0) {
704
- this.selectCompareItem({ type: 'commit', index: 0 });
705
- }
706
- else if (files.length > 0) {
707
- this.selectCompareItem({ type: 'file', index: 0 });
708
- }
709
- return;
710
- }
711
- const next = getNextCompareSelection(this.compareSelection, commits, files, 'down');
712
- if (next &&
713
- (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
714
- this.selectCompareItem(next);
715
- // Keep selection visible - scroll down if needed
716
- const state = this.uiState.state;
717
- const row = getRowFromCompareSelection(next, commits, files);
718
- const visibleEnd = state.compareScrollOffset + this.layout.dimensions.topPaneHeight - 1;
719
- if (row >= visibleEnd) {
720
- this.uiState.setCompareScrollOffset(state.compareScrollOffset + (row - visibleEnd + 1));
721
- }
722
- }
723
- }
724
- selectCompareItem(selection) {
725
- this.compareSelection = selection;
726
- this.uiState.setDiffScrollOffset(0);
727
- if (selection.type === 'commit') {
728
- this.gitManager?.selectCompareCommit(selection.index);
729
- }
730
- else {
731
- this.gitManager?.selectCompareFile(selection.index);
732
- }
733
- }
734
- // Explorer navigation
735
- navigateExplorerUp() {
736
- const state = this.uiState.state;
737
- const rows = this.explorerManager?.state.displayRows ?? [];
738
- if (rows.length === 0)
739
- return;
740
- const newScrollOffset = this.explorerManager?.navigateUp(state.explorerScrollOffset);
741
- if (newScrollOffset !== null && newScrollOffset !== undefined) {
742
- this.uiState.setExplorerScrollOffset(newScrollOffset);
743
- }
744
- this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
745
- }
746
- navigateExplorerDown() {
747
- const state = this.uiState.state;
748
- const rows = this.explorerManager?.state.displayRows ?? [];
749
- if (rows.length === 0)
750
- return;
751
- const visibleHeight = this.layout.dimensions.topPaneHeight;
752
- const newScrollOffset = this.explorerManager?.navigateDown(state.explorerScrollOffset, visibleHeight);
753
- if (newScrollOffset !== null && newScrollOffset !== undefined) {
754
- this.uiState.setExplorerScrollOffset(newScrollOffset);
755
- }
756
- this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
757
- }
758
- async enterExplorerDirectory() {
759
- await this.explorerManager?.enterDirectory();
760
- // Reset file content scroll when expanding/collapsing
761
- this.uiState.setExplorerFileScrollOffset(0);
762
- // Sync selected index from explorer manager (it maintains selection by path)
763
- this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
764
- }
765
- async goExplorerUp() {
766
- await this.explorerManager?.goUp();
767
- // Reset file content scroll when collapsing
768
- this.uiState.setExplorerFileScrollOffset(0);
769
- // Sync selected index from explorer manager
770
- this.uiState.setExplorerSelectedIndex(this.explorerManager?.state.selectedIndex ?? 0);
771
- }
772
- selectFileByIndex(index) {
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
- }
793
- }
794
- }
795
- /**
796
- * Navigate to a file given its absolute path.
797
- * Extracts the relative path and finds the file in the current file list.
798
- */
799
- navigateToFile(absolutePath) {
800
- if (!absolutePath || !this.repoPath)
801
- return;
802
- // Check if the path is within the current repo
803
- const repoPrefix = this.repoPath.endsWith('/') ? this.repoPath : this.repoPath + '/';
804
- if (!absolutePath.startsWith(repoPrefix))
805
- return;
806
- // Extract relative path
807
- const relativePath = absolutePath.slice(repoPrefix.length);
808
- if (!relativePath)
809
- return;
810
- // Find the file in the list
811
- const files = this.gitManager?.state.status?.files ?? [];
812
- const fileIndex = files.findIndex((f) => f.path === relativePath);
813
- if (fileIndex >= 0) {
814
- this.uiState.setSelectedIndex(fileIndex);
815
- this.selectFileByIndex(fileIndex);
816
- }
817
- }
818
- // Git operations
819
- async stageSelected() {
820
- const files = this.gitManager?.state.status?.files ?? [];
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
- }
839
- }
840
- }
841
- async unstageSelected() {
842
- const files = this.gitManager?.state.status?.files ?? [];
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
- }
853
- }
854
- else {
855
- const selectedFile = getFileAtIndex(files, index);
856
- if (selectedFile?.staged) {
857
- this.pendingSelectionAnchor = getCategoryForIndex(files, index);
858
- await this.gitManager?.unstage(selectedFile);
859
- }
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
- }
880
- }
881
- }
882
- }
883
- async stageAll() {
884
- await this.gitManager?.stageAll();
885
- }
886
- async unstageAll() {
887
- await this.gitManager?.unstageAll();
888
- }
889
- showDiscardConfirm(file) {
890
- this.activeModal = new DiscardConfirm(this.screen, file.path, async () => {
891
- this.activeModal = null;
892
- await this.gitManager?.discard(file);
893
- }, () => {
894
- this.activeModal = null;
895
- });
896
- this.activeModal.focus();
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
- }
1020
617
  async commit(message) {
1021
- await this.gitManager?.commit(message);
618
+ await this.gitManager?.workingTree.commit(message);
1022
619
  }
1023
620
  async refresh() {
1024
- await this.gitManager?.refresh();
621
+ await this.gitManager?.workingTree.refresh();
1025
622
  }
1026
623
  toggleMouseMode() {
1027
624
  const willEnable = !this.uiState.state.mouseEnabled;
@@ -1036,6 +633,29 @@ export class App {
1036
633
  program.disableMouse();
1037
634
  }
1038
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
+ }
1039
659
  toggleFollow() {
1040
660
  if (!this.followMode) {
1041
661
  this.followMode = new FollowMode(this.config.targetFile, () => this.repoPath, {
@@ -1071,44 +691,56 @@ export class App {
1071
691
  this.updateTopPane();
1072
692
  this.updateBottomPane();
1073
693
  // 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;
694
+ const pendingHunk = this.staging.consumePendingHunkIndex();
695
+ if (pendingHunk !== null && this.bottomPaneHunkCount > 0) {
696
+ const restored = Math.min(pendingHunk, this.bottomPaneHunkCount - 1);
1077
697
  this.uiState.setSelectedHunkIndex(restored);
1078
698
  this.updateBottomPane(); // Re-render with correct hunk selection
1079
699
  }
700
+ this.updateSeparators();
1080
701
  this.updateFooter();
1081
702
  this.screen.render();
1082
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
+ }
1083
713
  updateHeader() {
1084
- const gitState = this.gitManager?.state;
714
+ const gitState = this.gitManager?.workingTree.state;
1085
715
  const width = this.screen.width || 80;
1086
- const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width);
716
+ const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width, this.gitManager?.remote.remoteState ?? null);
1087
717
  this.layout.headerBox.setContent(content);
1088
718
  }
1089
719
  updateTopPane() {
1090
720
  const state = this.uiState.state;
1091
721
  const width = this.screen.width || 80;
1092
- const files = this.gitManager?.state.status?.files ?? [];
722
+ const files = this.gitManager?.workingTree.state.status?.files ?? [];
1093
723
  // Build and cache flat file list when in flat mode
1094
724
  if (state.flatViewMode) {
1095
- this.cachedFlatFiles = buildFlatFileList(files, this.gitManager?.state.hunkCounts ?? null);
725
+ this.cachedFlatFiles = buildFlatFileList(files, this.gitManager?.workingTree.state.hunkCounts ?? null);
1096
726
  }
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);
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);
1098
728
  this.layout.topPane.setContent(content);
1099
729
  }
1100
730
  updateBottomPane() {
1101
731
  const state = this.uiState.state;
1102
732
  const width = this.screen.width || 80;
1103
- const files = this.gitManager?.state.status?.files ?? [];
733
+ const files = this.gitManager?.workingTree.state.status?.files ?? [];
1104
734
  const stagedCount = files.filter((f) => f.staged).length;
1105
735
  // Update staged count for commit validation
1106
736
  this.commitFlowState.setStagedCount(stagedCount);
1107
737
  // Pass selectedHunkIndex and staged status only when diff pane is focused on diff tab
1108
738
  const diffPaneFocused = state.bottomTab === 'diff' && state.currentPane === 'diff';
1109
739
  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);
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);
1112
744
  this.bottomPaneTotalRows = totalRows;
1113
745
  this.bottomPaneHunkCount = hunkCount;
1114
746
  this.bottomPaneHunkBoundaries = hunkBoundaries;
@@ -1149,6 +781,9 @@ export class App {
1149
781
  if (this.commandServer) {
1150
782
  this.commandServer.stop();
1151
783
  }
784
+ if (this.remoteClearTimer) {
785
+ clearTimeout(this.remoteClearTimer);
786
+ }
1152
787
  // Destroy screen (this will clean up terminal)
1153
788
  this.screen.destroy();
1154
789
  }