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