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.
Files changed (46) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/.github/workflows/release.yml +8 -0
  5. package/README.md +43 -35
  6. package/bun.lock +82 -3
  7. package/dist/App.js +555 -552
  8. package/dist/FollowMode.js +85 -0
  9. package/dist/KeyBindings.js +228 -0
  10. package/dist/MouseHandlers.js +192 -0
  11. package/dist/core/ExplorerStateManager.js +423 -78
  12. package/dist/core/GitStateManager.js +260 -119
  13. package/dist/git/diff.js +102 -17
  14. package/dist/git/status.js +16 -54
  15. package/dist/git/test-helpers.js +67 -0
  16. package/dist/index.js +60 -53
  17. package/dist/ipc/CommandClient.js +6 -7
  18. package/dist/state/UIState.js +39 -4
  19. package/dist/ui/PaneRenderers.js +76 -0
  20. package/dist/ui/modals/FileFinder.js +193 -0
  21. package/dist/ui/modals/HotkeysModal.js +12 -3
  22. package/dist/ui/modals/ThemePicker.js +1 -2
  23. package/dist/ui/widgets/CommitPanel.js +1 -1
  24. package/dist/ui/widgets/CompareListView.js +123 -80
  25. package/dist/ui/widgets/DiffView.js +228 -180
  26. package/dist/ui/widgets/ExplorerContent.js +15 -28
  27. package/dist/ui/widgets/ExplorerView.js +148 -43
  28. package/dist/ui/widgets/FileList.js +62 -95
  29. package/dist/ui/widgets/FlatFileList.js +65 -0
  30. package/dist/ui/widgets/Footer.js +25 -11
  31. package/dist/ui/widgets/Header.js +17 -52
  32. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  33. package/dist/utils/ansiTruncate.js +0 -1
  34. package/dist/utils/displayRows.js +101 -21
  35. package/dist/utils/fileCategories.js +37 -0
  36. package/dist/utils/fileTree.js +148 -0
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +15 -0
  40. package/metrics/.gitkeep +0 -0
  41. package/metrics/v0.2.1.json +268 -0
  42. package/metrics/v0.2.2.json +229 -0
  43. package/package.json +9 -2
  44. package/dist/utils/ansiToBlessed.js +0 -125
  45. package/dist/utils/mouseCoordinates.js +0 -165
  46. 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
- ignorer = null;
20
+ ignorers = new Map();
21
+ diffDebounceTimer = null;
20
22
  // Current state
21
23
  _state = {
22
24
  status: null,
23
25
  diff: null,
24
- stagedDiff: '',
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 an Ignore instance that can test paths.
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
- 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'));
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
- ig.add(fs.readFileSync(excludePath, 'utf-8'));
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 ig;
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.ignorer = this.loadGitignore();
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
- // 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;
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.ignorer = this.loadGitignore();
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(() => this.doRefresh());
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 all diffs atomically
196
- const [allStagedDiff, allUnstagedDiff] = await Promise.all([
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
- 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
- }
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
- stagedDiff: allStagedDiff.raw,
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
- async selectFile(file) {
325
+ selectFile(file) {
248
326
  this.updateState({ selectedFile: file });
249
327
  if (!this._state.status?.isRepo)
250
328
  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 });
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
- else {
263
- const allDiff = await getStagedDiff(this.repoPath);
264
- this.updateState({ diff: allDiff });
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 with optimistic update.
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.scheduleRefresh();
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 with optimistic update.
405
+ * Unstage a file.
295
406
  */
296
407
  async unstage(file) {
297
- // Optimistic update
298
- const currentStatus = this._state.status;
299
- if (currentStatus) {
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
- status: {
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(() => unstageFile(this.repoPath, file.path));
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 unstage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
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(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
- });
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
- const commits = await this.queue.enqueue(() => getCommitHistory(this.repoPath, count));
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
  */