diffstalker 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dependency-cruiser.cjs +2 -2
- package/.githooks/pre-push +2 -2
- package/.github/workflows/release.yml +3 -0
- package/CHANGELOG.md +6 -0
- package/dist/App.js +278 -758
- package/dist/KeyBindings.js +103 -91
- package/dist/ModalController.js +166 -0
- package/dist/MouseHandlers.js +37 -30
- package/dist/NavigationController.js +290 -0
- package/dist/StagingOperations.js +199 -0
- package/dist/config.js +39 -0
- package/dist/core/CompareManager.js +134 -0
- package/dist/core/ExplorerStateManager.js +7 -3
- package/dist/core/GitStateManager.js +28 -771
- package/dist/core/HistoryManager.js +72 -0
- package/dist/core/RemoteOperationManager.js +109 -0
- package/dist/core/WorkingTreeManager.js +412 -0
- package/dist/index.js +57 -57
- package/dist/state/FocusRing.js +40 -0
- package/dist/state/UIState.js +82 -48
- package/dist/ui/PaneRenderers.js +3 -6
- package/dist/ui/modals/BaseBranchPicker.js +4 -7
- package/dist/ui/modals/CommitActionConfirm.js +4 -4
- package/dist/ui/modals/DiscardConfirm.js +4 -7
- package/dist/ui/modals/FileFinder.js +3 -6
- package/dist/ui/modals/HotkeysModal.js +24 -21
- package/dist/ui/modals/Modal.js +1 -0
- package/dist/ui/modals/RepoPicker.js +109 -0
- package/dist/ui/modals/ThemePicker.js +4 -7
- package/dist/ui/widgets/CommitPanel.js +26 -94
- package/dist/ui/widgets/CompareListView.js +1 -11
- package/dist/ui/widgets/DiffView.js +2 -27
- package/dist/ui/widgets/ExplorerContent.js +1 -4
- package/dist/ui/widgets/ExplorerView.js +1 -11
- package/dist/ui/widgets/FileList.js +2 -8
- package/dist/ui/widgets/Footer.js +1 -0
- package/dist/utils/ansi.js +38 -0
- package/dist/utils/ansiTruncate.js +1 -5
- package/dist/utils/displayRows.js +72 -59
- package/dist/utils/fileCategories.js +7 -0
- package/dist/utils/fileResolution.js +23 -0
- package/dist/utils/languageDetection.js +3 -2
- package/dist/utils/logger.js +32 -0
- package/metrics/v0.2.4.json +236 -0
- package/metrics/v0.2.5.json +236 -0
- package/package.json +1 -1
- package/dist/ui/modals/BranchPicker.js +0 -157
- package/dist/ui/modals/SoftResetConfirm.js +0 -68
- package/dist/ui/modals/StashListModal.js +0 -98
- 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 {
|
|
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 {
|
|
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
|
-
//
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
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
|
-
|
|
447
|
-
|
|
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 —
|
|
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
|
-
|
|
459
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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 (
|
|
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.
|
|
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
|
-
|
|
1187
|
-
|
|
1188
|
-
this.
|
|
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
|
|
1223
|
-
|
|
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;
|