diffstalker 0.2.2 → 0.2.3
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/dist/App.js +118 -3
- package/dist/KeyBindings.js +75 -1
- package/dist/MouseHandlers.js +13 -2
- package/dist/core/ExplorerStateManager.js +21 -38
- package/dist/core/GitStateManager.js +142 -1
- package/dist/git/status.js +95 -0
- package/dist/index.js +55 -50
- package/dist/types/remote.js +5 -0
- package/dist/ui/PaneRenderers.js +14 -4
- package/dist/ui/modals/BranchPicker.js +157 -0
- package/dist/ui/modals/CommitActionConfirm.js +66 -0
- package/dist/ui/modals/FileFinder.js +30 -21
- package/dist/ui/modals/HotkeysModal.js +23 -0
- package/dist/ui/modals/SoftResetConfirm.js +68 -0
- package/dist/ui/modals/StashListModal.js +98 -0
- package/dist/ui/widgets/CommitPanel.js +113 -7
- package/dist/ui/widgets/Header.js +37 -3
- package/metrics/v0.2.3.json +243 -0
- package/package.json +5 -2
package/dist/App.js
CHANGED
|
@@ -15,6 +15,10 @@ 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
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';
|
|
18
22
|
import { CommitFlowState } from './state/CommitFlowState.js';
|
|
19
23
|
import { UIState } from './state/UIState.js';
|
|
20
24
|
import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
|
|
@@ -43,6 +47,8 @@ export class App {
|
|
|
43
47
|
commitTextarea = null;
|
|
44
48
|
// Active modals
|
|
45
49
|
activeModal = null;
|
|
50
|
+
// Auto-clear timer for remote operation status
|
|
51
|
+
remoteClearTimer = null;
|
|
46
52
|
// Cached total rows and hunk info for scroll bounds (single source of truth from render)
|
|
47
53
|
bottomPaneTotalRows = 0;
|
|
48
54
|
bottomPaneHunkCount = 0;
|
|
@@ -170,11 +176,22 @@ export class App {
|
|
|
170
176
|
toggleCurrentHunk: () => this.toggleCurrentHunk(),
|
|
171
177
|
navigateNextHunk: () => this.navigateNextHunk(),
|
|
172
178
|
navigatePrevHunk: () => this.navigatePrevHunk(),
|
|
179
|
+
push: () => this.gitManager?.push(),
|
|
180
|
+
fetchRemote: () => this.gitManager?.fetchRemote(),
|
|
181
|
+
pullRebase: () => this.gitManager?.pullRebase(),
|
|
182
|
+
stash: () => this.gitManager?.stash(),
|
|
183
|
+
stashPop: () => this.gitManager?.stashPop(),
|
|
184
|
+
openStashListModal: () => this.openStashListModal(),
|
|
185
|
+
openBranchPicker: () => this.openBranchPicker(),
|
|
186
|
+
showSoftResetConfirm: () => this.showSoftResetConfirm(),
|
|
187
|
+
cherryPickSelected: () => this.cherryPickSelected(),
|
|
188
|
+
revertSelected: () => this.revertSelected(),
|
|
173
189
|
}, {
|
|
174
190
|
hasActiveModal: () => this.activeModal !== null,
|
|
175
191
|
getBottomTab: () => this.uiState.state.bottomTab,
|
|
176
192
|
getCurrentPane: () => this.uiState.state.currentPane,
|
|
177
193
|
isCommitInputFocused: () => this.commitFlowState.state.inputFocused,
|
|
194
|
+
isRemoteInProgress: () => this.gitManager?.remoteState.inProgress ?? false,
|
|
178
195
|
getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
|
|
179
196
|
getSelectedIndex: () => this.uiState.state.selectedIndex,
|
|
180
197
|
uiState: this.uiState,
|
|
@@ -195,6 +212,11 @@ export class App {
|
|
|
195
212
|
toggleMouseMode: () => this.toggleMouseMode(),
|
|
196
213
|
toggleFollow: () => this.toggleFollow(),
|
|
197
214
|
selectHunkAtRow: (row) => this.selectHunkAtRow(row),
|
|
215
|
+
focusCommitInput: () => this.focusCommitInput(),
|
|
216
|
+
toggleAmend: () => {
|
|
217
|
+
this.commitFlowState.toggleAmend();
|
|
218
|
+
this.render();
|
|
219
|
+
},
|
|
198
220
|
render: () => this.render(),
|
|
199
221
|
}, {
|
|
200
222
|
uiState: this.uiState,
|
|
@@ -265,6 +287,13 @@ export class App {
|
|
|
265
287
|
this.explorerManager?.loadDirectory('');
|
|
266
288
|
}
|
|
267
289
|
}
|
|
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
|
+
}
|
|
268
297
|
});
|
|
269
298
|
// Handle modal opening/closing
|
|
270
299
|
this.uiState.on('modal-change', (modal) => {
|
|
@@ -370,6 +399,22 @@ export class App {
|
|
|
370
399
|
this.gitManager.on('compare-selection-change', () => {
|
|
371
400
|
this.render();
|
|
372
401
|
});
|
|
402
|
+
this.gitManager.on('remote-state-change', (remoteState) => {
|
|
403
|
+
// Auto-clear success after 3s, error after 5s
|
|
404
|
+
if (this.remoteClearTimer)
|
|
405
|
+
clearTimeout(this.remoteClearTimer);
|
|
406
|
+
if (remoteState.lastResult && !remoteState.inProgress) {
|
|
407
|
+
this.remoteClearTimer = setTimeout(() => {
|
|
408
|
+
this.gitManager?.clearRemoteState();
|
|
409
|
+
}, 3000);
|
|
410
|
+
}
|
|
411
|
+
else if (remoteState.error) {
|
|
412
|
+
this.remoteClearTimer = setTimeout(() => {
|
|
413
|
+
this.gitManager?.clearRemoteState();
|
|
414
|
+
}, 5000);
|
|
415
|
+
}
|
|
416
|
+
this.render();
|
|
417
|
+
});
|
|
373
418
|
// Start watching and do initial refresh
|
|
374
419
|
this.gitManager.startWatching();
|
|
375
420
|
this.gitManager.refresh();
|
|
@@ -439,6 +484,8 @@ export class App {
|
|
|
439
484
|
});
|
|
440
485
|
// Load root directory
|
|
441
486
|
this.explorerManager.loadDirectory('');
|
|
487
|
+
// Pre-load file paths for file finder (runs in background)
|
|
488
|
+
this.explorerManager.loadFilePaths();
|
|
442
489
|
// Update git status after tree is loaded
|
|
443
490
|
this.updateExplorerGitStatus();
|
|
444
491
|
}
|
|
@@ -985,7 +1032,12 @@ export class App {
|
|
|
985
1032
|
}
|
|
986
1033
|
}
|
|
987
1034
|
async openFileFinder() {
|
|
988
|
-
|
|
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
|
+
}
|
|
989
1041
|
if (allPaths.length === 0)
|
|
990
1042
|
return;
|
|
991
1043
|
this.activeModal = new FileFinder(this.screen, allPaths, async (selectedPath) => {
|
|
@@ -1017,6 +1069,66 @@ export class App {
|
|
|
1017
1069
|
});
|
|
1018
1070
|
this.activeModal.focus();
|
|
1019
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
|
+
}
|
|
1020
1132
|
async commit(message) {
|
|
1021
1133
|
await this.gitManager?.commit(message);
|
|
1022
1134
|
}
|
|
@@ -1083,7 +1195,7 @@ export class App {
|
|
|
1083
1195
|
updateHeader() {
|
|
1084
1196
|
const gitState = this.gitManager?.state;
|
|
1085
1197
|
const width = this.screen.width || 80;
|
|
1086
|
-
const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width);
|
|
1198
|
+
const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width, this.gitManager?.remoteState ?? null);
|
|
1087
1199
|
this.layout.headerBox.setContent(content);
|
|
1088
1200
|
}
|
|
1089
1201
|
updateTopPane() {
|
|
@@ -1108,7 +1220,7 @@ export class App {
|
|
|
1108
1220
|
const diffPaneFocused = state.bottomTab === 'diff' && state.currentPane === 'diff';
|
|
1109
1221
|
const hunkIndex = diffPaneFocused ? state.selectedHunkIndex : undefined;
|
|
1110
1222
|
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);
|
|
1223
|
+
const { content, totalRows, hunkCount, hunkBoundaries, hunkMapping } = renderBottomPane(state, this.gitManager?.state.diff ?? null, this.gitManager?.historyState, this.gitManager?.compareSelectionState, this.explorerManager?.state?.selectedFile ?? null, this.commitFlowState.state, stagedCount, this.currentTheme, width, this.layout.dimensions.bottomPaneHeight, hunkIndex, isFileStaged, state.flatViewMode ? this.gitManager?.state.combinedFileDiffs : undefined, this.gitManager?.state.status?.branch ?? null, this.gitManager?.remoteState ?? null, this.gitManager?.state.stashList, this.gitManager?.historyState.commits[0] ?? null);
|
|
1112
1224
|
this.bottomPaneTotalRows = totalRows;
|
|
1113
1225
|
this.bottomPaneHunkCount = hunkCount;
|
|
1114
1226
|
this.bottomPaneHunkBoundaries = hunkBoundaries;
|
|
@@ -1149,6 +1261,9 @@ export class App {
|
|
|
1149
1261
|
if (this.commandServer) {
|
|
1150
1262
|
this.commandServer.stop();
|
|
1151
1263
|
}
|
|
1264
|
+
if (this.remoteClearTimer) {
|
|
1265
|
+
clearTimeout(this.remoteClearTimer);
|
|
1266
|
+
}
|
|
1152
1267
|
// Destroy screen (this will clean up terminal)
|
|
1153
1268
|
this.screen.destroy();
|
|
1154
1269
|
}
|
package/dist/KeyBindings.js
CHANGED
|
@@ -130,6 +130,29 @@ export function setupKeyBindings(screen, actions, ctx) {
|
|
|
130
130
|
ctx.uiState.toggleAutoTab();
|
|
131
131
|
}
|
|
132
132
|
});
|
|
133
|
+
// Ctrl+a: toggle amend on commit tab (works even when input is focused)
|
|
134
|
+
screen.key(['C-a'], () => {
|
|
135
|
+
if (ctx.getBottomTab() === 'commit') {
|
|
136
|
+
ctx.commitFlowState.toggleAmend();
|
|
137
|
+
actions.render();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
// Remote operations (global, any tab)
|
|
141
|
+
screen.key(['S-p'], () => {
|
|
142
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
|
|
143
|
+
return;
|
|
144
|
+
actions.push();
|
|
145
|
+
});
|
|
146
|
+
screen.key(['S-f'], () => {
|
|
147
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
|
|
148
|
+
return;
|
|
149
|
+
actions.fetchRemote();
|
|
150
|
+
});
|
|
151
|
+
screen.key(['S-r'], () => {
|
|
152
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
|
|
153
|
+
return;
|
|
154
|
+
actions.pullRebase();
|
|
155
|
+
});
|
|
133
156
|
screen.key(['escape'], () => {
|
|
134
157
|
if (ctx.getBottomTab() === 'commit') {
|
|
135
158
|
if (ctx.isCommitInputFocused()) {
|
|
@@ -163,11 +186,16 @@ export function setupKeyBindings(screen, actions, ctx) {
|
|
|
163
186
|
screen.key(['?'], () => ctx.uiState.toggleModal('hotkeys'));
|
|
164
187
|
// Follow toggle
|
|
165
188
|
screen.key(['f'], () => actions.toggleFollow());
|
|
166
|
-
// Compare view: base branch picker
|
|
189
|
+
// Compare view: base branch picker / Commit tab: branch picker
|
|
167
190
|
screen.key(['b'], () => {
|
|
191
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
|
|
192
|
+
return;
|
|
168
193
|
if (ctx.getBottomTab() === 'compare') {
|
|
169
194
|
ctx.uiState.openModal('baseBranch');
|
|
170
195
|
}
|
|
196
|
+
else if (ctx.getBottomTab() === 'commit') {
|
|
197
|
+
actions.openBranchPicker();
|
|
198
|
+
}
|
|
171
199
|
});
|
|
172
200
|
// u: toggle uncommitted in compare view
|
|
173
201
|
screen.key(['u'], () => {
|
|
@@ -225,4 +253,50 @@ export function setupKeyBindings(screen, actions, ctx) {
|
|
|
225
253
|
actions.navigatePrevHunk();
|
|
226
254
|
}
|
|
227
255
|
});
|
|
256
|
+
// Stash: save (global)
|
|
257
|
+
screen.key(['S-s'], () => {
|
|
258
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
|
|
259
|
+
return;
|
|
260
|
+
actions.stash();
|
|
261
|
+
});
|
|
262
|
+
// Stash: pop (commit tab only)
|
|
263
|
+
screen.key(['o'], () => {
|
|
264
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
|
|
265
|
+
return;
|
|
266
|
+
if (ctx.getBottomTab() === 'commit') {
|
|
267
|
+
actions.stashPop();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
270
|
+
// Stash: list modal (commit tab only)
|
|
271
|
+
screen.key(['l'], () => {
|
|
272
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
|
|
273
|
+
return;
|
|
274
|
+
if (ctx.getBottomTab() === 'commit') {
|
|
275
|
+
actions.openStashListModal();
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
// Soft reset HEAD~1 (commit tab only)
|
|
279
|
+
screen.key(['S-x'], () => {
|
|
280
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
|
|
281
|
+
return;
|
|
282
|
+
if (ctx.getBottomTab() === 'commit') {
|
|
283
|
+
actions.showSoftResetConfirm();
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
// Cherry-pick selected commit (history tab only)
|
|
287
|
+
screen.key(['p'], () => {
|
|
288
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
|
|
289
|
+
return;
|
|
290
|
+
if (ctx.getBottomTab() === 'history') {
|
|
291
|
+
actions.cherryPickSelected();
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
// Revert selected commit (history tab only)
|
|
295
|
+
screen.key(['v'], () => {
|
|
296
|
+
if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
|
|
297
|
+
return;
|
|
298
|
+
if (ctx.getBottomTab() === 'history') {
|
|
299
|
+
actions.revertSelected();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
228
302
|
}
|
package/dist/MouseHandlers.js
CHANGED
|
@@ -29,11 +29,22 @@ export function setupMouseHandlers(layout, actions, ctx) {
|
|
|
29
29
|
handleTopPaneClick(clickedRow, mouse.x, actions, ctx);
|
|
30
30
|
}
|
|
31
31
|
});
|
|
32
|
-
// Click on bottom pane
|
|
32
|
+
// Click on bottom pane
|
|
33
33
|
layout.bottomPane.on('click', (mouse) => {
|
|
34
34
|
const clickedRow = layout.screenYToBottomPaneRow(mouse.y);
|
|
35
35
|
if (clickedRow >= 0) {
|
|
36
|
-
|
|
36
|
+
if (ctx.uiState.state.bottomTab === 'commit') {
|
|
37
|
+
// Row 6 (0-indexed) is the amend checkbox row
|
|
38
|
+
if (clickedRow === 6) {
|
|
39
|
+
actions.toggleAmend();
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
actions.focusCommitInput();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
actions.selectHunkAtRow(clickedRow);
|
|
47
|
+
}
|
|
37
48
|
}
|
|
38
49
|
});
|
|
39
50
|
// Click on footer for tabs and toggles
|
|
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { EventEmitter } from 'node:events';
|
|
4
4
|
import { getIgnoredFiles } from '../git/ignoreUtils.js';
|
|
5
|
+
import { listAllFiles } from '../git/status.js';
|
|
5
6
|
const MAX_FILE_SIZE = 1024 * 1024; // 1MB
|
|
6
7
|
const WARN_FILE_SIZE = 100 * 1024; // 100KB
|
|
7
8
|
/**
|
|
@@ -25,6 +26,7 @@ export class ExplorerStateManager extends EventEmitter {
|
|
|
25
26
|
options;
|
|
26
27
|
expandedPaths = new Set();
|
|
27
28
|
gitStatusMap = { files: new Map(), directories: new Set() };
|
|
29
|
+
_cachedFilePaths = null;
|
|
28
30
|
_state = {
|
|
29
31
|
currentPath: '',
|
|
30
32
|
tree: null,
|
|
@@ -61,9 +63,12 @@ export class ExplorerStateManager extends EventEmitter {
|
|
|
61
63
|
}
|
|
62
64
|
/**
|
|
63
65
|
* Update git status map and refresh display.
|
|
66
|
+
* Also invalidates the file path cache so the next file finder open gets fresh data.
|
|
64
67
|
*/
|
|
65
68
|
setGitStatus(statusMap) {
|
|
66
69
|
this.gitStatusMap = statusMap;
|
|
70
|
+
// Invalidate file path cache — reload in background
|
|
71
|
+
this.loadFilePaths();
|
|
67
72
|
// Refresh display to show updated status
|
|
68
73
|
if (this._state.tree) {
|
|
69
74
|
this.applyGitStatusToTree(this._state.tree);
|
|
@@ -538,45 +543,23 @@ export class ExplorerStateManager extends EventEmitter {
|
|
|
538
543
|
return search(this._state.tree);
|
|
539
544
|
}
|
|
540
545
|
/**
|
|
541
|
-
*
|
|
542
|
-
*
|
|
546
|
+
* Load all file paths using git ls-files (fast, single git command).
|
|
547
|
+
* Stores result in cache for instant access by FileFinder.
|
|
543
548
|
*/
|
|
544
|
-
async
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
if (this.options.hideHidden && entry.name.startsWith('.')) {
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
const entryPath = dirPath ? path.join(dirPath, entry.name) : entry.name;
|
|
562
|
-
// Filter gitignored files
|
|
563
|
-
if (this.options.hideGitignored && ignoredFiles.has(entryPath)) {
|
|
564
|
-
continue;
|
|
565
|
-
}
|
|
566
|
-
if (entry.isDirectory()) {
|
|
567
|
-
await scanDir(entryPath);
|
|
568
|
-
}
|
|
569
|
-
else {
|
|
570
|
-
paths.push(entryPath);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
catch {
|
|
575
|
-
// Ignore errors for individual directories
|
|
576
|
-
}
|
|
577
|
-
};
|
|
578
|
-
await scanDir('');
|
|
579
|
-
return paths;
|
|
549
|
+
async loadFilePaths() {
|
|
550
|
+
try {
|
|
551
|
+
this._cachedFilePaths = await listAllFiles(this.repoPath);
|
|
552
|
+
}
|
|
553
|
+
catch {
|
|
554
|
+
this._cachedFilePaths = [];
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Get cached file paths (for file finder).
|
|
559
|
+
* Returns empty array if not yet loaded.
|
|
560
|
+
*/
|
|
561
|
+
getCachedFilePaths() {
|
|
562
|
+
return this._cachedFilePaths ?? [];
|
|
580
563
|
}
|
|
581
564
|
/**
|
|
582
565
|
* Navigate to a specific file path in the tree.
|
|
@@ -5,7 +5,7 @@ import { watch } from 'chokidar';
|
|
|
5
5
|
import { EventEmitter } from 'node:events';
|
|
6
6
|
import ignore from 'ignore';
|
|
7
7
|
import { getQueueForRepo, removeQueueForRepo } from './GitOperationQueue.js';
|
|
8
|
-
import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, stageHunk as gitStageHunk, unstageHunk as gitUnstageHunk, } from '../git/status.js';
|
|
8
|
+
import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, stageHunk as gitStageHunk, unstageHunk as gitUnstageHunk, push as gitPush, fetchRemote as gitFetchRemote, pullRebase as gitPullRebase, getStashList as gitGetStashList, stashSave as gitStashSave, stashPop as gitStashPop, getLocalBranches as gitGetLocalBranches, switchBranch as gitSwitchBranch, createBranch as gitCreateBranch, softResetHead as gitSoftResetHead, cherryPick as gitCherryPick, revertCommit as gitRevertCommit, } from '../git/status.js';
|
|
9
9
|
import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, countHunksPerFile, } from '../git/diff.js';
|
|
10
10
|
import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
|
|
11
11
|
/**
|
|
@@ -28,6 +28,7 @@ export class GitStateManager extends EventEmitter {
|
|
|
28
28
|
isLoading: false,
|
|
29
29
|
error: null,
|
|
30
30
|
hunkCounts: null,
|
|
31
|
+
stashList: [],
|
|
31
32
|
};
|
|
32
33
|
_compareState = {
|
|
33
34
|
compareDiff: null,
|
|
@@ -46,6 +47,12 @@ export class GitStateManager extends EventEmitter {
|
|
|
46
47
|
index: 0,
|
|
47
48
|
diff: null,
|
|
48
49
|
};
|
|
50
|
+
_remoteState = {
|
|
51
|
+
operation: null,
|
|
52
|
+
inProgress: false,
|
|
53
|
+
error: null,
|
|
54
|
+
lastResult: null,
|
|
55
|
+
};
|
|
49
56
|
constructor(repoPath) {
|
|
50
57
|
super();
|
|
51
58
|
this.repoPath = repoPath;
|
|
@@ -63,6 +70,13 @@ export class GitStateManager extends EventEmitter {
|
|
|
63
70
|
get compareSelectionState() {
|
|
64
71
|
return this._compareSelectionState;
|
|
65
72
|
}
|
|
73
|
+
get remoteState() {
|
|
74
|
+
return this._remoteState;
|
|
75
|
+
}
|
|
76
|
+
updateRemoteState(partial) {
|
|
77
|
+
this._remoteState = { ...this._remoteState, ...partial };
|
|
78
|
+
this.emit('remote-state-change', this._remoteState);
|
|
79
|
+
}
|
|
66
80
|
updateState(partial) {
|
|
67
81
|
this._state = { ...this._state, ...partial };
|
|
68
82
|
this.emit('state-change', this._state);
|
|
@@ -504,6 +518,133 @@ export class GitStateManager extends EventEmitter {
|
|
|
504
518
|
});
|
|
505
519
|
}
|
|
506
520
|
}
|
|
521
|
+
// Remote operations
|
|
522
|
+
/**
|
|
523
|
+
* Push to remote.
|
|
524
|
+
*/
|
|
525
|
+
async push() {
|
|
526
|
+
if (this._remoteState.inProgress)
|
|
527
|
+
return;
|
|
528
|
+
await this.runRemoteOperation('push', () => gitPush(this.repoPath));
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Fetch from remote.
|
|
532
|
+
*/
|
|
533
|
+
async fetchRemote() {
|
|
534
|
+
if (this._remoteState.inProgress)
|
|
535
|
+
return;
|
|
536
|
+
await this.runRemoteOperation('fetch', () => gitFetchRemote(this.repoPath));
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Pull with rebase from remote.
|
|
540
|
+
*/
|
|
541
|
+
async pullRebase() {
|
|
542
|
+
if (this._remoteState.inProgress)
|
|
543
|
+
return;
|
|
544
|
+
await this.runRemoteOperation('pull', () => gitPullRebase(this.repoPath));
|
|
545
|
+
}
|
|
546
|
+
async runRemoteOperation(operation, fn) {
|
|
547
|
+
this.updateRemoteState({ operation, inProgress: true, error: null, lastResult: null });
|
|
548
|
+
try {
|
|
549
|
+
const result = await this.queue.enqueue(fn);
|
|
550
|
+
this.updateRemoteState({ inProgress: false, lastResult: result });
|
|
551
|
+
// Refresh status to pick up new ahead/behind counts
|
|
552
|
+
this.scheduleRefresh();
|
|
553
|
+
}
|
|
554
|
+
catch (err) {
|
|
555
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
556
|
+
this.updateRemoteState({ inProgress: false, error: message });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// Stash operations
|
|
560
|
+
/**
|
|
561
|
+
* Load the stash list.
|
|
562
|
+
*/
|
|
563
|
+
async loadStashList() {
|
|
564
|
+
try {
|
|
565
|
+
const stashList = await this.queue.enqueue(() => gitGetStashList(this.repoPath));
|
|
566
|
+
this.updateState({ stashList });
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// Silently ignore — stash list is non-critical
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/**
|
|
573
|
+
* Save working changes to stash.
|
|
574
|
+
*/
|
|
575
|
+
async stash(message) {
|
|
576
|
+
if (this._remoteState.inProgress)
|
|
577
|
+
return;
|
|
578
|
+
await this.runRemoteOperation('stash', () => gitStashSave(this.repoPath, message));
|
|
579
|
+
await this.loadStashList();
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Pop a stash entry.
|
|
583
|
+
*/
|
|
584
|
+
async stashPop(index = 0) {
|
|
585
|
+
if (this._remoteState.inProgress)
|
|
586
|
+
return;
|
|
587
|
+
await this.runRemoteOperation('stashPop', () => gitStashPop(this.repoPath, index));
|
|
588
|
+
await this.loadStashList();
|
|
589
|
+
}
|
|
590
|
+
// Branch operations
|
|
591
|
+
/**
|
|
592
|
+
* Get local branches.
|
|
593
|
+
*/
|
|
594
|
+
async getLocalBranches() {
|
|
595
|
+
return this.queue.enqueue(() => gitGetLocalBranches(this.repoPath));
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Switch to an existing branch.
|
|
599
|
+
*/
|
|
600
|
+
async switchBranch(name) {
|
|
601
|
+
if (this._remoteState.inProgress)
|
|
602
|
+
return;
|
|
603
|
+
await this.runRemoteOperation('branchSwitch', () => gitSwitchBranch(this.repoPath, name));
|
|
604
|
+
// Reset compare base branch since it may not exist on the new branch
|
|
605
|
+
this.updateCompareState({ compareBaseBranch: null });
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Create and switch to a new branch.
|
|
609
|
+
*/
|
|
610
|
+
async createBranch(name) {
|
|
611
|
+
if (this._remoteState.inProgress)
|
|
612
|
+
return;
|
|
613
|
+
await this.runRemoteOperation('branchCreate', () => gitCreateBranch(this.repoPath, name));
|
|
614
|
+
this.updateCompareState({ compareBaseBranch: null });
|
|
615
|
+
}
|
|
616
|
+
// Undo operations
|
|
617
|
+
/**
|
|
618
|
+
* Soft reset HEAD by count commits.
|
|
619
|
+
*/
|
|
620
|
+
async softReset(count = 1) {
|
|
621
|
+
if (this._remoteState.inProgress)
|
|
622
|
+
return;
|
|
623
|
+
await this.runRemoteOperation('softReset', () => gitSoftResetHead(this.repoPath, count));
|
|
624
|
+
}
|
|
625
|
+
// History actions
|
|
626
|
+
/**
|
|
627
|
+
* Cherry-pick a commit.
|
|
628
|
+
*/
|
|
629
|
+
async cherryPick(hash) {
|
|
630
|
+
if (this._remoteState.inProgress)
|
|
631
|
+
return;
|
|
632
|
+
await this.runRemoteOperation('cherryPick', () => gitCherryPick(this.repoPath, hash));
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Revert a commit.
|
|
636
|
+
*/
|
|
637
|
+
async revertCommit(hash) {
|
|
638
|
+
if (this._remoteState.inProgress)
|
|
639
|
+
return;
|
|
640
|
+
await this.runRemoteOperation('revert', () => gitRevertCommit(this.repoPath, hash));
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Clear the remote state (e.g. after auto-clear timeout).
|
|
644
|
+
*/
|
|
645
|
+
clearRemoteState() {
|
|
646
|
+
this.updateRemoteState({ operation: null, error: null, lastResult: null });
|
|
647
|
+
}
|
|
507
648
|
/**
|
|
508
649
|
* Get the HEAD commit message.
|
|
509
650
|
*/
|