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
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import * as path from 'node:path';
|
|
2
2
|
import * as fs from 'node:fs';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
3
4
|
import { watch } from 'chokidar';
|
|
4
5
|
import { EventEmitter } from 'node:events';
|
|
5
6
|
import ignore from 'ignore';
|
|
6
7
|
import { getQueueForRepo, removeQueueForRepo } from './GitOperationQueue.js';
|
|
7
|
-
import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, } from '../git/status.js';
|
|
8
|
-
import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, } from '../git/diff.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';
|
|
9
|
+
import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, countHunksPerFile, } from '../git/diff.js';
|
|
9
10
|
import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
|
|
10
11
|
/**
|
|
11
12
|
* GitStateManager manages git state independent of React.
|
|
@@ -16,15 +17,17 @@ export class GitStateManager extends EventEmitter {
|
|
|
16
17
|
queue;
|
|
17
18
|
gitWatcher = null;
|
|
18
19
|
workingDirWatcher = null;
|
|
19
|
-
|
|
20
|
+
ignorers = new Map();
|
|
21
|
+
diffDebounceTimer = null;
|
|
20
22
|
// Current state
|
|
21
23
|
_state = {
|
|
22
24
|
status: null,
|
|
23
25
|
diff: null,
|
|
24
|
-
|
|
26
|
+
combinedFileDiffs: null,
|
|
25
27
|
selectedFile: null,
|
|
26
28
|
isLoading: false,
|
|
27
29
|
error: null,
|
|
30
|
+
hunkCounts: null,
|
|
28
31
|
};
|
|
29
32
|
_compareState = {
|
|
30
33
|
compareDiff: null,
|
|
@@ -77,24 +80,49 @@ export class GitStateManager extends EventEmitter {
|
|
|
77
80
|
this.emit('compare-selection-change', this._compareSelectionState);
|
|
78
81
|
}
|
|
79
82
|
/**
|
|
80
|
-
* Load gitignore patterns from .gitignore and .git/info/exclude.
|
|
81
|
-
* Returns
|
|
83
|
+
* Load gitignore patterns from all .gitignore files and .git/info/exclude.
|
|
84
|
+
* Returns a Map of directory → Ignore instance, where each instance handles
|
|
85
|
+
* patterns relative to its own directory (matching how git scopes .gitignore files).
|
|
82
86
|
*/
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
if (fs.existsSync(
|
|
90
|
-
|
|
87
|
+
loadGitignores() {
|
|
88
|
+
const ignorers = new Map();
|
|
89
|
+
// Root ignorer: .git dir + root .gitignore + .git/info/exclude
|
|
90
|
+
const rootIg = ignore();
|
|
91
|
+
rootIg.add('.git');
|
|
92
|
+
const rootGitignorePath = path.join(this.repoPath, '.gitignore');
|
|
93
|
+
if (fs.existsSync(rootGitignorePath)) {
|
|
94
|
+
rootIg.add(fs.readFileSync(rootGitignorePath, 'utf-8'));
|
|
91
95
|
}
|
|
92
|
-
// Load .git/info/exclude if it exists (repo-specific ignores)
|
|
93
96
|
const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
|
|
94
97
|
if (fs.existsSync(excludePath)) {
|
|
95
|
-
|
|
98
|
+
rootIg.add(fs.readFileSync(excludePath, 'utf-8'));
|
|
99
|
+
}
|
|
100
|
+
ignorers.set('', rootIg);
|
|
101
|
+
// Find all nested .gitignore files using git ls-files
|
|
102
|
+
try {
|
|
103
|
+
const output = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '**/.gitignore'], { cwd: this.repoPath, encoding: 'utf-8' });
|
|
104
|
+
for (const entry of output.split('\0')) {
|
|
105
|
+
if (!entry || entry === '.gitignore')
|
|
106
|
+
continue;
|
|
107
|
+
if (!entry.endsWith('.gitignore'))
|
|
108
|
+
continue;
|
|
109
|
+
const dir = path.dirname(entry);
|
|
110
|
+
const absPath = path.join(this.repoPath, entry);
|
|
111
|
+
try {
|
|
112
|
+
const content = fs.readFileSync(absPath, 'utf-8');
|
|
113
|
+
const ig = ignore();
|
|
114
|
+
ig.add(content);
|
|
115
|
+
ignorers.set(dir, ig);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
// Skip unreadable files
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// git ls-files failed — we still have the root ignorer
|
|
96
124
|
}
|
|
97
|
-
return
|
|
125
|
+
return ignorers;
|
|
98
126
|
}
|
|
99
127
|
/**
|
|
100
128
|
* Start watching for file changes.
|
|
@@ -117,19 +145,26 @@ export class GitStateManager extends EventEmitter {
|
|
|
117
145
|
interval: 100,
|
|
118
146
|
});
|
|
119
147
|
// --- Working directory watcher with gitignore support ---
|
|
120
|
-
this.
|
|
148
|
+
this.ignorers = this.loadGitignores();
|
|
121
149
|
this.workingDirWatcher = watch(this.repoPath, {
|
|
122
150
|
persistent: true,
|
|
123
151
|
ignoreInitial: true,
|
|
124
152
|
ignored: (filePath) => {
|
|
125
|
-
// Get path relative to repo root
|
|
126
153
|
const relativePath = path.relative(this.repoPath, filePath);
|
|
127
|
-
// Don't ignore the repo root itself
|
|
128
154
|
if (!relativePath)
|
|
129
155
|
return false;
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
156
|
+
// Walk ancestor directories from root to parent, checking each ignorer
|
|
157
|
+
const parts = relativePath.split('/');
|
|
158
|
+
for (let depth = 0; depth < parts.length; depth++) {
|
|
159
|
+
const dir = depth === 0 ? '' : parts.slice(0, depth).join('/');
|
|
160
|
+
const ig = this.ignorers.get(dir);
|
|
161
|
+
if (ig) {
|
|
162
|
+
const relToDir = depth === 0 ? relativePath : parts.slice(depth).join('/');
|
|
163
|
+
if (ig.ignores(relToDir))
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
133
168
|
},
|
|
134
169
|
awaitWriteFinish: {
|
|
135
170
|
stabilityThreshold: 100,
|
|
@@ -140,7 +175,7 @@ export class GitStateManager extends EventEmitter {
|
|
|
140
175
|
this.gitWatcher.on('change', (filePath) => {
|
|
141
176
|
// Reload gitignore patterns if .gitignore changed
|
|
142
177
|
if (filePath === gitignorePath) {
|
|
143
|
-
this.
|
|
178
|
+
this.ignorers = this.loadGitignores();
|
|
144
179
|
}
|
|
145
180
|
scheduleRefresh();
|
|
146
181
|
});
|
|
@@ -162,15 +197,47 @@ export class GitStateManager extends EventEmitter {
|
|
|
162
197
|
* Stop watching and clean up resources.
|
|
163
198
|
*/
|
|
164
199
|
dispose() {
|
|
200
|
+
if (this.diffDebounceTimer)
|
|
201
|
+
clearTimeout(this.diffDebounceTimer);
|
|
165
202
|
this.gitWatcher?.close();
|
|
166
203
|
this.workingDirWatcher?.close();
|
|
167
204
|
removeQueueForRepo(this.repoPath);
|
|
168
205
|
}
|
|
169
206
|
/**
|
|
170
207
|
* Schedule a refresh (coalesced if one is already pending).
|
|
208
|
+
* Also refreshes history and compare data if they were previously loaded.
|
|
171
209
|
*/
|
|
172
210
|
scheduleRefresh() {
|
|
173
|
-
this.queue.scheduleRefresh(() =>
|
|
211
|
+
this.queue.scheduleRefresh(async () => {
|
|
212
|
+
await this.doRefresh();
|
|
213
|
+
// Also refresh history if it was loaded (has commits)
|
|
214
|
+
if (this._historyState.commits.length > 0) {
|
|
215
|
+
await this.doLoadHistory();
|
|
216
|
+
}
|
|
217
|
+
// Also refresh compare if it was loaded (has a base branch set)
|
|
218
|
+
if (this._compareState.compareBaseBranch) {
|
|
219
|
+
await this.doRefreshCompareDiff(false);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Schedule a lightweight status-only refresh (no diff fetching).
|
|
225
|
+
* Used after stage/unstage where the diff view updates on file selection.
|
|
226
|
+
*/
|
|
227
|
+
scheduleStatusRefresh() {
|
|
228
|
+
this.queue.scheduleRefresh(async () => {
|
|
229
|
+
const newStatus = await getStatus(this.repoPath);
|
|
230
|
+
if (!newStatus.isRepo) {
|
|
231
|
+
this.updateState({
|
|
232
|
+
status: newStatus,
|
|
233
|
+
diff: null,
|
|
234
|
+
isLoading: false,
|
|
235
|
+
error: 'Not a git repository',
|
|
236
|
+
});
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
this.updateState({ status: newStatus, isLoading: false });
|
|
240
|
+
});
|
|
174
241
|
}
|
|
175
242
|
/**
|
|
176
243
|
* Immediately refresh git state.
|
|
@@ -186,51 +253,29 @@ export class GitStateManager extends EventEmitter {
|
|
|
186
253
|
this.updateState({
|
|
187
254
|
status: newStatus,
|
|
188
255
|
diff: null,
|
|
189
|
-
stagedDiff: '',
|
|
190
256
|
isLoading: false,
|
|
191
257
|
error: 'Not a git repository',
|
|
192
258
|
});
|
|
193
259
|
return;
|
|
194
260
|
}
|
|
195
|
-
// Fetch
|
|
196
|
-
const [
|
|
197
|
-
getStagedDiff(this.repoPath),
|
|
261
|
+
// Fetch unstaged and staged diffs in parallel
|
|
262
|
+
const [allUnstagedDiff, allStagedDiff] = await Promise.all([
|
|
198
263
|
getDiff(this.repoPath, undefined, false),
|
|
264
|
+
getDiff(this.repoPath, undefined, true),
|
|
199
265
|
]);
|
|
266
|
+
// Count hunks per file for the file list display
|
|
267
|
+
const hunkCounts = {
|
|
268
|
+
unstaged: countHunksPerFile(allUnstagedDiff.raw),
|
|
269
|
+
staged: countHunksPerFile(allStagedDiff.raw),
|
|
270
|
+
};
|
|
200
271
|
// Determine display diff based on selected file
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (currentSelectedFile) {
|
|
204
|
-
const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged);
|
|
205
|
-
if (currentFile) {
|
|
206
|
-
if (currentFile.status === 'untracked') {
|
|
207
|
-
displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
|
|
208
|
-
}
|
|
209
|
-
else {
|
|
210
|
-
displayDiff = await getDiff(this.repoPath, currentFile.path, currentFile.staged);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
else {
|
|
214
|
-
// File no longer exists - clear selection
|
|
215
|
-
displayDiff = allUnstagedDiff.raw ? allUnstagedDiff : allStagedDiff;
|
|
216
|
-
this.updateState({ selectedFile: null });
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
else {
|
|
220
|
-
if (allUnstagedDiff.raw) {
|
|
221
|
-
displayDiff = allUnstagedDiff;
|
|
222
|
-
}
|
|
223
|
-
else if (allStagedDiff.raw) {
|
|
224
|
-
displayDiff = allStagedDiff;
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
displayDiff = { raw: '', lines: [] };
|
|
228
|
-
}
|
|
229
|
-
}
|
|
272
|
+
const { displayDiff, combinedFileDiffs } = await this.resolveFileDiffs(newStatus, allUnstagedDiff);
|
|
273
|
+
// Batch status + diffs into a single update to avoid flicker
|
|
230
274
|
this.updateState({
|
|
231
275
|
status: newStatus,
|
|
232
276
|
diff: displayDiff,
|
|
233
|
-
|
|
277
|
+
combinedFileDiffs,
|
|
278
|
+
hunkCounts,
|
|
234
279
|
isLoading: false,
|
|
235
280
|
});
|
|
236
281
|
}
|
|
@@ -241,47 +286,113 @@ export class GitStateManager extends EventEmitter {
|
|
|
241
286
|
});
|
|
242
287
|
}
|
|
243
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Resolve display diff and combined diffs for the currently selected file.
|
|
291
|
+
*/
|
|
292
|
+
async resolveFileDiffs(newStatus, fallbackDiff) {
|
|
293
|
+
const currentSelectedFile = this._state.selectedFile;
|
|
294
|
+
if (!currentSelectedFile) {
|
|
295
|
+
return { displayDiff: fallbackDiff, combinedFileDiffs: null };
|
|
296
|
+
}
|
|
297
|
+
// Match by path + staged, falling back to path-only (handles staging state changes)
|
|
298
|
+
const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged) ?? newStatus.files.find((f) => f.path === currentSelectedFile.path);
|
|
299
|
+
if (!currentFile) {
|
|
300
|
+
this.updateState({ selectedFile: null });
|
|
301
|
+
return { displayDiff: fallbackDiff, combinedFileDiffs: null };
|
|
302
|
+
}
|
|
303
|
+
if (currentFile.status === 'untracked') {
|
|
304
|
+
const displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
|
|
305
|
+
return {
|
|
306
|
+
displayDiff,
|
|
307
|
+
combinedFileDiffs: { unstaged: displayDiff, staged: { raw: '', lines: [] } },
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
const [unstagedFileDiff, stagedFileDiff] = await Promise.all([
|
|
311
|
+
getDiff(this.repoPath, currentFile.path, false),
|
|
312
|
+
getDiff(this.repoPath, currentFile.path, true),
|
|
313
|
+
]);
|
|
314
|
+
const displayDiff = currentFile.staged ? stagedFileDiff : unstagedFileDiff;
|
|
315
|
+
return {
|
|
316
|
+
displayDiff,
|
|
317
|
+
combinedFileDiffs: { unstaged: unstagedFileDiff, staged: stagedFileDiff },
|
|
318
|
+
};
|
|
319
|
+
}
|
|
244
320
|
/**
|
|
245
321
|
* Select a file and update the diff display.
|
|
322
|
+
* The selection highlight updates immediately; the diff fetch is debounced
|
|
323
|
+
* so rapid arrow-key presses only spawn one git process for the final file.
|
|
246
324
|
*/
|
|
247
|
-
|
|
325
|
+
selectFile(file) {
|
|
248
326
|
this.updateState({ selectedFile: file });
|
|
249
327
|
if (!this._state.status?.isRepo)
|
|
250
328
|
return;
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
329
|
+
if (this.diffDebounceTimer) {
|
|
330
|
+
// Already cooling down — reset the timer and fetch when it expires
|
|
331
|
+
clearTimeout(this.diffDebounceTimer);
|
|
332
|
+
this.diffDebounceTimer = setTimeout(() => {
|
|
333
|
+
this.diffDebounceTimer = null;
|
|
334
|
+
this.fetchDiffForSelection();
|
|
335
|
+
}, 20);
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
// First call — fetch immediately, then start cooldown
|
|
339
|
+
this.fetchDiffForSelection();
|
|
340
|
+
this.diffDebounceTimer = setTimeout(() => {
|
|
341
|
+
this.diffDebounceTimer = null;
|
|
342
|
+
}, 20);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
fetchDiffForSelection() {
|
|
346
|
+
const file = this._state.selectedFile;
|
|
347
|
+
this.queue
|
|
348
|
+
.enqueue(async () => {
|
|
349
|
+
if (file !== this._state.selectedFile)
|
|
350
|
+
return;
|
|
351
|
+
await this.doFetchDiffForFile(file);
|
|
352
|
+
})
|
|
353
|
+
.catch((err) => {
|
|
354
|
+
this.updateState({
|
|
355
|
+
error: `Failed to load diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
async doFetchDiffForFile(file) {
|
|
360
|
+
if (!file) {
|
|
361
|
+
const allDiff = await getStagedDiff(this.repoPath);
|
|
362
|
+
if (this._state.selectedFile === null) {
|
|
363
|
+
this.updateState({ diff: allDiff, combinedFileDiffs: null });
|
|
261
364
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (file.status === 'untracked') {
|
|
368
|
+
const fileDiff = await getDiffForUntracked(this.repoPath, file.path);
|
|
369
|
+
if (file === this._state.selectedFile) {
|
|
370
|
+
this.updateState({
|
|
371
|
+
diff: fileDiff,
|
|
372
|
+
combinedFileDiffs: { unstaged: fileDiff, staged: { raw: '', lines: [] } },
|
|
373
|
+
});
|
|
265
374
|
}
|
|
266
|
-
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const [unstagedDiff, stagedDiff] = await Promise.all([
|
|
378
|
+
getDiff(this.repoPath, file.path, false),
|
|
379
|
+
getDiff(this.repoPath, file.path, true),
|
|
380
|
+
]);
|
|
381
|
+
if (file === this._state.selectedFile) {
|
|
382
|
+
const displayDiff = file.staged ? stagedDiff : unstagedDiff;
|
|
383
|
+
this.updateState({
|
|
384
|
+
diff: displayDiff,
|
|
385
|
+
combinedFileDiffs: { unstaged: unstagedDiff, staged: stagedDiff },
|
|
386
|
+
});
|
|
387
|
+
}
|
|
267
388
|
}
|
|
268
389
|
/**
|
|
269
|
-
* Stage a file
|
|
390
|
+
* Stage a file.
|
|
270
391
|
*/
|
|
271
392
|
async stage(file) {
|
|
272
|
-
// Optimistic update
|
|
273
|
-
const currentStatus = this._state.status;
|
|
274
|
-
if (currentStatus) {
|
|
275
|
-
this.updateState({
|
|
276
|
-
status: {
|
|
277
|
-
...currentStatus,
|
|
278
|
-
files: currentStatus.files.map((f) => f.path === file.path && !f.staged ? { ...f, staged: true } : f),
|
|
279
|
-
},
|
|
280
|
-
});
|
|
281
|
-
}
|
|
282
393
|
try {
|
|
283
394
|
await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
|
|
284
|
-
this.
|
|
395
|
+
this.scheduleStatusRefresh();
|
|
285
396
|
}
|
|
286
397
|
catch (err) {
|
|
287
398
|
await this.refresh();
|
|
@@ -291,27 +402,47 @@ export class GitStateManager extends EventEmitter {
|
|
|
291
402
|
}
|
|
292
403
|
}
|
|
293
404
|
/**
|
|
294
|
-
* Unstage a file
|
|
405
|
+
* Unstage a file.
|
|
295
406
|
*/
|
|
296
407
|
async unstage(file) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
408
|
+
try {
|
|
409
|
+
await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
|
|
410
|
+
this.scheduleStatusRefresh();
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
await this.refresh();
|
|
300
414
|
this.updateState({
|
|
301
|
-
|
|
302
|
-
...currentStatus,
|
|
303
|
-
files: currentStatus.files.map((f) => f.path === file.path && f.staged ? { ...f, staged: false } : f),
|
|
304
|
-
},
|
|
415
|
+
error: `Failed to unstage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
305
416
|
});
|
|
306
417
|
}
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Stage a single hunk via patch.
|
|
421
|
+
*/
|
|
422
|
+
async stageHunk(patch) {
|
|
307
423
|
try {
|
|
308
|
-
await this.queue.enqueueMutation(() =>
|
|
424
|
+
await this.queue.enqueueMutation(async () => gitStageHunk(this.repoPath, patch));
|
|
309
425
|
this.scheduleRefresh();
|
|
310
426
|
}
|
|
311
427
|
catch (err) {
|
|
312
428
|
await this.refresh();
|
|
313
429
|
this.updateState({
|
|
314
|
-
error: `Failed to
|
|
430
|
+
error: `Failed to stage hunk: ${err instanceof Error ? err.message : String(err)}`,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Unstage a single hunk via patch.
|
|
436
|
+
*/
|
|
437
|
+
async unstageHunk(patch) {
|
|
438
|
+
try {
|
|
439
|
+
await this.queue.enqueueMutation(async () => gitUnstageHunk(this.repoPath, patch));
|
|
440
|
+
this.scheduleRefresh();
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
await this.refresh();
|
|
444
|
+
this.updateState({
|
|
445
|
+
error: `Failed to unstage hunk: ${err instanceof Error ? err.message : String(err)}`,
|
|
315
446
|
});
|
|
316
447
|
}
|
|
317
448
|
}
|
|
@@ -385,27 +516,7 @@ export class GitStateManager extends EventEmitter {
|
|
|
385
516
|
async refreshCompareDiff(includeUncommitted = false) {
|
|
386
517
|
this.updateCompareState({ compareLoading: true, compareError: null });
|
|
387
518
|
try {
|
|
388
|
-
await this.queue.enqueue(
|
|
389
|
-
let base = this._compareState.compareBaseBranch;
|
|
390
|
-
if (!base) {
|
|
391
|
-
// Try cached value first, then fall back to default detection
|
|
392
|
-
base = getCachedBaseBranch(this.repoPath) ?? (await getDefaultBaseBranch(this.repoPath));
|
|
393
|
-
this.updateCompareState({ compareBaseBranch: base });
|
|
394
|
-
}
|
|
395
|
-
if (base) {
|
|
396
|
-
const diff = includeUncommitted
|
|
397
|
-
? await getCompareDiffWithUncommitted(this.repoPath, base)
|
|
398
|
-
: await getDiffBetweenRefs(this.repoPath, base);
|
|
399
|
-
this.updateCompareState({ compareDiff: diff, compareLoading: false });
|
|
400
|
-
}
|
|
401
|
-
else {
|
|
402
|
-
this.updateCompareState({
|
|
403
|
-
compareDiff: null,
|
|
404
|
-
compareLoading: false,
|
|
405
|
-
compareError: 'No base branch found',
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
});
|
|
519
|
+
await this.queue.enqueue(() => this.doRefreshCompareDiff(includeUncommitted));
|
|
409
520
|
}
|
|
410
521
|
catch (err) {
|
|
411
522
|
this.updateCompareState({
|
|
@@ -414,6 +525,30 @@ export class GitStateManager extends EventEmitter {
|
|
|
414
525
|
});
|
|
415
526
|
}
|
|
416
527
|
}
|
|
528
|
+
/**
|
|
529
|
+
* Internal: refresh compare diff (called within queue).
|
|
530
|
+
*/
|
|
531
|
+
async doRefreshCompareDiff(includeUncommitted) {
|
|
532
|
+
let base = this._compareState.compareBaseBranch;
|
|
533
|
+
if (!base) {
|
|
534
|
+
// Try cached value first, then fall back to default detection
|
|
535
|
+
base = getCachedBaseBranch(this.repoPath) ?? (await getDefaultBaseBranch(this.repoPath));
|
|
536
|
+
this.updateCompareState({ compareBaseBranch: base });
|
|
537
|
+
}
|
|
538
|
+
if (base) {
|
|
539
|
+
const diff = includeUncommitted
|
|
540
|
+
? await getCompareDiffWithUncommitted(this.repoPath, base)
|
|
541
|
+
: await getDiffBetweenRefs(this.repoPath, base);
|
|
542
|
+
this.updateCompareState({ compareDiff: diff, compareLoading: false });
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
this.updateCompareState({
|
|
546
|
+
compareDiff: null,
|
|
547
|
+
compareLoading: false,
|
|
548
|
+
compareError: 'No base branch found',
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
417
552
|
/**
|
|
418
553
|
* Get candidate base branches for branch comparison.
|
|
419
554
|
*/
|
|
@@ -435,8 +570,7 @@ export class GitStateManager extends EventEmitter {
|
|
|
435
570
|
async loadHistory(count = 100) {
|
|
436
571
|
this.updateHistoryState({ isLoading: true });
|
|
437
572
|
try {
|
|
438
|
-
|
|
439
|
-
this.updateHistoryState({ commits, isLoading: false });
|
|
573
|
+
await this.queue.enqueue(() => this.doLoadHistory(count));
|
|
440
574
|
}
|
|
441
575
|
catch (err) {
|
|
442
576
|
this.updateHistoryState({ isLoading: false });
|
|
@@ -445,6 +579,13 @@ export class GitStateManager extends EventEmitter {
|
|
|
445
579
|
});
|
|
446
580
|
}
|
|
447
581
|
}
|
|
582
|
+
/**
|
|
583
|
+
* Internal: load commit history (called within queue).
|
|
584
|
+
*/
|
|
585
|
+
async doLoadHistory(count = 100) {
|
|
586
|
+
const commits = await getCommitHistory(this.repoPath, count);
|
|
587
|
+
this.updateHistoryState({ commits, isLoading: false });
|
|
588
|
+
}
|
|
448
589
|
/**
|
|
449
590
|
* Select a commit in history view and load its diff.
|
|
450
591
|
*/
|