diffstalker 0.2.3 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dependency-cruiser.cjs +2 -2
- 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 +17 -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/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
|
@@ -1,786 +1,43 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import { EventEmitter } from 'node:events';
|
|
6
|
-
import ignore from 'ignore';
|
|
1
|
+
/**
|
|
2
|
+
* GitStateManager coordinator.
|
|
3
|
+
* Wires sub-managers together; callers access sub-managers directly.
|
|
4
|
+
*/
|
|
7
5
|
import { getQueueForRepo, removeQueueForRepo } from './GitOperationQueue.js';
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
6
|
+
import { WorkingTreeManager } from './WorkingTreeManager.js';
|
|
7
|
+
import { HistoryManager } from './HistoryManager.js';
|
|
8
|
+
import { CompareManager } from './CompareManager.js';
|
|
9
|
+
import { RemoteOperationManager } from './RemoteOperationManager.js';
|
|
11
10
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
11
|
+
* Coordinates WorkingTreeManager, HistoryManager,
|
|
12
|
+
* CompareManager, and RemoteOperationManager.
|
|
13
|
+
* Sub-managers are public readonly — callers use them directly.
|
|
14
14
|
*/
|
|
15
|
-
export class GitStateManager
|
|
15
|
+
export class GitStateManager {
|
|
16
|
+
workingTree;
|
|
17
|
+
history;
|
|
18
|
+
compare;
|
|
19
|
+
remote;
|
|
16
20
|
repoPath;
|
|
17
|
-
queue;
|
|
18
|
-
gitWatcher = null;
|
|
19
|
-
workingDirWatcher = null;
|
|
20
|
-
ignorers = new Map();
|
|
21
|
-
diffDebounceTimer = null;
|
|
22
|
-
// Current state
|
|
23
|
-
_state = {
|
|
24
|
-
status: null,
|
|
25
|
-
diff: null,
|
|
26
|
-
combinedFileDiffs: null,
|
|
27
|
-
selectedFile: null,
|
|
28
|
-
isLoading: false,
|
|
29
|
-
error: null,
|
|
30
|
-
hunkCounts: null,
|
|
31
|
-
stashList: [],
|
|
32
|
-
};
|
|
33
|
-
_compareState = {
|
|
34
|
-
compareDiff: null,
|
|
35
|
-
compareBaseBranch: null,
|
|
36
|
-
compareLoading: false,
|
|
37
|
-
compareError: null,
|
|
38
|
-
};
|
|
39
|
-
_historyState = {
|
|
40
|
-
commits: [],
|
|
41
|
-
selectedCommit: null,
|
|
42
|
-
commitDiff: null,
|
|
43
|
-
isLoading: false,
|
|
44
|
-
};
|
|
45
|
-
_compareSelectionState = {
|
|
46
|
-
type: null,
|
|
47
|
-
index: 0,
|
|
48
|
-
diff: null,
|
|
49
|
-
};
|
|
50
|
-
_remoteState = {
|
|
51
|
-
operation: null,
|
|
52
|
-
inProgress: false,
|
|
53
|
-
error: null,
|
|
54
|
-
lastResult: null,
|
|
55
|
-
};
|
|
56
21
|
constructor(repoPath) {
|
|
57
|
-
super();
|
|
58
22
|
this.repoPath = repoPath;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
get historyState() {
|
|
68
|
-
return this._historyState;
|
|
69
|
-
}
|
|
70
|
-
get compareSelectionState() {
|
|
71
|
-
return this._compareSelectionState;
|
|
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
|
-
}
|
|
80
|
-
updateState(partial) {
|
|
81
|
-
this._state = { ...this._state, ...partial };
|
|
82
|
-
this.emit('state-change', this._state);
|
|
83
|
-
}
|
|
84
|
-
updateCompareState(partial) {
|
|
85
|
-
this._compareState = { ...this._compareState, ...partial };
|
|
86
|
-
this.emit('compare-state-change', this._compareState);
|
|
87
|
-
}
|
|
88
|
-
updateHistoryState(partial) {
|
|
89
|
-
this._historyState = { ...this._historyState, ...partial };
|
|
90
|
-
this.emit('history-state-change', this._historyState);
|
|
91
|
-
}
|
|
92
|
-
updateCompareSelectionState(partial) {
|
|
93
|
-
this._compareSelectionState = { ...this._compareSelectionState, ...partial };
|
|
94
|
-
this.emit('compare-selection-change', this._compareSelectionState);
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* Load gitignore patterns from all .gitignore files and .git/info/exclude.
|
|
98
|
-
* Returns a Map of directory → Ignore instance, where each instance handles
|
|
99
|
-
* patterns relative to its own directory (matching how git scopes .gitignore files).
|
|
100
|
-
*/
|
|
101
|
-
loadGitignores() {
|
|
102
|
-
const ignorers = new Map();
|
|
103
|
-
// Root ignorer: .git dir + root .gitignore + .git/info/exclude
|
|
104
|
-
const rootIg = ignore();
|
|
105
|
-
rootIg.add('.git');
|
|
106
|
-
const rootGitignorePath = path.join(this.repoPath, '.gitignore');
|
|
107
|
-
if (fs.existsSync(rootGitignorePath)) {
|
|
108
|
-
rootIg.add(fs.readFileSync(rootGitignorePath, 'utf-8'));
|
|
109
|
-
}
|
|
110
|
-
const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
|
|
111
|
-
if (fs.existsSync(excludePath)) {
|
|
112
|
-
rootIg.add(fs.readFileSync(excludePath, 'utf-8'));
|
|
113
|
-
}
|
|
114
|
-
ignorers.set('', rootIg);
|
|
115
|
-
// Find all nested .gitignore files using git ls-files
|
|
116
|
-
try {
|
|
117
|
-
const output = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '**/.gitignore'], { cwd: this.repoPath, encoding: 'utf-8' });
|
|
118
|
-
for (const entry of output.split('\0')) {
|
|
119
|
-
if (!entry || entry === '.gitignore')
|
|
120
|
-
continue;
|
|
121
|
-
if (!entry.endsWith('.gitignore'))
|
|
122
|
-
continue;
|
|
123
|
-
const dir = path.dirname(entry);
|
|
124
|
-
const absPath = path.join(this.repoPath, entry);
|
|
125
|
-
try {
|
|
126
|
-
const content = fs.readFileSync(absPath, 'utf-8');
|
|
127
|
-
const ig = ignore();
|
|
128
|
-
ig.add(content);
|
|
129
|
-
ignorers.set(dir, ig);
|
|
130
|
-
}
|
|
131
|
-
catch {
|
|
132
|
-
// Skip unreadable files
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
catch {
|
|
137
|
-
// git ls-files failed — we still have the root ignorer
|
|
138
|
-
}
|
|
139
|
-
return ignorers;
|
|
140
|
-
}
|
|
141
|
-
/**
|
|
142
|
-
* Start watching for file changes.
|
|
143
|
-
*/
|
|
144
|
-
startWatching() {
|
|
145
|
-
const gitDir = path.join(this.repoPath, '.git');
|
|
146
|
-
if (!fs.existsSync(gitDir))
|
|
147
|
-
return;
|
|
148
|
-
// --- Git internals watcher ---
|
|
149
|
-
const indexFile = path.join(gitDir, 'index');
|
|
150
|
-
const headFile = path.join(gitDir, 'HEAD');
|
|
151
|
-
const refsDir = path.join(gitDir, 'refs');
|
|
152
|
-
const gitignorePath = path.join(this.repoPath, '.gitignore');
|
|
153
|
-
// Git uses atomic writes (write to temp, then rename). We use polling
|
|
154
|
-
// for reliable detection of these atomic operations.
|
|
155
|
-
this.gitWatcher = watch([indexFile, headFile, refsDir, gitignorePath], {
|
|
156
|
-
persistent: true,
|
|
157
|
-
ignoreInitial: true,
|
|
158
|
-
usePolling: true,
|
|
159
|
-
interval: 100,
|
|
160
|
-
});
|
|
161
|
-
// --- Working directory watcher with gitignore support ---
|
|
162
|
-
this.ignorers = this.loadGitignores();
|
|
163
|
-
this.workingDirWatcher = watch(this.repoPath, {
|
|
164
|
-
persistent: true,
|
|
165
|
-
ignoreInitial: true,
|
|
166
|
-
ignored: (filePath) => {
|
|
167
|
-
const relativePath = path.relative(this.repoPath, filePath);
|
|
168
|
-
if (!relativePath)
|
|
169
|
-
return false;
|
|
170
|
-
// Walk ancestor directories from root to parent, checking each ignorer
|
|
171
|
-
const parts = relativePath.split('/');
|
|
172
|
-
for (let depth = 0; depth < parts.length; depth++) {
|
|
173
|
-
const dir = depth === 0 ? '' : parts.slice(0, depth).join('/');
|
|
174
|
-
const ig = this.ignorers.get(dir);
|
|
175
|
-
if (ig) {
|
|
176
|
-
const relToDir = depth === 0 ? relativePath : parts.slice(depth).join('/');
|
|
177
|
-
if (ig.ignores(relToDir))
|
|
178
|
-
return true;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return false;
|
|
182
|
-
},
|
|
183
|
-
awaitWriteFinish: {
|
|
184
|
-
stabilityThreshold: 100,
|
|
185
|
-
pollInterval: 50,
|
|
186
|
-
},
|
|
23
|
+
const queue = getQueueForRepo(repoPath);
|
|
24
|
+
// Create sub-managers with cross-cutting callbacks
|
|
25
|
+
this.history = new HistoryManager(repoPath, queue);
|
|
26
|
+
this.compare = new CompareManager(repoPath, queue);
|
|
27
|
+
this.workingTree = new WorkingTreeManager(repoPath, queue, async () => {
|
|
28
|
+
await this.history.refreshIfLoaded();
|
|
29
|
+
await this.compare.refreshIfLoaded();
|
|
187
30
|
});
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
this.ignorers = this.loadGitignores();
|
|
193
|
-
}
|
|
194
|
-
scheduleRefresh();
|
|
195
|
-
});
|
|
196
|
-
this.gitWatcher.on('add', scheduleRefresh);
|
|
197
|
-
this.gitWatcher.on('unlink', scheduleRefresh);
|
|
198
|
-
this.gitWatcher.on('error', (err) => {
|
|
199
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
200
|
-
this.emit('error', `Git watcher error: ${message}`);
|
|
201
|
-
});
|
|
202
|
-
this.workingDirWatcher.on('change', scheduleRefresh);
|
|
203
|
-
this.workingDirWatcher.on('add', scheduleRefresh);
|
|
204
|
-
this.workingDirWatcher.on('unlink', scheduleRefresh);
|
|
205
|
-
this.workingDirWatcher.on('error', (err) => {
|
|
206
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
207
|
-
this.emit('error', `Working dir watcher error: ${message}`);
|
|
31
|
+
this.remote = new RemoteOperationManager(repoPath, queue, {
|
|
32
|
+
scheduleRefresh: () => this.workingTree.scheduleRefresh(),
|
|
33
|
+
loadStashList: () => this.workingTree.loadStashList(),
|
|
34
|
+
resetCompareBaseBranch: () => this.compare.resetBaseBranch(),
|
|
208
35
|
});
|
|
209
36
|
}
|
|
210
|
-
/**
|
|
211
|
-
* Stop watching and clean up resources.
|
|
212
|
-
*/
|
|
213
37
|
dispose() {
|
|
214
|
-
|
|
215
|
-
clearTimeout(this.diffDebounceTimer);
|
|
216
|
-
this.gitWatcher?.close();
|
|
217
|
-
this.workingDirWatcher?.close();
|
|
38
|
+
this.workingTree.dispose();
|
|
218
39
|
removeQueueForRepo(this.repoPath);
|
|
219
40
|
}
|
|
220
|
-
/**
|
|
221
|
-
* Schedule a refresh (coalesced if one is already pending).
|
|
222
|
-
* Also refreshes history and compare data if they were previously loaded.
|
|
223
|
-
*/
|
|
224
|
-
scheduleRefresh() {
|
|
225
|
-
this.queue.scheduleRefresh(async () => {
|
|
226
|
-
await this.doRefresh();
|
|
227
|
-
// Also refresh history if it was loaded (has commits)
|
|
228
|
-
if (this._historyState.commits.length > 0) {
|
|
229
|
-
await this.doLoadHistory();
|
|
230
|
-
}
|
|
231
|
-
// Also refresh compare if it was loaded (has a base branch set)
|
|
232
|
-
if (this._compareState.compareBaseBranch) {
|
|
233
|
-
await this.doRefreshCompareDiff(false);
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* Schedule a lightweight status-only refresh (no diff fetching).
|
|
239
|
-
* Used after stage/unstage where the diff view updates on file selection.
|
|
240
|
-
*/
|
|
241
|
-
scheduleStatusRefresh() {
|
|
242
|
-
this.queue.scheduleRefresh(async () => {
|
|
243
|
-
const newStatus = await getStatus(this.repoPath);
|
|
244
|
-
if (!newStatus.isRepo) {
|
|
245
|
-
this.updateState({
|
|
246
|
-
status: newStatus,
|
|
247
|
-
diff: null,
|
|
248
|
-
isLoading: false,
|
|
249
|
-
error: 'Not a git repository',
|
|
250
|
-
});
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
this.updateState({ status: newStatus, isLoading: false });
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
/**
|
|
257
|
-
* Immediately refresh git state.
|
|
258
|
-
*/
|
|
259
|
-
async refresh() {
|
|
260
|
-
await this.queue.enqueue(() => this.doRefresh());
|
|
261
|
-
}
|
|
262
|
-
async doRefresh() {
|
|
263
|
-
this.updateState({ isLoading: true, error: null });
|
|
264
|
-
try {
|
|
265
|
-
const newStatus = await getStatus(this.repoPath);
|
|
266
|
-
if (!newStatus.isRepo) {
|
|
267
|
-
this.updateState({
|
|
268
|
-
status: newStatus,
|
|
269
|
-
diff: null,
|
|
270
|
-
isLoading: false,
|
|
271
|
-
error: 'Not a git repository',
|
|
272
|
-
});
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
// Fetch unstaged and staged diffs in parallel
|
|
276
|
-
const [allUnstagedDiff, allStagedDiff] = await Promise.all([
|
|
277
|
-
getDiff(this.repoPath, undefined, false),
|
|
278
|
-
getDiff(this.repoPath, undefined, true),
|
|
279
|
-
]);
|
|
280
|
-
// Count hunks per file for the file list display
|
|
281
|
-
const hunkCounts = {
|
|
282
|
-
unstaged: countHunksPerFile(allUnstagedDiff.raw),
|
|
283
|
-
staged: countHunksPerFile(allStagedDiff.raw),
|
|
284
|
-
};
|
|
285
|
-
// Determine display diff based on selected file
|
|
286
|
-
const { displayDiff, combinedFileDiffs } = await this.resolveFileDiffs(newStatus, allUnstagedDiff);
|
|
287
|
-
// Batch status + diffs into a single update to avoid flicker
|
|
288
|
-
this.updateState({
|
|
289
|
-
status: newStatus,
|
|
290
|
-
diff: displayDiff,
|
|
291
|
-
combinedFileDiffs,
|
|
292
|
-
hunkCounts,
|
|
293
|
-
isLoading: false,
|
|
294
|
-
});
|
|
295
|
-
}
|
|
296
|
-
catch (err) {
|
|
297
|
-
this.updateState({
|
|
298
|
-
isLoading: false,
|
|
299
|
-
error: err instanceof Error ? err.message : 'Unknown error',
|
|
300
|
-
});
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* Resolve display diff and combined diffs for the currently selected file.
|
|
305
|
-
*/
|
|
306
|
-
async resolveFileDiffs(newStatus, fallbackDiff) {
|
|
307
|
-
const currentSelectedFile = this._state.selectedFile;
|
|
308
|
-
if (!currentSelectedFile) {
|
|
309
|
-
return { displayDiff: fallbackDiff, combinedFileDiffs: null };
|
|
310
|
-
}
|
|
311
|
-
// Match by path + staged, falling back to path-only (handles staging state changes)
|
|
312
|
-
const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged) ?? newStatus.files.find((f) => f.path === currentSelectedFile.path);
|
|
313
|
-
if (!currentFile) {
|
|
314
|
-
this.updateState({ selectedFile: null });
|
|
315
|
-
return { displayDiff: fallbackDiff, combinedFileDiffs: null };
|
|
316
|
-
}
|
|
317
|
-
if (currentFile.status === 'untracked') {
|
|
318
|
-
const displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
|
|
319
|
-
return {
|
|
320
|
-
displayDiff,
|
|
321
|
-
combinedFileDiffs: { unstaged: displayDiff, staged: { raw: '', lines: [] } },
|
|
322
|
-
};
|
|
323
|
-
}
|
|
324
|
-
const [unstagedFileDiff, stagedFileDiff] = await Promise.all([
|
|
325
|
-
getDiff(this.repoPath, currentFile.path, false),
|
|
326
|
-
getDiff(this.repoPath, currentFile.path, true),
|
|
327
|
-
]);
|
|
328
|
-
const displayDiff = currentFile.staged ? stagedFileDiff : unstagedFileDiff;
|
|
329
|
-
return {
|
|
330
|
-
displayDiff,
|
|
331
|
-
combinedFileDiffs: { unstaged: unstagedFileDiff, staged: stagedFileDiff },
|
|
332
|
-
};
|
|
333
|
-
}
|
|
334
|
-
/**
|
|
335
|
-
* Select a file and update the diff display.
|
|
336
|
-
* The selection highlight updates immediately; the diff fetch is debounced
|
|
337
|
-
* so rapid arrow-key presses only spawn one git process for the final file.
|
|
338
|
-
*/
|
|
339
|
-
selectFile(file) {
|
|
340
|
-
this.updateState({ selectedFile: file });
|
|
341
|
-
if (!this._state.status?.isRepo)
|
|
342
|
-
return;
|
|
343
|
-
if (this.diffDebounceTimer) {
|
|
344
|
-
// Already cooling down — reset the timer and fetch when it expires
|
|
345
|
-
clearTimeout(this.diffDebounceTimer);
|
|
346
|
-
this.diffDebounceTimer = setTimeout(() => {
|
|
347
|
-
this.diffDebounceTimer = null;
|
|
348
|
-
this.fetchDiffForSelection();
|
|
349
|
-
}, 20);
|
|
350
|
-
}
|
|
351
|
-
else {
|
|
352
|
-
// First call — fetch immediately, then start cooldown
|
|
353
|
-
this.fetchDiffForSelection();
|
|
354
|
-
this.diffDebounceTimer = setTimeout(() => {
|
|
355
|
-
this.diffDebounceTimer = null;
|
|
356
|
-
}, 20);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
fetchDiffForSelection() {
|
|
360
|
-
const file = this._state.selectedFile;
|
|
361
|
-
this.queue
|
|
362
|
-
.enqueue(async () => {
|
|
363
|
-
if (file !== this._state.selectedFile)
|
|
364
|
-
return;
|
|
365
|
-
await this.doFetchDiffForFile(file);
|
|
366
|
-
})
|
|
367
|
-
.catch((err) => {
|
|
368
|
-
this.updateState({
|
|
369
|
-
error: `Failed to load diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
370
|
-
});
|
|
371
|
-
});
|
|
372
|
-
}
|
|
373
|
-
async doFetchDiffForFile(file) {
|
|
374
|
-
if (!file) {
|
|
375
|
-
const allDiff = await getStagedDiff(this.repoPath);
|
|
376
|
-
if (this._state.selectedFile === null) {
|
|
377
|
-
this.updateState({ diff: allDiff, combinedFileDiffs: null });
|
|
378
|
-
}
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
if (file.status === 'untracked') {
|
|
382
|
-
const fileDiff = await getDiffForUntracked(this.repoPath, file.path);
|
|
383
|
-
if (file === this._state.selectedFile) {
|
|
384
|
-
this.updateState({
|
|
385
|
-
diff: fileDiff,
|
|
386
|
-
combinedFileDiffs: { unstaged: fileDiff, staged: { raw: '', lines: [] } },
|
|
387
|
-
});
|
|
388
|
-
}
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
const [unstagedDiff, stagedDiff] = await Promise.all([
|
|
392
|
-
getDiff(this.repoPath, file.path, false),
|
|
393
|
-
getDiff(this.repoPath, file.path, true),
|
|
394
|
-
]);
|
|
395
|
-
if (file === this._state.selectedFile) {
|
|
396
|
-
const displayDiff = file.staged ? stagedDiff : unstagedDiff;
|
|
397
|
-
this.updateState({
|
|
398
|
-
diff: displayDiff,
|
|
399
|
-
combinedFileDiffs: { unstaged: unstagedDiff, staged: stagedDiff },
|
|
400
|
-
});
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
/**
|
|
404
|
-
* Stage a file.
|
|
405
|
-
*/
|
|
406
|
-
async stage(file) {
|
|
407
|
-
try {
|
|
408
|
-
await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
|
|
409
|
-
this.scheduleStatusRefresh();
|
|
410
|
-
}
|
|
411
|
-
catch (err) {
|
|
412
|
-
await this.refresh();
|
|
413
|
-
this.updateState({
|
|
414
|
-
error: `Failed to stage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
415
|
-
});
|
|
416
|
-
}
|
|
417
|
-
}
|
|
418
|
-
/**
|
|
419
|
-
* Unstage a file.
|
|
420
|
-
*/
|
|
421
|
-
async unstage(file) {
|
|
422
|
-
try {
|
|
423
|
-
await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
|
|
424
|
-
this.scheduleStatusRefresh();
|
|
425
|
-
}
|
|
426
|
-
catch (err) {
|
|
427
|
-
await this.refresh();
|
|
428
|
-
this.updateState({
|
|
429
|
-
error: `Failed to unstage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
430
|
-
});
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
/**
|
|
434
|
-
* Stage a single hunk via patch.
|
|
435
|
-
*/
|
|
436
|
-
async stageHunk(patch) {
|
|
437
|
-
try {
|
|
438
|
-
await this.queue.enqueueMutation(async () => gitStageHunk(this.repoPath, patch));
|
|
439
|
-
this.scheduleRefresh();
|
|
440
|
-
}
|
|
441
|
-
catch (err) {
|
|
442
|
-
await this.refresh();
|
|
443
|
-
this.updateState({
|
|
444
|
-
error: `Failed to stage hunk: ${err instanceof Error ? err.message : String(err)}`,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
/**
|
|
449
|
-
* Unstage a single hunk via patch.
|
|
450
|
-
*/
|
|
451
|
-
async unstageHunk(patch) {
|
|
452
|
-
try {
|
|
453
|
-
await this.queue.enqueueMutation(async () => gitUnstageHunk(this.repoPath, patch));
|
|
454
|
-
this.scheduleRefresh();
|
|
455
|
-
}
|
|
456
|
-
catch (err) {
|
|
457
|
-
await this.refresh();
|
|
458
|
-
this.updateState({
|
|
459
|
-
error: `Failed to unstage hunk: ${err instanceof Error ? err.message : String(err)}`,
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
/**
|
|
464
|
-
* Discard changes to a file.
|
|
465
|
-
*/
|
|
466
|
-
async discard(file) {
|
|
467
|
-
if (file.staged || file.status === 'untracked')
|
|
468
|
-
return;
|
|
469
|
-
try {
|
|
470
|
-
await this.queue.enqueueMutation(() => gitDiscardChanges(this.repoPath, file.path));
|
|
471
|
-
await this.refresh();
|
|
472
|
-
}
|
|
473
|
-
catch (err) {
|
|
474
|
-
this.updateState({
|
|
475
|
-
error: `Failed to discard ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
476
|
-
});
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
/**
|
|
480
|
-
* Stage all files.
|
|
481
|
-
*/
|
|
482
|
-
async stageAll() {
|
|
483
|
-
try {
|
|
484
|
-
await this.queue.enqueueMutation(() => gitStageAll(this.repoPath));
|
|
485
|
-
await this.refresh();
|
|
486
|
-
}
|
|
487
|
-
catch (err) {
|
|
488
|
-
this.updateState({
|
|
489
|
-
error: `Failed to stage all: ${err instanceof Error ? err.message : String(err)}`,
|
|
490
|
-
});
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
/**
|
|
494
|
-
* Unstage all files.
|
|
495
|
-
*/
|
|
496
|
-
async unstageAll() {
|
|
497
|
-
try {
|
|
498
|
-
await this.queue.enqueueMutation(() => gitUnstageAll(this.repoPath));
|
|
499
|
-
await this.refresh();
|
|
500
|
-
}
|
|
501
|
-
catch (err) {
|
|
502
|
-
this.updateState({
|
|
503
|
-
error: `Failed to unstage all: ${err instanceof Error ? err.message : String(err)}`,
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
/**
|
|
508
|
-
* Create a commit.
|
|
509
|
-
*/
|
|
510
|
-
async commit(message, amend = false) {
|
|
511
|
-
try {
|
|
512
|
-
await this.queue.enqueue(() => gitCommit(this.repoPath, message, amend));
|
|
513
|
-
await this.refresh();
|
|
514
|
-
}
|
|
515
|
-
catch (err) {
|
|
516
|
-
this.updateState({
|
|
517
|
-
error: `Failed to commit: ${err instanceof Error ? err.message : String(err)}`,
|
|
518
|
-
});
|
|
519
|
-
}
|
|
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
|
-
}
|
|
648
|
-
/**
|
|
649
|
-
* Get the HEAD commit message.
|
|
650
|
-
*/
|
|
651
|
-
async getHeadCommitMessage() {
|
|
652
|
-
return this.queue.enqueue(() => getHeadMessage(this.repoPath));
|
|
653
|
-
}
|
|
654
|
-
/**
|
|
655
|
-
* Refresh compare diff.
|
|
656
|
-
*/
|
|
657
|
-
async refreshCompareDiff(includeUncommitted = false) {
|
|
658
|
-
this.updateCompareState({ compareLoading: true, compareError: null });
|
|
659
|
-
try {
|
|
660
|
-
await this.queue.enqueue(() => this.doRefreshCompareDiff(includeUncommitted));
|
|
661
|
-
}
|
|
662
|
-
catch (err) {
|
|
663
|
-
this.updateCompareState({
|
|
664
|
-
compareLoading: false,
|
|
665
|
-
compareError: `Failed to load compare diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
666
|
-
});
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
/**
|
|
670
|
-
* Internal: refresh compare diff (called within queue).
|
|
671
|
-
*/
|
|
672
|
-
async doRefreshCompareDiff(includeUncommitted) {
|
|
673
|
-
let base = this._compareState.compareBaseBranch;
|
|
674
|
-
if (!base) {
|
|
675
|
-
// Try cached value first, then fall back to default detection
|
|
676
|
-
base = getCachedBaseBranch(this.repoPath) ?? (await getDefaultBaseBranch(this.repoPath));
|
|
677
|
-
this.updateCompareState({ compareBaseBranch: base });
|
|
678
|
-
}
|
|
679
|
-
if (base) {
|
|
680
|
-
const diff = includeUncommitted
|
|
681
|
-
? await getCompareDiffWithUncommitted(this.repoPath, base)
|
|
682
|
-
: await getDiffBetweenRefs(this.repoPath, base);
|
|
683
|
-
this.updateCompareState({ compareDiff: diff, compareLoading: false });
|
|
684
|
-
}
|
|
685
|
-
else {
|
|
686
|
-
this.updateCompareState({
|
|
687
|
-
compareDiff: null,
|
|
688
|
-
compareLoading: false,
|
|
689
|
-
compareError: 'No base branch found',
|
|
690
|
-
});
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
/**
|
|
694
|
-
* Get candidate base branches for branch comparison.
|
|
695
|
-
*/
|
|
696
|
-
async getCandidateBaseBranches() {
|
|
697
|
-
return getCandidateBaseBranches(this.repoPath);
|
|
698
|
-
}
|
|
699
|
-
/**
|
|
700
|
-
* Set the base branch for branch comparison and refresh.
|
|
701
|
-
* Also saves the selection to the cache for future sessions.
|
|
702
|
-
*/
|
|
703
|
-
async setCompareBaseBranch(branch, includeUncommitted = false) {
|
|
704
|
-
this.updateCompareState({ compareBaseBranch: branch });
|
|
705
|
-
setCachedBaseBranch(this.repoPath, branch);
|
|
706
|
-
await this.refreshCompareDiff(includeUncommitted);
|
|
707
|
-
}
|
|
708
|
-
/**
|
|
709
|
-
* Load commit history for the history view.
|
|
710
|
-
*/
|
|
711
|
-
async loadHistory(count = 100) {
|
|
712
|
-
this.updateHistoryState({ isLoading: true });
|
|
713
|
-
try {
|
|
714
|
-
await this.queue.enqueue(() => this.doLoadHistory(count));
|
|
715
|
-
}
|
|
716
|
-
catch (err) {
|
|
717
|
-
this.updateHistoryState({ isLoading: false });
|
|
718
|
-
this.updateState({
|
|
719
|
-
error: `Failed to load history: ${err instanceof Error ? err.message : String(err)}`,
|
|
720
|
-
});
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
/**
|
|
724
|
-
* Internal: load commit history (called within queue).
|
|
725
|
-
*/
|
|
726
|
-
async doLoadHistory(count = 100) {
|
|
727
|
-
const commits = await getCommitHistory(this.repoPath, count);
|
|
728
|
-
this.updateHistoryState({ commits, isLoading: false });
|
|
729
|
-
}
|
|
730
|
-
/**
|
|
731
|
-
* Select a commit in history view and load its diff.
|
|
732
|
-
*/
|
|
733
|
-
async selectHistoryCommit(commit) {
|
|
734
|
-
this.updateHistoryState({ selectedCommit: commit, commitDiff: null });
|
|
735
|
-
if (!commit)
|
|
736
|
-
return;
|
|
737
|
-
try {
|
|
738
|
-
await this.queue.enqueue(async () => {
|
|
739
|
-
const diff = await getCommitDiff(this.repoPath, commit.hash);
|
|
740
|
-
this.updateHistoryState({ commitDiff: diff });
|
|
741
|
-
});
|
|
742
|
-
}
|
|
743
|
-
catch (err) {
|
|
744
|
-
this.updateState({
|
|
745
|
-
error: `Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
746
|
-
});
|
|
747
|
-
}
|
|
748
|
-
}
|
|
749
|
-
/**
|
|
750
|
-
* Select a commit in compare view and load its diff.
|
|
751
|
-
*/
|
|
752
|
-
async selectCompareCommit(index) {
|
|
753
|
-
const compareDiff = this._compareState.compareDiff;
|
|
754
|
-
if (!compareDiff || index < 0 || index >= compareDiff.commits.length) {
|
|
755
|
-
this.updateCompareSelectionState({ type: null, index: 0, diff: null });
|
|
756
|
-
return;
|
|
757
|
-
}
|
|
758
|
-
const commit = compareDiff.commits[index];
|
|
759
|
-
this.updateCompareSelectionState({ type: 'commit', index, diff: null });
|
|
760
|
-
try {
|
|
761
|
-
await this.queue.enqueue(async () => {
|
|
762
|
-
const diff = await getCommitDiff(this.repoPath, commit.hash);
|
|
763
|
-
this.updateCompareSelectionState({ diff });
|
|
764
|
-
});
|
|
765
|
-
}
|
|
766
|
-
catch (err) {
|
|
767
|
-
this.updateState({
|
|
768
|
-
error: `Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
}
|
|
772
|
-
/**
|
|
773
|
-
* Select a file in compare view and show its diff.
|
|
774
|
-
*/
|
|
775
|
-
selectCompareFile(index) {
|
|
776
|
-
const compareDiff = this._compareState.compareDiff;
|
|
777
|
-
if (!compareDiff || index < 0 || index >= compareDiff.files.length) {
|
|
778
|
-
this.updateCompareSelectionState({ type: null, index: 0, diff: null });
|
|
779
|
-
return;
|
|
780
|
-
}
|
|
781
|
-
const file = compareDiff.files[index];
|
|
782
|
-
this.updateCompareSelectionState({ type: 'file', index, diff: file.diff });
|
|
783
|
-
}
|
|
784
41
|
}
|
|
785
42
|
// Registry of managers per repo path
|
|
786
43
|
const managerRegistry = new Map();
|