diffstalker 0.2.2 → 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.
Files changed (47) hide show
  1. package/.dependency-cruiser.cjs +2 -2
  2. package/dist/App.js +299 -664
  3. package/dist/KeyBindings.js +125 -39
  4. package/dist/ModalController.js +166 -0
  5. package/dist/MouseHandlers.js +43 -25
  6. package/dist/NavigationController.js +290 -0
  7. package/dist/StagingOperations.js +199 -0
  8. package/dist/config.js +39 -0
  9. package/dist/core/CompareManager.js +134 -0
  10. package/dist/core/ExplorerStateManager.js +27 -40
  11. package/dist/core/GitStateManager.js +28 -630
  12. package/dist/core/HistoryManager.js +72 -0
  13. package/dist/core/RemoteOperationManager.js +109 -0
  14. package/dist/core/WorkingTreeManager.js +412 -0
  15. package/dist/git/status.js +95 -0
  16. package/dist/index.js +59 -54
  17. package/dist/state/FocusRing.js +40 -0
  18. package/dist/state/UIState.js +82 -48
  19. package/dist/types/remote.js +5 -0
  20. package/dist/ui/PaneRenderers.js +11 -4
  21. package/dist/ui/modals/BaseBranchPicker.js +4 -7
  22. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  23. package/dist/ui/modals/DiscardConfirm.js +4 -7
  24. package/dist/ui/modals/FileFinder.js +33 -27
  25. package/dist/ui/modals/HotkeysModal.js +32 -13
  26. package/dist/ui/modals/Modal.js +1 -0
  27. package/dist/ui/modals/RepoPicker.js +109 -0
  28. package/dist/ui/modals/ThemePicker.js +4 -7
  29. package/dist/ui/widgets/CommitPanel.js +52 -14
  30. package/dist/ui/widgets/CompareListView.js +1 -11
  31. package/dist/ui/widgets/DiffView.js +2 -27
  32. package/dist/ui/widgets/ExplorerContent.js +1 -4
  33. package/dist/ui/widgets/ExplorerView.js +1 -11
  34. package/dist/ui/widgets/FileList.js +2 -8
  35. package/dist/ui/widgets/Footer.js +1 -0
  36. package/dist/ui/widgets/Header.js +37 -3
  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.3.json +243 -0
  45. package/metrics/v0.2.4.json +236 -0
  46. package/package.json +5 -2
  47. package/dist/utils/layoutCalculations.js +0 -100
@@ -1,645 +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, } 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
- };
32
- _compareState = {
33
- compareDiff: null,
34
- compareBaseBranch: null,
35
- compareLoading: false,
36
- compareError: null,
37
- };
38
- _historyState = {
39
- commits: [],
40
- selectedCommit: null,
41
- commitDiff: null,
42
- isLoading: false,
43
- };
44
- _compareSelectionState = {
45
- type: null,
46
- index: 0,
47
- diff: null,
48
- };
49
21
  constructor(repoPath) {
50
- super();
51
22
  this.repoPath = repoPath;
52
- this.queue = getQueueForRepo(repoPath);
53
- }
54
- get state() {
55
- return this._state;
56
- }
57
- get compareState() {
58
- return this._compareState;
59
- }
60
- get historyState() {
61
- return this._historyState;
62
- }
63
- get compareSelectionState() {
64
- return this._compareSelectionState;
65
- }
66
- updateState(partial) {
67
- this._state = { ...this._state, ...partial };
68
- this.emit('state-change', this._state);
69
- }
70
- updateCompareState(partial) {
71
- this._compareState = { ...this._compareState, ...partial };
72
- this.emit('compare-state-change', this._compareState);
73
- }
74
- updateHistoryState(partial) {
75
- this._historyState = { ...this._historyState, ...partial };
76
- this.emit('history-state-change', this._historyState);
77
- }
78
- updateCompareSelectionState(partial) {
79
- this._compareSelectionState = { ...this._compareSelectionState, ...partial };
80
- this.emit('compare-selection-change', this._compareSelectionState);
81
- }
82
- /**
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).
86
- */
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'));
95
- }
96
- const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
97
- if (fs.existsSync(excludePath)) {
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
124
- }
125
- return ignorers;
126
- }
127
- /**
128
- * Start watching for file changes.
129
- */
130
- startWatching() {
131
- const gitDir = path.join(this.repoPath, '.git');
132
- if (!fs.existsSync(gitDir))
133
- return;
134
- // --- Git internals watcher ---
135
- const indexFile = path.join(gitDir, 'index');
136
- const headFile = path.join(gitDir, 'HEAD');
137
- const refsDir = path.join(gitDir, 'refs');
138
- const gitignorePath = path.join(this.repoPath, '.gitignore');
139
- // Git uses atomic writes (write to temp, then rename). We use polling
140
- // for reliable detection of these atomic operations.
141
- this.gitWatcher = watch([indexFile, headFile, refsDir, gitignorePath], {
142
- persistent: true,
143
- ignoreInitial: true,
144
- usePolling: true,
145
- interval: 100,
146
- });
147
- // --- Working directory watcher with gitignore support ---
148
- this.ignorers = this.loadGitignores();
149
- this.workingDirWatcher = watch(this.repoPath, {
150
- persistent: true,
151
- ignoreInitial: true,
152
- ignored: (filePath) => {
153
- const relativePath = path.relative(this.repoPath, filePath);
154
- if (!relativePath)
155
- return false;
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;
168
- },
169
- awaitWriteFinish: {
170
- stabilityThreshold: 100,
171
- pollInterval: 50,
172
- },
173
- });
174
- const scheduleRefresh = () => this.scheduleRefresh();
175
- this.gitWatcher.on('change', (filePath) => {
176
- // Reload gitignore patterns if .gitignore changed
177
- if (filePath === gitignorePath) {
178
- this.ignorers = this.loadGitignores();
179
- }
180
- scheduleRefresh();
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();
181
30
  });
182
- this.gitWatcher.on('add', scheduleRefresh);
183
- this.gitWatcher.on('unlink', scheduleRefresh);
184
- this.gitWatcher.on('error', (err) => {
185
- const message = err instanceof Error ? err.message : String(err);
186
- this.emit('error', `Git watcher error: ${message}`);
187
- });
188
- this.workingDirWatcher.on('change', scheduleRefresh);
189
- this.workingDirWatcher.on('add', scheduleRefresh);
190
- this.workingDirWatcher.on('unlink', scheduleRefresh);
191
- this.workingDirWatcher.on('error', (err) => {
192
- const message = err instanceof Error ? err.message : String(err);
193
- 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(),
194
35
  });
195
36
  }
196
- /**
197
- * Stop watching and clean up resources.
198
- */
199
37
  dispose() {
200
- if (this.diffDebounceTimer)
201
- clearTimeout(this.diffDebounceTimer);
202
- this.gitWatcher?.close();
203
- this.workingDirWatcher?.close();
38
+ this.workingTree.dispose();
204
39
  removeQueueForRepo(this.repoPath);
205
40
  }
206
- /**
207
- * Schedule a refresh (coalesced if one is already pending).
208
- * Also refreshes history and compare data if they were previously loaded.
209
- */
210
- 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
- });
241
- }
242
- /**
243
- * Immediately refresh git state.
244
- */
245
- async refresh() {
246
- await this.queue.enqueue(() => this.doRefresh());
247
- }
248
- async doRefresh() {
249
- this.updateState({ isLoading: true, error: null });
250
- try {
251
- const newStatus = await getStatus(this.repoPath);
252
- if (!newStatus.isRepo) {
253
- this.updateState({
254
- status: newStatus,
255
- diff: null,
256
- isLoading: false,
257
- error: 'Not a git repository',
258
- });
259
- return;
260
- }
261
- // Fetch unstaged and staged diffs in parallel
262
- const [allUnstagedDiff, allStagedDiff] = await Promise.all([
263
- getDiff(this.repoPath, undefined, false),
264
- getDiff(this.repoPath, undefined, true),
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
- };
271
- // Determine display diff based on selected file
272
- const { displayDiff, combinedFileDiffs } = await this.resolveFileDiffs(newStatus, allUnstagedDiff);
273
- // Batch status + diffs into a single update to avoid flicker
274
- this.updateState({
275
- status: newStatus,
276
- diff: displayDiff,
277
- combinedFileDiffs,
278
- hunkCounts,
279
- isLoading: false,
280
- });
281
- }
282
- catch (err) {
283
- this.updateState({
284
- isLoading: false,
285
- error: err instanceof Error ? err.message : 'Unknown error',
286
- });
287
- }
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
- }
320
- /**
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.
324
- */
325
- selectFile(file) {
326
- this.updateState({ selectedFile: file });
327
- if (!this._state.status?.isRepo)
328
- return;
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 });
364
- }
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
- });
374
- }
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
- }
388
- }
389
- /**
390
- * Stage a file.
391
- */
392
- async stage(file) {
393
- try {
394
- await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
395
- this.scheduleStatusRefresh();
396
- }
397
- catch (err) {
398
- await this.refresh();
399
- this.updateState({
400
- error: `Failed to stage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
401
- });
402
- }
403
- }
404
- /**
405
- * Unstage a file.
406
- */
407
- async unstage(file) {
408
- try {
409
- await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
410
- this.scheduleStatusRefresh();
411
- }
412
- catch (err) {
413
- await this.refresh();
414
- this.updateState({
415
- error: `Failed to unstage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
416
- });
417
- }
418
- }
419
- /**
420
- * Stage a single hunk via patch.
421
- */
422
- async stageHunk(patch) {
423
- try {
424
- await this.queue.enqueueMutation(async () => gitStageHunk(this.repoPath, patch));
425
- this.scheduleRefresh();
426
- }
427
- catch (err) {
428
- await this.refresh();
429
- this.updateState({
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)}`,
446
- });
447
- }
448
- }
449
- /**
450
- * Discard changes to a file.
451
- */
452
- async discard(file) {
453
- if (file.staged || file.status === 'untracked')
454
- return;
455
- try {
456
- await this.queue.enqueueMutation(() => gitDiscardChanges(this.repoPath, file.path));
457
- await this.refresh();
458
- }
459
- catch (err) {
460
- this.updateState({
461
- error: `Failed to discard ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
462
- });
463
- }
464
- }
465
- /**
466
- * Stage all files.
467
- */
468
- async stageAll() {
469
- try {
470
- await this.queue.enqueueMutation(() => gitStageAll(this.repoPath));
471
- await this.refresh();
472
- }
473
- catch (err) {
474
- this.updateState({
475
- error: `Failed to stage all: ${err instanceof Error ? err.message : String(err)}`,
476
- });
477
- }
478
- }
479
- /**
480
- * Unstage all files.
481
- */
482
- async unstageAll() {
483
- try {
484
- await this.queue.enqueueMutation(() => gitUnstageAll(this.repoPath));
485
- await this.refresh();
486
- }
487
- catch (err) {
488
- this.updateState({
489
- error: `Failed to unstage all: ${err instanceof Error ? err.message : String(err)}`,
490
- });
491
- }
492
- }
493
- /**
494
- * Create a commit.
495
- */
496
- async commit(message, amend = false) {
497
- try {
498
- await this.queue.enqueue(() => gitCommit(this.repoPath, message, amend));
499
- await this.refresh();
500
- }
501
- catch (err) {
502
- this.updateState({
503
- error: `Failed to commit: ${err instanceof Error ? err.message : String(err)}`,
504
- });
505
- }
506
- }
507
- /**
508
- * Get the HEAD commit message.
509
- */
510
- async getHeadCommitMessage() {
511
- return this.queue.enqueue(() => getHeadMessage(this.repoPath));
512
- }
513
- /**
514
- * Refresh compare diff.
515
- */
516
- async refreshCompareDiff(includeUncommitted = false) {
517
- this.updateCompareState({ compareLoading: true, compareError: null });
518
- try {
519
- await this.queue.enqueue(() => this.doRefreshCompareDiff(includeUncommitted));
520
- }
521
- catch (err) {
522
- this.updateCompareState({
523
- compareLoading: false,
524
- compareError: `Failed to load compare diff: ${err instanceof Error ? err.message : String(err)}`,
525
- });
526
- }
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
- }
552
- /**
553
- * Get candidate base branches for branch comparison.
554
- */
555
- async getCandidateBaseBranches() {
556
- return getCandidateBaseBranches(this.repoPath);
557
- }
558
- /**
559
- * Set the base branch for branch comparison and refresh.
560
- * Also saves the selection to the cache for future sessions.
561
- */
562
- async setCompareBaseBranch(branch, includeUncommitted = false) {
563
- this.updateCompareState({ compareBaseBranch: branch });
564
- setCachedBaseBranch(this.repoPath, branch);
565
- await this.refreshCompareDiff(includeUncommitted);
566
- }
567
- /**
568
- * Load commit history for the history view.
569
- */
570
- async loadHistory(count = 100) {
571
- this.updateHistoryState({ isLoading: true });
572
- try {
573
- await this.queue.enqueue(() => this.doLoadHistory(count));
574
- }
575
- catch (err) {
576
- this.updateHistoryState({ isLoading: false });
577
- this.updateState({
578
- error: `Failed to load history: ${err instanceof Error ? err.message : String(err)}`,
579
- });
580
- }
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
- }
589
- /**
590
- * Select a commit in history view and load its diff.
591
- */
592
- async selectHistoryCommit(commit) {
593
- this.updateHistoryState({ selectedCommit: commit, commitDiff: null });
594
- if (!commit)
595
- return;
596
- try {
597
- await this.queue.enqueue(async () => {
598
- const diff = await getCommitDiff(this.repoPath, commit.hash);
599
- this.updateHistoryState({ commitDiff: diff });
600
- });
601
- }
602
- catch (err) {
603
- this.updateState({
604
- error: `Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`,
605
- });
606
- }
607
- }
608
- /**
609
- * Select a commit in compare view and load its diff.
610
- */
611
- async selectCompareCommit(index) {
612
- const compareDiff = this._compareState.compareDiff;
613
- if (!compareDiff || index < 0 || index >= compareDiff.commits.length) {
614
- this.updateCompareSelectionState({ type: null, index: 0, diff: null });
615
- return;
616
- }
617
- const commit = compareDiff.commits[index];
618
- this.updateCompareSelectionState({ type: 'commit', index, diff: null });
619
- try {
620
- await this.queue.enqueue(async () => {
621
- const diff = await getCommitDiff(this.repoPath, commit.hash);
622
- this.updateCompareSelectionState({ diff });
623
- });
624
- }
625
- catch (err) {
626
- this.updateState({
627
- error: `Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`,
628
- });
629
- }
630
- }
631
- /**
632
- * Select a file in compare view and show its diff.
633
- */
634
- selectCompareFile(index) {
635
- const compareDiff = this._compareState.compareDiff;
636
- if (!compareDiff || index < 0 || index >= compareDiff.files.length) {
637
- this.updateCompareSelectionState({ type: null, index: 0, diff: null });
638
- return;
639
- }
640
- const file = compareDiff.files[index];
641
- this.updateCompareSelectionState({ type: 'file', index, diff: file.diff });
642
- }
643
41
  }
644
42
  // Registry of managers per repo path
645
43
  const managerRegistry = new Map();