diffstalker 0.2.3 → 0.2.5

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.
Files changed (50) hide show
  1. package/.dependency-cruiser.cjs +2 -2
  2. package/.githooks/pre-push +2 -2
  3. package/.github/workflows/release.yml +3 -0
  4. package/CHANGELOG.md +6 -0
  5. package/dist/App.js +278 -758
  6. package/dist/KeyBindings.js +103 -91
  7. package/dist/ModalController.js +166 -0
  8. package/dist/MouseHandlers.js +37 -30
  9. package/dist/NavigationController.js +290 -0
  10. package/dist/StagingOperations.js +199 -0
  11. package/dist/config.js +39 -0
  12. package/dist/core/CompareManager.js +134 -0
  13. package/dist/core/ExplorerStateManager.js +7 -3
  14. package/dist/core/GitStateManager.js +28 -771
  15. package/dist/core/HistoryManager.js +72 -0
  16. package/dist/core/RemoteOperationManager.js +109 -0
  17. package/dist/core/WorkingTreeManager.js +412 -0
  18. package/dist/index.js +57 -57
  19. package/dist/state/FocusRing.js +40 -0
  20. package/dist/state/UIState.js +82 -48
  21. package/dist/ui/PaneRenderers.js +3 -6
  22. package/dist/ui/modals/BaseBranchPicker.js +4 -7
  23. package/dist/ui/modals/CommitActionConfirm.js +4 -4
  24. package/dist/ui/modals/DiscardConfirm.js +4 -7
  25. package/dist/ui/modals/FileFinder.js +3 -6
  26. package/dist/ui/modals/HotkeysModal.js +24 -21
  27. package/dist/ui/modals/Modal.js +1 -0
  28. package/dist/ui/modals/RepoPicker.js +109 -0
  29. package/dist/ui/modals/ThemePicker.js +4 -7
  30. package/dist/ui/widgets/CommitPanel.js +26 -94
  31. package/dist/ui/widgets/CompareListView.js +1 -11
  32. package/dist/ui/widgets/DiffView.js +2 -27
  33. package/dist/ui/widgets/ExplorerContent.js +1 -4
  34. package/dist/ui/widgets/ExplorerView.js +1 -11
  35. package/dist/ui/widgets/FileList.js +2 -8
  36. package/dist/ui/widgets/Footer.js +1 -0
  37. package/dist/utils/ansi.js +38 -0
  38. package/dist/utils/ansiTruncate.js +1 -5
  39. package/dist/utils/displayRows.js +72 -59
  40. package/dist/utils/fileCategories.js +7 -0
  41. package/dist/utils/fileResolution.js +23 -0
  42. package/dist/utils/languageDetection.js +3 -2
  43. package/dist/utils/logger.js +32 -0
  44. package/metrics/v0.2.4.json +236 -0
  45. package/metrics/v0.2.5.json +236 -0
  46. package/package.json +1 -1
  47. package/dist/ui/modals/BranchPicker.js +0 -157
  48. package/dist/ui/modals/SoftResetConfirm.js +0 -68
  49. package/dist/ui/modals/StashListModal.js +0 -98
  50. package/dist/utils/layoutCalculations.js +0 -100
@@ -1,786 +1,43 @@
1
- import * as path from 'node:path';
2
- import * as fs from 'node:fs';
3
- import { execFileSync } from 'node:child_process';
4
- import { watch } from 'chokidar';
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 { 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
- import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, countHunksPerFile, } from '../git/diff.js';
10
- import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
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
- * GitStateManager manages git state independent of React.
13
- * It owns the operation queue, file watchers, and emits events on state changes.
11
+ * Coordinates WorkingTreeManager, HistoryManager,
12
+ * CompareManager, and RemoteOperationManager.
13
+ * Sub-managers are public readonly — callers use them directly.
14
14
  */
15
- export class GitStateManager extends EventEmitter {
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
- this.queue = getQueueForRepo(repoPath);
60
- }
61
- get state() {
62
- return this._state;
63
- }
64
- get compareState() {
65
- return this._compareState;
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
- const scheduleRefresh = () => this.scheduleRefresh();
189
- this.gitWatcher.on('change', (filePath) => {
190
- // Reload gitignore patterns if .gitignore changed
191
- if (filePath === gitignorePath) {
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
- if (this.diffDebounceTimer)
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();