diffstalker 0.1.6 → 0.2.0
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/.github/workflows/release.yml +5 -3
- package/CHANGELOG.md +36 -0
- package/bun.lock +378 -0
- package/dist/App.js +1162 -1
- package/dist/config.js +83 -2
- package/dist/core/ExplorerStateManager.js +266 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitOperationQueue.js +109 -1
- package/dist/core/GitStateManager.js +525 -1
- package/dist/git/diff.js +471 -10
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +237 -5
- package/dist/index.js +70 -16
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/services/commitService.js +22 -1
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +182 -0
- package/dist/themes.js +127 -1
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/HotkeysModal.js +209 -0
- package/dist/ui/modals/ThemePicker.js +107 -0
- package/dist/ui/widgets/CommitPanel.js +58 -0
- package/dist/ui/widgets/CompareListView.js +216 -0
- package/dist/ui/widgets/DiffView.js +279 -0
- package/dist/ui/widgets/ExplorerContent.js +102 -0
- package/dist/ui/widgets/ExplorerView.js +95 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +46 -0
- package/dist/ui/widgets/Header.js +111 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/ansiToBlessed.js +125 -0
- package/dist/utils/ansiTruncate.js +108 -0
- package/dist/utils/baseBranchCache.js +44 -2
- package/dist/utils/commitFormat.js +38 -1
- package/dist/utils/diffFilters.js +21 -1
- package/dist/utils/diffRowCalculations.js +113 -1
- package/dist/utils/displayRows.js +351 -2
- package/dist/utils/explorerDisplayRows.js +169 -0
- package/dist/utils/fileCategories.js +26 -1
- package/dist/utils/formatDate.js +39 -1
- package/dist/utils/formatPath.js +58 -1
- package/dist/utils/languageDetection.js +236 -0
- package/dist/utils/layoutCalculations.js +98 -1
- package/dist/utils/lineBreaking.js +88 -5
- package/dist/utils/mouseCoordinates.js +165 -1
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/rowCalculations.js +246 -4
- package/dist/utils/wordDiff.js +50 -0
- package/package.json +15 -19
- package/dist/components/BaseBranchPicker.js +0 -1
- package/dist/components/BottomPane.js +0 -1
- package/dist/components/CommitPanel.js +0 -1
- package/dist/components/CompareListView.js +0 -1
- package/dist/components/ExplorerContentView.js +0 -3
- package/dist/components/ExplorerView.js +0 -1
- package/dist/components/FileList.js +0 -1
- package/dist/components/Footer.js +0 -1
- package/dist/components/Header.js +0 -1
- package/dist/components/HistoryView.js +0 -1
- package/dist/components/HotkeysModal.js +0 -1
- package/dist/components/Modal.js +0 -1
- package/dist/components/ScrollableList.js +0 -1
- package/dist/components/ThemePicker.js +0 -1
- package/dist/components/TopPane.js +0 -1
- package/dist/components/UnifiedDiffView.js +0 -1
- package/dist/hooks/useCommitFlow.js +0 -1
- package/dist/hooks/useCompareState.js +0 -1
- package/dist/hooks/useExplorerState.js +0 -9
- package/dist/hooks/useGit.js +0 -1
- package/dist/hooks/useHistoryState.js +0 -1
- package/dist/hooks/useKeymap.js +0 -1
- package/dist/hooks/useLayout.js +0 -1
- package/dist/hooks/useMouse.js +0 -1
- package/dist/hooks/useTerminalSize.js +0 -1
- package/dist/hooks/useWatcher.js +0 -11
|
@@ -1 +1,525 @@
|
|
|
1
|
-
import*as
|
|
1
|
+
import * as path from 'node:path';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import { watch } from 'chokidar';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import ignore from 'ignore';
|
|
6
|
+
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';
|
|
9
|
+
import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
|
|
10
|
+
/**
|
|
11
|
+
* GitStateManager manages git state independent of React.
|
|
12
|
+
* It owns the operation queue, file watchers, and emits events on state changes.
|
|
13
|
+
*/
|
|
14
|
+
export class GitStateManager extends EventEmitter {
|
|
15
|
+
repoPath;
|
|
16
|
+
queue;
|
|
17
|
+
gitWatcher = null;
|
|
18
|
+
workingDirWatcher = null;
|
|
19
|
+
ignorer = null;
|
|
20
|
+
// Current state
|
|
21
|
+
_state = {
|
|
22
|
+
status: null,
|
|
23
|
+
diff: null,
|
|
24
|
+
stagedDiff: '',
|
|
25
|
+
selectedFile: null,
|
|
26
|
+
isLoading: false,
|
|
27
|
+
error: null,
|
|
28
|
+
};
|
|
29
|
+
_compareState = {
|
|
30
|
+
compareDiff: null,
|
|
31
|
+
compareBaseBranch: null,
|
|
32
|
+
compareLoading: false,
|
|
33
|
+
compareError: null,
|
|
34
|
+
};
|
|
35
|
+
_historyState = {
|
|
36
|
+
commits: [],
|
|
37
|
+
selectedCommit: null,
|
|
38
|
+
commitDiff: null,
|
|
39
|
+
isLoading: false,
|
|
40
|
+
};
|
|
41
|
+
_compareSelectionState = {
|
|
42
|
+
type: null,
|
|
43
|
+
index: 0,
|
|
44
|
+
diff: null,
|
|
45
|
+
};
|
|
46
|
+
constructor(repoPath) {
|
|
47
|
+
super();
|
|
48
|
+
this.repoPath = repoPath;
|
|
49
|
+
this.queue = getQueueForRepo(repoPath);
|
|
50
|
+
}
|
|
51
|
+
get state() {
|
|
52
|
+
return this._state;
|
|
53
|
+
}
|
|
54
|
+
get compareState() {
|
|
55
|
+
return this._compareState;
|
|
56
|
+
}
|
|
57
|
+
get historyState() {
|
|
58
|
+
return this._historyState;
|
|
59
|
+
}
|
|
60
|
+
get compareSelectionState() {
|
|
61
|
+
return this._compareSelectionState;
|
|
62
|
+
}
|
|
63
|
+
updateState(partial) {
|
|
64
|
+
this._state = { ...this._state, ...partial };
|
|
65
|
+
this.emit('state-change', this._state);
|
|
66
|
+
}
|
|
67
|
+
updateCompareState(partial) {
|
|
68
|
+
this._compareState = { ...this._compareState, ...partial };
|
|
69
|
+
this.emit('compare-state-change', this._compareState);
|
|
70
|
+
}
|
|
71
|
+
updateHistoryState(partial) {
|
|
72
|
+
this._historyState = { ...this._historyState, ...partial };
|
|
73
|
+
this.emit('history-state-change', this._historyState);
|
|
74
|
+
}
|
|
75
|
+
updateCompareSelectionState(partial) {
|
|
76
|
+
this._compareSelectionState = { ...this._compareSelectionState, ...partial };
|
|
77
|
+
this.emit('compare-selection-change', this._compareSelectionState);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Load gitignore patterns from .gitignore and .git/info/exclude.
|
|
81
|
+
* Returns an Ignore instance that can test paths.
|
|
82
|
+
*/
|
|
83
|
+
loadGitignore() {
|
|
84
|
+
const ig = ignore();
|
|
85
|
+
// Always ignore .git directory (has its own dedicated watcher)
|
|
86
|
+
ig.add('.git');
|
|
87
|
+
// Load .gitignore if it exists
|
|
88
|
+
const gitignorePath = path.join(this.repoPath, '.gitignore');
|
|
89
|
+
if (fs.existsSync(gitignorePath)) {
|
|
90
|
+
ig.add(fs.readFileSync(gitignorePath, 'utf-8'));
|
|
91
|
+
}
|
|
92
|
+
// Load .git/info/exclude if it exists (repo-specific ignores)
|
|
93
|
+
const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
|
|
94
|
+
if (fs.existsSync(excludePath)) {
|
|
95
|
+
ig.add(fs.readFileSync(excludePath, 'utf-8'));
|
|
96
|
+
}
|
|
97
|
+
return ig;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Start watching for file changes.
|
|
101
|
+
*/
|
|
102
|
+
startWatching() {
|
|
103
|
+
const gitDir = path.join(this.repoPath, '.git');
|
|
104
|
+
if (!fs.existsSync(gitDir))
|
|
105
|
+
return;
|
|
106
|
+
// --- Git internals watcher ---
|
|
107
|
+
const indexFile = path.join(gitDir, 'index');
|
|
108
|
+
const headFile = path.join(gitDir, 'HEAD');
|
|
109
|
+
const refsDir = path.join(gitDir, 'refs');
|
|
110
|
+
const gitignorePath = path.join(this.repoPath, '.gitignore');
|
|
111
|
+
// Git uses atomic writes (write to temp, then rename). We use polling
|
|
112
|
+
// for reliable detection of these atomic operations.
|
|
113
|
+
this.gitWatcher = watch([indexFile, headFile, refsDir, gitignorePath], {
|
|
114
|
+
persistent: true,
|
|
115
|
+
ignoreInitial: true,
|
|
116
|
+
usePolling: true,
|
|
117
|
+
interval: 100,
|
|
118
|
+
});
|
|
119
|
+
// --- Working directory watcher with gitignore support ---
|
|
120
|
+
this.ignorer = this.loadGitignore();
|
|
121
|
+
this.workingDirWatcher = watch(this.repoPath, {
|
|
122
|
+
persistent: true,
|
|
123
|
+
ignoreInitial: true,
|
|
124
|
+
ignored: (filePath) => {
|
|
125
|
+
// Get path relative to repo root
|
|
126
|
+
const relativePath = path.relative(this.repoPath, filePath);
|
|
127
|
+
// Don't ignore the repo root itself
|
|
128
|
+
if (!relativePath)
|
|
129
|
+
return false;
|
|
130
|
+
// Check against gitignore patterns
|
|
131
|
+
// When this returns true for a directory, chokidar won't recurse into it
|
|
132
|
+
return this.ignorer?.ignores(relativePath) ?? false;
|
|
133
|
+
},
|
|
134
|
+
awaitWriteFinish: {
|
|
135
|
+
stabilityThreshold: 100,
|
|
136
|
+
pollInterval: 50,
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
const scheduleRefresh = () => this.scheduleRefresh();
|
|
140
|
+
this.gitWatcher.on('change', (filePath) => {
|
|
141
|
+
// Reload gitignore patterns if .gitignore changed
|
|
142
|
+
if (filePath === gitignorePath) {
|
|
143
|
+
this.ignorer = this.loadGitignore();
|
|
144
|
+
}
|
|
145
|
+
scheduleRefresh();
|
|
146
|
+
});
|
|
147
|
+
this.gitWatcher.on('add', scheduleRefresh);
|
|
148
|
+
this.gitWatcher.on('unlink', scheduleRefresh);
|
|
149
|
+
this.gitWatcher.on('error', (err) => {
|
|
150
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
151
|
+
this.emit('error', `Git watcher error: ${message}`);
|
|
152
|
+
});
|
|
153
|
+
this.workingDirWatcher.on('change', scheduleRefresh);
|
|
154
|
+
this.workingDirWatcher.on('add', scheduleRefresh);
|
|
155
|
+
this.workingDirWatcher.on('unlink', scheduleRefresh);
|
|
156
|
+
this.workingDirWatcher.on('error', (err) => {
|
|
157
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
158
|
+
this.emit('error', `Working dir watcher error: ${message}`);
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Stop watching and clean up resources.
|
|
163
|
+
*/
|
|
164
|
+
dispose() {
|
|
165
|
+
this.gitWatcher?.close();
|
|
166
|
+
this.workingDirWatcher?.close();
|
|
167
|
+
removeQueueForRepo(this.repoPath);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Schedule a refresh (coalesced if one is already pending).
|
|
171
|
+
*/
|
|
172
|
+
scheduleRefresh() {
|
|
173
|
+
this.queue.scheduleRefresh(() => this.doRefresh());
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Immediately refresh git state.
|
|
177
|
+
*/
|
|
178
|
+
async refresh() {
|
|
179
|
+
await this.queue.enqueue(() => this.doRefresh());
|
|
180
|
+
}
|
|
181
|
+
async doRefresh() {
|
|
182
|
+
this.updateState({ isLoading: true, error: null });
|
|
183
|
+
try {
|
|
184
|
+
const newStatus = await getStatus(this.repoPath);
|
|
185
|
+
if (!newStatus.isRepo) {
|
|
186
|
+
this.updateState({
|
|
187
|
+
status: newStatus,
|
|
188
|
+
diff: null,
|
|
189
|
+
stagedDiff: '',
|
|
190
|
+
isLoading: false,
|
|
191
|
+
error: 'Not a git repository',
|
|
192
|
+
});
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
// Fetch all diffs atomically
|
|
196
|
+
const [allStagedDiff, allUnstagedDiff] = await Promise.all([
|
|
197
|
+
getStagedDiff(this.repoPath),
|
|
198
|
+
getDiff(this.repoPath, undefined, false),
|
|
199
|
+
]);
|
|
200
|
+
// Determine display diff based on selected file
|
|
201
|
+
let displayDiff;
|
|
202
|
+
const currentSelectedFile = this._state.selectedFile;
|
|
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
|
+
}
|
|
230
|
+
this.updateState({
|
|
231
|
+
status: newStatus,
|
|
232
|
+
diff: displayDiff,
|
|
233
|
+
stagedDiff: allStagedDiff.raw,
|
|
234
|
+
isLoading: false,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
catch (err) {
|
|
238
|
+
this.updateState({
|
|
239
|
+
isLoading: false,
|
|
240
|
+
error: err instanceof Error ? err.message : 'Unknown error',
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Select a file and update the diff display.
|
|
246
|
+
*/
|
|
247
|
+
async selectFile(file) {
|
|
248
|
+
this.updateState({ selectedFile: file });
|
|
249
|
+
if (!this._state.status?.isRepo)
|
|
250
|
+
return;
|
|
251
|
+
await this.queue.enqueue(async () => {
|
|
252
|
+
if (file) {
|
|
253
|
+
let fileDiff;
|
|
254
|
+
if (file.status === 'untracked') {
|
|
255
|
+
fileDiff = await getDiffForUntracked(this.repoPath, file.path);
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
fileDiff = await getDiff(this.repoPath, file.path, file.staged);
|
|
259
|
+
}
|
|
260
|
+
this.updateState({ diff: fileDiff });
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
const allDiff = await getStagedDiff(this.repoPath);
|
|
264
|
+
this.updateState({ diff: allDiff });
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Stage a file with optimistic update.
|
|
270
|
+
*/
|
|
271
|
+
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
|
+
try {
|
|
283
|
+
await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
|
|
284
|
+
this.scheduleRefresh();
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
await this.refresh();
|
|
288
|
+
this.updateState({
|
|
289
|
+
error: `Failed to stage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Unstage a file with optimistic update.
|
|
295
|
+
*/
|
|
296
|
+
async unstage(file) {
|
|
297
|
+
// Optimistic update
|
|
298
|
+
const currentStatus = this._state.status;
|
|
299
|
+
if (currentStatus) {
|
|
300
|
+
this.updateState({
|
|
301
|
+
status: {
|
|
302
|
+
...currentStatus,
|
|
303
|
+
files: currentStatus.files.map((f) => f.path === file.path && f.staged ? { ...f, staged: false } : f),
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
|
|
309
|
+
this.scheduleRefresh();
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
await this.refresh();
|
|
313
|
+
this.updateState({
|
|
314
|
+
error: `Failed to unstage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Discard changes to a file.
|
|
320
|
+
*/
|
|
321
|
+
async discard(file) {
|
|
322
|
+
if (file.staged || file.status === 'untracked')
|
|
323
|
+
return;
|
|
324
|
+
try {
|
|
325
|
+
await this.queue.enqueueMutation(() => gitDiscardChanges(this.repoPath, file.path));
|
|
326
|
+
await this.refresh();
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
this.updateState({
|
|
330
|
+
error: `Failed to discard ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Stage all files.
|
|
336
|
+
*/
|
|
337
|
+
async stageAll() {
|
|
338
|
+
try {
|
|
339
|
+
await this.queue.enqueueMutation(() => gitStageAll(this.repoPath));
|
|
340
|
+
await this.refresh();
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
this.updateState({
|
|
344
|
+
error: `Failed to stage all: ${err instanceof Error ? err.message : String(err)}`,
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Unstage all files.
|
|
350
|
+
*/
|
|
351
|
+
async unstageAll() {
|
|
352
|
+
try {
|
|
353
|
+
await this.queue.enqueueMutation(() => gitUnstageAll(this.repoPath));
|
|
354
|
+
await this.refresh();
|
|
355
|
+
}
|
|
356
|
+
catch (err) {
|
|
357
|
+
this.updateState({
|
|
358
|
+
error: `Failed to unstage all: ${err instanceof Error ? err.message : String(err)}`,
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Create a commit.
|
|
364
|
+
*/
|
|
365
|
+
async commit(message, amend = false) {
|
|
366
|
+
try {
|
|
367
|
+
await this.queue.enqueue(() => gitCommit(this.repoPath, message, amend));
|
|
368
|
+
await this.refresh();
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
this.updateState({
|
|
372
|
+
error: `Failed to commit: ${err instanceof Error ? err.message : String(err)}`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Get the HEAD commit message.
|
|
378
|
+
*/
|
|
379
|
+
async getHeadCommitMessage() {
|
|
380
|
+
return this.queue.enqueue(() => getHeadMessage(this.repoPath));
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* Refresh compare diff.
|
|
384
|
+
*/
|
|
385
|
+
async refreshCompareDiff(includeUncommitted = false) {
|
|
386
|
+
this.updateCompareState({ compareLoading: true, compareError: null });
|
|
387
|
+
try {
|
|
388
|
+
await this.queue.enqueue(async () => {
|
|
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
|
+
});
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
this.updateCompareState({
|
|
412
|
+
compareLoading: false,
|
|
413
|
+
compareError: `Failed to load compare diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Get candidate base branches for branch comparison.
|
|
419
|
+
*/
|
|
420
|
+
async getCandidateBaseBranches() {
|
|
421
|
+
return getCandidateBaseBranches(this.repoPath);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Set the base branch for branch comparison and refresh.
|
|
425
|
+
* Also saves the selection to the cache for future sessions.
|
|
426
|
+
*/
|
|
427
|
+
async setCompareBaseBranch(branch, includeUncommitted = false) {
|
|
428
|
+
this.updateCompareState({ compareBaseBranch: branch });
|
|
429
|
+
setCachedBaseBranch(this.repoPath, branch);
|
|
430
|
+
await this.refreshCompareDiff(includeUncommitted);
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Load commit history for the history view.
|
|
434
|
+
*/
|
|
435
|
+
async loadHistory(count = 100) {
|
|
436
|
+
this.updateHistoryState({ isLoading: true });
|
|
437
|
+
try {
|
|
438
|
+
const commits = await this.queue.enqueue(() => getCommitHistory(this.repoPath, count));
|
|
439
|
+
this.updateHistoryState({ commits, isLoading: false });
|
|
440
|
+
}
|
|
441
|
+
catch (err) {
|
|
442
|
+
this.updateHistoryState({ isLoading: false });
|
|
443
|
+
this.updateState({
|
|
444
|
+
error: `Failed to load history: ${err instanceof Error ? err.message : String(err)}`,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Select a commit in history view and load its diff.
|
|
450
|
+
*/
|
|
451
|
+
async selectHistoryCommit(commit) {
|
|
452
|
+
this.updateHistoryState({ selectedCommit: commit, commitDiff: null });
|
|
453
|
+
if (!commit)
|
|
454
|
+
return;
|
|
455
|
+
try {
|
|
456
|
+
await this.queue.enqueue(async () => {
|
|
457
|
+
const diff = await getCommitDiff(this.repoPath, commit.hash);
|
|
458
|
+
this.updateHistoryState({ commitDiff: diff });
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
this.updateState({
|
|
463
|
+
error: `Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* Select a commit in compare view and load its diff.
|
|
469
|
+
*/
|
|
470
|
+
async selectCompareCommit(index) {
|
|
471
|
+
const compareDiff = this._compareState.compareDiff;
|
|
472
|
+
if (!compareDiff || index < 0 || index >= compareDiff.commits.length) {
|
|
473
|
+
this.updateCompareSelectionState({ type: null, index: 0, diff: null });
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const commit = compareDiff.commits[index];
|
|
477
|
+
this.updateCompareSelectionState({ type: 'commit', index, diff: null });
|
|
478
|
+
try {
|
|
479
|
+
await this.queue.enqueue(async () => {
|
|
480
|
+
const diff = await getCommitDiff(this.repoPath, commit.hash);
|
|
481
|
+
this.updateCompareSelectionState({ diff });
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
catch (err) {
|
|
485
|
+
this.updateState({
|
|
486
|
+
error: `Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Select a file in compare view and show its diff.
|
|
492
|
+
*/
|
|
493
|
+
selectCompareFile(index) {
|
|
494
|
+
const compareDiff = this._compareState.compareDiff;
|
|
495
|
+
if (!compareDiff || index < 0 || index >= compareDiff.files.length) {
|
|
496
|
+
this.updateCompareSelectionState({ type: null, index: 0, diff: null });
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const file = compareDiff.files[index];
|
|
500
|
+
this.updateCompareSelectionState({ type: 'file', index, diff: file.diff });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
// Registry of managers per repo path
|
|
504
|
+
const managerRegistry = new Map();
|
|
505
|
+
/**
|
|
506
|
+
* Get the state manager for a specific repository.
|
|
507
|
+
*/
|
|
508
|
+
export function getManagerForRepo(repoPath) {
|
|
509
|
+
let manager = managerRegistry.get(repoPath);
|
|
510
|
+
if (!manager) {
|
|
511
|
+
manager = new GitStateManager(repoPath);
|
|
512
|
+
managerRegistry.set(repoPath, manager);
|
|
513
|
+
}
|
|
514
|
+
return manager;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Remove a manager from the registry.
|
|
518
|
+
*/
|
|
519
|
+
export function removeManagerForRepo(repoPath) {
|
|
520
|
+
const manager = managerRegistry.get(repoPath);
|
|
521
|
+
if (manager) {
|
|
522
|
+
manager.dispose();
|
|
523
|
+
managerRegistry.delete(repoPath);
|
|
524
|
+
}
|
|
525
|
+
}
|