diffstalker 0.1.7 → 0.2.1

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 (74) hide show
  1. package/.github/workflows/release.yml +8 -0
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +89 -306
  4. package/dist/App.js +895 -520
  5. package/dist/FollowMode.js +85 -0
  6. package/dist/KeyBindings.js +178 -0
  7. package/dist/MouseHandlers.js +156 -0
  8. package/dist/core/ExplorerStateManager.js +632 -0
  9. package/dist/core/FilePathWatcher.js +133 -0
  10. package/dist/core/GitStateManager.js +221 -86
  11. package/dist/git/diff.js +4 -0
  12. package/dist/git/ignoreUtils.js +30 -0
  13. package/dist/git/status.js +2 -34
  14. package/dist/index.js +68 -53
  15. package/dist/ipc/CommandClient.js +165 -0
  16. package/dist/ipc/CommandServer.js +152 -0
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +195 -0
  19. package/dist/types/tabs.js +4 -0
  20. package/dist/ui/Layout.js +252 -0
  21. package/dist/ui/PaneRenderers.js +56 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/FileFinder.js +232 -0
  25. package/dist/ui/modals/HotkeysModal.js +209 -0
  26. package/dist/ui/modals/ThemePicker.js +107 -0
  27. package/dist/ui/widgets/CommitPanel.js +58 -0
  28. package/dist/ui/widgets/CompareListView.js +238 -0
  29. package/dist/ui/widgets/DiffView.js +281 -0
  30. package/dist/ui/widgets/ExplorerContent.js +89 -0
  31. package/dist/ui/widgets/ExplorerView.js +204 -0
  32. package/dist/ui/widgets/FileList.js +185 -0
  33. package/dist/ui/widgets/Footer.js +50 -0
  34. package/dist/ui/widgets/Header.js +68 -0
  35. package/dist/ui/widgets/HistoryView.js +69 -0
  36. package/dist/utils/displayRows.js +185 -6
  37. package/dist/utils/explorerDisplayRows.js +1 -1
  38. package/dist/utils/fileCategories.js +37 -0
  39. package/dist/utils/fileTree.js +148 -0
  40. package/dist/utils/languageDetection.js +56 -0
  41. package/dist/utils/pathUtils.js +27 -0
  42. package/dist/utils/wordDiff.js +50 -0
  43. package/eslint.metrics.js +16 -0
  44. package/metrics/.gitkeep +0 -0
  45. package/metrics/v0.2.1.json +268 -0
  46. package/package.json +14 -12
  47. package/dist/components/BaseBranchPicker.js +0 -60
  48. package/dist/components/BottomPane.js +0 -101
  49. package/dist/components/CommitPanel.js +0 -58
  50. package/dist/components/CompareListView.js +0 -110
  51. package/dist/components/ExplorerContentView.js +0 -80
  52. package/dist/components/ExplorerView.js +0 -37
  53. package/dist/components/FileList.js +0 -131
  54. package/dist/components/Footer.js +0 -6
  55. package/dist/components/Header.js +0 -107
  56. package/dist/components/HistoryView.js +0 -21
  57. package/dist/components/HotkeysModal.js +0 -108
  58. package/dist/components/Modal.js +0 -19
  59. package/dist/components/ScrollableList.js +0 -125
  60. package/dist/components/ThemePicker.js +0 -42
  61. package/dist/components/TopPane.js +0 -14
  62. package/dist/components/UnifiedDiffView.js +0 -115
  63. package/dist/hooks/useCommitFlow.js +0 -66
  64. package/dist/hooks/useCompareState.js +0 -123
  65. package/dist/hooks/useExplorerState.js +0 -248
  66. package/dist/hooks/useGit.js +0 -156
  67. package/dist/hooks/useHistoryState.js +0 -62
  68. package/dist/hooks/useKeymap.js +0 -167
  69. package/dist/hooks/useLayout.js +0 -154
  70. package/dist/hooks/useMouse.js +0 -87
  71. package/dist/hooks/useTerminalSize.js +0 -20
  72. package/dist/hooks/useWatcher.js +0 -137
  73. package/dist/utils/mouseCoordinates.js +0 -165
  74. package/dist/utils/rowCalculations.js +0 -209
@@ -0,0 +1,133 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { watch } from 'chokidar';
4
+ import { EventEmitter } from 'node:events';
5
+ import { ensureTargetDir } from '../config.js';
6
+ import { expandPath, getLastNonEmptyLine } from '../utils/pathUtils.js';
7
+ /**
8
+ * FilePathWatcher watches a target file and emits events when the path it contains changes.
9
+ * It supports append-only files by reading only the last non-empty line.
10
+ */
11
+ export class FilePathWatcher extends EventEmitter {
12
+ targetFile;
13
+ debug;
14
+ watcher = null;
15
+ debounceTimer = null;
16
+ lastReadPath = null;
17
+ _state = {
18
+ path: null,
19
+ lastUpdate: null,
20
+ rawContent: null,
21
+ sourceFile: null,
22
+ };
23
+ constructor(targetFile, debug = false) {
24
+ super();
25
+ this.targetFile = targetFile;
26
+ this.debug = debug;
27
+ this._state.sourceFile = targetFile;
28
+ }
29
+ get state() {
30
+ return this._state;
31
+ }
32
+ updateState(partial) {
33
+ this._state = { ...this._state, ...partial };
34
+ this.emit('path-change', this._state);
35
+ }
36
+ processContent(content) {
37
+ if (!content)
38
+ return null;
39
+ const expanded = expandPath(content);
40
+ return path.isAbsolute(expanded) ? expanded : path.resolve(expanded);
41
+ }
42
+ readTargetDebounced() {
43
+ if (this.debounceTimer) {
44
+ clearTimeout(this.debounceTimer);
45
+ }
46
+ this.debounceTimer = setTimeout(() => {
47
+ this.readTarget();
48
+ }, 100);
49
+ }
50
+ readTarget() {
51
+ try {
52
+ const raw = fs.readFileSync(this.targetFile, 'utf-8');
53
+ const content = getLastNonEmptyLine(raw);
54
+ if (content && content !== this.lastReadPath) {
55
+ const resolved = this.processContent(content);
56
+ const now = new Date();
57
+ if (this.debug && resolved) {
58
+ process.stderr.write(`[diffstalker ${now.toISOString()}] Path change detected\n`);
59
+ process.stderr.write(` Source file: ${this.targetFile}\n`);
60
+ process.stderr.write(` Raw content: "${content}"\n`);
61
+ process.stderr.write(` Previous: "${this.lastReadPath ?? '(none)'}"\n`);
62
+ process.stderr.write(` Resolved: "${resolved}"\n`);
63
+ }
64
+ this.lastReadPath = resolved;
65
+ this.updateState({
66
+ path: resolved,
67
+ lastUpdate: now,
68
+ rawContent: content,
69
+ });
70
+ }
71
+ }
72
+ catch {
73
+ // Ignore read errors
74
+ }
75
+ }
76
+ /**
77
+ * Start watching the target file.
78
+ */
79
+ start() {
80
+ // Ensure the directory exists
81
+ ensureTargetDir(this.targetFile);
82
+ // Create the file if it doesn't exist
83
+ if (!fs.existsSync(this.targetFile)) {
84
+ fs.writeFileSync(this.targetFile, '');
85
+ }
86
+ // Read initial value immediately (no debounce for first read)
87
+ try {
88
+ const raw = fs.readFileSync(this.targetFile, 'utf-8');
89
+ const content = getLastNonEmptyLine(raw);
90
+ if (content) {
91
+ const resolved = this.processContent(content);
92
+ const now = new Date();
93
+ if (this.debug && resolved) {
94
+ process.stderr.write(`[diffstalker ${now.toISOString()}] Initial path read\n`);
95
+ process.stderr.write(` Source file: ${this.targetFile}\n`);
96
+ process.stderr.write(` Raw content: "${content}"\n`);
97
+ process.stderr.write(` Resolved: "${resolved}"\n`);
98
+ }
99
+ this.lastReadPath = resolved;
100
+ this._state = {
101
+ path: resolved,
102
+ lastUpdate: now,
103
+ rawContent: content,
104
+ sourceFile: this.targetFile,
105
+ };
106
+ // Don't emit on initial read - caller should check state after start()
107
+ }
108
+ }
109
+ catch {
110
+ // Ignore read errors
111
+ }
112
+ // Watch for changes
113
+ this.watcher = watch(this.targetFile, {
114
+ persistent: true,
115
+ ignoreInitial: true,
116
+ });
117
+ this.watcher.on('change', () => this.readTargetDebounced());
118
+ this.watcher.on('add', () => this.readTargetDebounced());
119
+ }
120
+ /**
121
+ * Stop watching and clean up resources.
122
+ */
123
+ stop() {
124
+ if (this.debounceTimer) {
125
+ clearTimeout(this.debounceTimer);
126
+ this.debounceTimer = null;
127
+ }
128
+ if (this.watcher) {
129
+ this.watcher.close();
130
+ this.watcher = null;
131
+ }
132
+ }
133
+ }
@@ -1,9 +1,11 @@
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';
6
+ import ignore from 'ignore';
5
7
  import { getQueueForRepo, removeQueueForRepo } from './GitOperationQueue.js';
6
- import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, } from '../git/status.js';
8
+ import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, } from '../git/status.js';
7
9
  import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, } from '../git/diff.js';
8
10
  import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
9
11
  /**
@@ -15,11 +17,12 @@ export class GitStateManager extends EventEmitter {
15
17
  queue;
16
18
  gitWatcher = null;
17
19
  workingDirWatcher = null;
20
+ ignorers = new Map();
21
+ diffDebounceTimer = null;
18
22
  // Current state
19
23
  _state = {
20
24
  status: null,
21
25
  diff: null,
22
- stagedDiff: '',
23
26
  selectedFile: null,
24
27
  isLoading: false,
25
28
  error: null,
@@ -31,8 +34,10 @@ export class GitStateManager extends EventEmitter {
31
34
  compareError: null,
32
35
  };
33
36
  _historyState = {
37
+ commits: [],
34
38
  selectedCommit: null,
35
39
  commitDiff: null,
40
+ isLoading: false,
36
41
  };
37
42
  _compareSelectionState = {
38
43
  type: null,
@@ -72,6 +77,51 @@ export class GitStateManager extends EventEmitter {
72
77
  this._compareSelectionState = { ...this._compareSelectionState, ...partial };
73
78
  this.emit('compare-selection-change', this._compareSelectionState);
74
79
  }
80
+ /**
81
+ * Load gitignore patterns from all .gitignore files and .git/info/exclude.
82
+ * Returns a Map of directory → Ignore instance, where each instance handles
83
+ * patterns relative to its own directory (matching how git scopes .gitignore files).
84
+ */
85
+ loadGitignores() {
86
+ const ignorers = new Map();
87
+ // Root ignorer: .git dir + root .gitignore + .git/info/exclude
88
+ const rootIg = ignore();
89
+ rootIg.add('.git');
90
+ const rootGitignorePath = path.join(this.repoPath, '.gitignore');
91
+ if (fs.existsSync(rootGitignorePath)) {
92
+ rootIg.add(fs.readFileSync(rootGitignorePath, 'utf-8'));
93
+ }
94
+ const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
95
+ if (fs.existsSync(excludePath)) {
96
+ rootIg.add(fs.readFileSync(excludePath, 'utf-8'));
97
+ }
98
+ ignorers.set('', rootIg);
99
+ // Find all nested .gitignore files using git ls-files
100
+ try {
101
+ const output = execFileSync('git', ['ls-files', '-z', '--cached', '--others', '**/.gitignore'], { cwd: this.repoPath, encoding: 'utf-8' });
102
+ for (const entry of output.split('\0')) {
103
+ if (!entry || entry === '.gitignore')
104
+ continue;
105
+ if (!entry.endsWith('.gitignore'))
106
+ continue;
107
+ const dir = path.dirname(entry);
108
+ const absPath = path.join(this.repoPath, entry);
109
+ try {
110
+ const content = fs.readFileSync(absPath, 'utf-8');
111
+ const ig = ignore();
112
+ ig.add(content);
113
+ ignorers.set(dir, ig);
114
+ }
115
+ catch {
116
+ // Skip unreadable files
117
+ }
118
+ }
119
+ }
120
+ catch {
121
+ // git ls-files failed — we still have the root ignorer
122
+ }
123
+ return ignorers;
124
+ }
75
125
  /**
76
126
  * Start watching for file changes.
77
127
  */
@@ -79,55 +129,113 @@ export class GitStateManager extends EventEmitter {
79
129
  const gitDir = path.join(this.repoPath, '.git');
80
130
  if (!fs.existsSync(gitDir))
81
131
  return;
132
+ // --- Git internals watcher ---
82
133
  const indexFile = path.join(gitDir, 'index');
83
134
  const headFile = path.join(gitDir, 'HEAD');
84
135
  const refsDir = path.join(gitDir, 'refs');
85
- this.gitWatcher = watch([indexFile, headFile, refsDir], {
136
+ const gitignorePath = path.join(this.repoPath, '.gitignore');
137
+ // Git uses atomic writes (write to temp, then rename). We use polling
138
+ // for reliable detection of these atomic operations.
139
+ this.gitWatcher = watch([indexFile, headFile, refsDir, gitignorePath], {
86
140
  persistent: true,
87
141
  ignoreInitial: true,
88
- awaitWriteFinish: {
89
- stabilityThreshold: 100,
90
- pollInterval: 50,
91
- },
142
+ usePolling: true,
143
+ interval: 100,
92
144
  });
145
+ // --- Working directory watcher with gitignore support ---
146
+ this.ignorers = this.loadGitignores();
93
147
  this.workingDirWatcher = watch(this.repoPath, {
94
148
  persistent: true,
95
149
  ignoreInitial: true,
96
- ignored: [
97
- '**/node_modules/**',
98
- '**/.git/**',
99
- '**/dist/**',
100
- '**/build/**',
101
- '**/*.log',
102
- '**/.DS_Store',
103
- ],
150
+ ignored: (filePath) => {
151
+ const relativePath = path.relative(this.repoPath, filePath);
152
+ if (!relativePath)
153
+ return false;
154
+ // Walk ancestor directories from root to parent, checking each ignorer
155
+ const parts = relativePath.split('/');
156
+ for (let depth = 0; depth < parts.length; depth++) {
157
+ const dir = depth === 0 ? '' : parts.slice(0, depth).join('/');
158
+ const ig = this.ignorers.get(dir);
159
+ if (ig) {
160
+ const relToDir = depth === 0 ? relativePath : parts.slice(depth).join('/');
161
+ if (ig.ignores(relToDir))
162
+ return true;
163
+ }
164
+ }
165
+ return false;
166
+ },
104
167
  awaitWriteFinish: {
105
168
  stabilityThreshold: 100,
106
169
  pollInterval: 50,
107
170
  },
108
- depth: 10,
109
171
  });
110
172
  const scheduleRefresh = () => this.scheduleRefresh();
111
- this.gitWatcher.on('change', scheduleRefresh);
173
+ this.gitWatcher.on('change', (filePath) => {
174
+ // Reload gitignore patterns if .gitignore changed
175
+ if (filePath === gitignorePath) {
176
+ this.ignorers = this.loadGitignores();
177
+ }
178
+ scheduleRefresh();
179
+ });
112
180
  this.gitWatcher.on('add', scheduleRefresh);
113
181
  this.gitWatcher.on('unlink', scheduleRefresh);
182
+ this.gitWatcher.on('error', (err) => {
183
+ const message = err instanceof Error ? err.message : String(err);
184
+ this.emit('error', `Git watcher error: ${message}`);
185
+ });
114
186
  this.workingDirWatcher.on('change', scheduleRefresh);
115
187
  this.workingDirWatcher.on('add', scheduleRefresh);
116
188
  this.workingDirWatcher.on('unlink', scheduleRefresh);
189
+ this.workingDirWatcher.on('error', (err) => {
190
+ const message = err instanceof Error ? err.message : String(err);
191
+ this.emit('error', `Working dir watcher error: ${message}`);
192
+ });
117
193
  }
118
194
  /**
119
195
  * Stop watching and clean up resources.
120
196
  */
121
197
  dispose() {
198
+ if (this.diffDebounceTimer)
199
+ clearTimeout(this.diffDebounceTimer);
122
200
  this.gitWatcher?.close();
123
201
  this.workingDirWatcher?.close();
124
202
  removeQueueForRepo(this.repoPath);
125
203
  }
126
204
  /**
127
205
  * Schedule a refresh (coalesced if one is already pending).
206
+ * Also refreshes history and compare data if they were previously loaded.
128
207
  */
129
208
  scheduleRefresh() {
130
- this.queue.scheduleRefresh(() => this.doRefresh());
209
+ this.queue.scheduleRefresh(async () => {
210
+ await this.doRefresh();
211
+ // Also refresh history if it was loaded (has commits)
212
+ if (this._historyState.commits.length > 0) {
213
+ await this.doLoadHistory();
214
+ }
215
+ // Also refresh compare if it was loaded (has a base branch set)
216
+ if (this._compareState.compareBaseBranch) {
217
+ await this.doRefreshCompareDiff(false);
218
+ }
219
+ });
220
+ }
221
+ /**
222
+ * Schedule a lightweight status-only refresh (no diff fetching).
223
+ * Used after stage/unstage where the diff view updates on file selection.
224
+ */
225
+ scheduleStatusRefresh() {
226
+ this.queue.scheduleRefresh(async () => {
227
+ const newStatus = await getStatus(this.repoPath);
228
+ if (!newStatus.isRepo) {
229
+ this.updateState({
230
+ status: newStatus,
231
+ diff: null,
232
+ isLoading: false,
233
+ error: 'Not a git repository',
234
+ });
235
+ return;
236
+ }
237
+ this.updateState({ status: newStatus, isLoading: false });
238
+ });
131
239
  }
132
240
  /**
133
241
  * Immediately refresh git state.
@@ -143,17 +251,15 @@ export class GitStateManager extends EventEmitter {
143
251
  this.updateState({
144
252
  status: newStatus,
145
253
  diff: null,
146
- stagedDiff: '',
147
254
  isLoading: false,
148
255
  error: 'Not a git repository',
149
256
  });
150
257
  return;
151
258
  }
152
- // Fetch all diffs atomically
153
- const [allStagedDiff, allUnstagedDiff] = await Promise.all([
154
- getStagedDiff(this.repoPath),
155
- getDiff(this.repoPath, undefined, false),
156
- ]);
259
+ // Emit status immediately so the file list updates after a single git spawn
260
+ this.updateState({ status: newStatus });
261
+ // Fetch unstaged diff (updates diff view once complete)
262
+ const allUnstagedDiff = await getDiff(this.repoPath, undefined, false);
157
263
  // Determine display diff based on selected file
158
264
  let displayDiff;
159
265
  const currentSelectedFile = this._state.selectedFile;
@@ -168,26 +274,16 @@ export class GitStateManager extends EventEmitter {
168
274
  }
169
275
  }
170
276
  else {
171
- // File no longer exists - clear selection
172
- displayDiff = allUnstagedDiff.raw ? allUnstagedDiff : allStagedDiff;
277
+ // File no longer exists - clear selection, show unstaged diff
278
+ displayDiff = allUnstagedDiff;
173
279
  this.updateState({ selectedFile: null });
174
280
  }
175
281
  }
176
282
  else {
177
- if (allUnstagedDiff.raw) {
178
- displayDiff = allUnstagedDiff;
179
- }
180
- else if (allStagedDiff.raw) {
181
- displayDiff = allStagedDiff;
182
- }
183
- else {
184
- displayDiff = { raw: '', lines: [] };
185
- }
283
+ displayDiff = allUnstagedDiff;
186
284
  }
187
285
  this.updateState({
188
- status: newStatus,
189
286
  diff: displayDiff,
190
- stagedDiff: allStagedDiff.raw,
191
287
  isLoading: false,
192
288
  });
193
289
  }
@@ -200,12 +296,36 @@ export class GitStateManager extends EventEmitter {
200
296
  }
201
297
  /**
202
298
  * Select a file and update the diff display.
299
+ * The selection highlight updates immediately; the diff fetch is debounced
300
+ * so rapid arrow-key presses only spawn one git process for the final file.
203
301
  */
204
- async selectFile(file) {
302
+ selectFile(file) {
205
303
  this.updateState({ selectedFile: file });
206
304
  if (!this._state.status?.isRepo)
207
305
  return;
208
- await this.queue.enqueue(async () => {
306
+ if (this.diffDebounceTimer) {
307
+ // Already cooling down — reset the timer and fetch when it expires
308
+ clearTimeout(this.diffDebounceTimer);
309
+ this.diffDebounceTimer = setTimeout(() => {
310
+ this.diffDebounceTimer = null;
311
+ this.fetchDiffForSelection();
312
+ }, 20);
313
+ }
314
+ else {
315
+ // First call — fetch immediately, then start cooldown
316
+ this.fetchDiffForSelection();
317
+ this.diffDebounceTimer = setTimeout(() => {
318
+ this.diffDebounceTimer = null;
319
+ }, 20);
320
+ }
321
+ }
322
+ fetchDiffForSelection() {
323
+ const file = this._state.selectedFile;
324
+ this.queue
325
+ .enqueue(async () => {
326
+ // Selection changed while queued — skip stale fetch
327
+ if (file !== this._state.selectedFile)
328
+ return;
209
329
  if (file) {
210
330
  let fileDiff;
211
331
  if (file.status === 'untracked') {
@@ -214,31 +334,30 @@ export class GitStateManager extends EventEmitter {
214
334
  else {
215
335
  fileDiff = await getDiff(this.repoPath, file.path, file.staged);
216
336
  }
217
- this.updateState({ diff: fileDiff });
337
+ if (file === this._state.selectedFile) {
338
+ this.updateState({ diff: fileDiff });
339
+ }
218
340
  }
219
341
  else {
220
342
  const allDiff = await getStagedDiff(this.repoPath);
221
- this.updateState({ diff: allDiff });
343
+ if (this._state.selectedFile === null) {
344
+ this.updateState({ diff: allDiff });
345
+ }
222
346
  }
347
+ })
348
+ .catch((err) => {
349
+ this.updateState({
350
+ error: `Failed to load diff: ${err instanceof Error ? err.message : String(err)}`,
351
+ });
223
352
  });
224
353
  }
225
354
  /**
226
- * Stage a file with optimistic update.
355
+ * Stage a file.
227
356
  */
228
357
  async stage(file) {
229
- // Optimistic update
230
- const currentStatus = this._state.status;
231
- if (currentStatus) {
232
- this.updateState({
233
- status: {
234
- ...currentStatus,
235
- files: currentStatus.files.map((f) => f.path === file.path && !f.staged ? { ...f, staged: true } : f),
236
- },
237
- });
238
- }
239
358
  try {
240
359
  await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
241
- this.scheduleRefresh();
360
+ this.scheduleStatusRefresh();
242
361
  }
243
362
  catch (err) {
244
363
  await this.refresh();
@@ -248,22 +367,12 @@ export class GitStateManager extends EventEmitter {
248
367
  }
249
368
  }
250
369
  /**
251
- * Unstage a file with optimistic update.
370
+ * Unstage a file.
252
371
  */
253
372
  async unstage(file) {
254
- // Optimistic update
255
- const currentStatus = this._state.status;
256
- if (currentStatus) {
257
- this.updateState({
258
- status: {
259
- ...currentStatus,
260
- files: currentStatus.files.map((f) => f.path === file.path && f.staged ? { ...f, staged: false } : f),
261
- },
262
- });
263
- }
264
373
  try {
265
374
  await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
266
- this.scheduleRefresh();
375
+ this.scheduleStatusRefresh();
267
376
  }
268
377
  catch (err) {
269
378
  await this.refresh();
@@ -342,27 +451,7 @@ export class GitStateManager extends EventEmitter {
342
451
  async refreshCompareDiff(includeUncommitted = false) {
343
452
  this.updateCompareState({ compareLoading: true, compareError: null });
344
453
  try {
345
- await this.queue.enqueue(async () => {
346
- let base = this._compareState.compareBaseBranch;
347
- if (!base) {
348
- // Try cached value first, then fall back to default detection
349
- base = getCachedBaseBranch(this.repoPath) ?? (await getDefaultBaseBranch(this.repoPath));
350
- this.updateCompareState({ compareBaseBranch: base });
351
- }
352
- if (base) {
353
- const diff = includeUncommitted
354
- ? await getCompareDiffWithUncommitted(this.repoPath, base)
355
- : await getDiffBetweenRefs(this.repoPath, base);
356
- this.updateCompareState({ compareDiff: diff, compareLoading: false });
357
- }
358
- else {
359
- this.updateCompareState({
360
- compareDiff: null,
361
- compareLoading: false,
362
- compareError: 'No base branch found',
363
- });
364
- }
365
- });
454
+ await this.queue.enqueue(() => this.doRefreshCompareDiff(includeUncommitted));
366
455
  }
367
456
  catch (err) {
368
457
  this.updateCompareState({
@@ -371,6 +460,30 @@ export class GitStateManager extends EventEmitter {
371
460
  });
372
461
  }
373
462
  }
463
+ /**
464
+ * Internal: refresh compare diff (called within queue).
465
+ */
466
+ async doRefreshCompareDiff(includeUncommitted) {
467
+ let base = this._compareState.compareBaseBranch;
468
+ if (!base) {
469
+ // Try cached value first, then fall back to default detection
470
+ base = getCachedBaseBranch(this.repoPath) ?? (await getDefaultBaseBranch(this.repoPath));
471
+ this.updateCompareState({ compareBaseBranch: base });
472
+ }
473
+ if (base) {
474
+ const diff = includeUncommitted
475
+ ? await getCompareDiffWithUncommitted(this.repoPath, base)
476
+ : await getDiffBetweenRefs(this.repoPath, base);
477
+ this.updateCompareState({ compareDiff: diff, compareLoading: false });
478
+ }
479
+ else {
480
+ this.updateCompareState({
481
+ compareDiff: null,
482
+ compareLoading: false,
483
+ compareError: 'No base branch found',
484
+ });
485
+ }
486
+ }
374
487
  /**
375
488
  * Get candidate base branches for branch comparison.
376
489
  */
@@ -386,6 +499,28 @@ export class GitStateManager extends EventEmitter {
386
499
  setCachedBaseBranch(this.repoPath, branch);
387
500
  await this.refreshCompareDiff(includeUncommitted);
388
501
  }
502
+ /**
503
+ * Load commit history for the history view.
504
+ */
505
+ async loadHistory(count = 100) {
506
+ this.updateHistoryState({ isLoading: true });
507
+ try {
508
+ await this.queue.enqueue(() => this.doLoadHistory(count));
509
+ }
510
+ catch (err) {
511
+ this.updateHistoryState({ isLoading: false });
512
+ this.updateState({
513
+ error: `Failed to load history: ${err instanceof Error ? err.message : String(err)}`,
514
+ });
515
+ }
516
+ }
517
+ /**
518
+ * Internal: load commit history (called within queue).
519
+ */
520
+ async doLoadHistory(count = 100) {
521
+ const commits = await getCommitHistory(this.repoPath, count);
522
+ this.updateHistoryState({ commits, isLoading: false });
523
+ }
389
524
  /**
390
525
  * Select a commit in history view and load its diff.
391
526
  */
package/dist/git/diff.js CHANGED
@@ -302,6 +302,8 @@ export async function getDiffBetweenRefs(repoPath, baseRef) {
302
302
  date: new Date(entry.date),
303
303
  refs: entry.refs || '',
304
304
  }));
305
+ // Sort files alphabetically by path
306
+ fileDiffs.sort((a, b) => a.path.localeCompare(b.path));
305
307
  return {
306
308
  baseBranch: baseRef,
307
309
  stats: {
@@ -457,6 +459,8 @@ export async function getCompareDiffWithUncommitted(repoPath, baseRef) {
457
459
  totalAdditions += file.additions;
458
460
  totalDeletions += file.deletions;
459
461
  }
462
+ // Sort files alphabetically by path
463
+ mergedFiles.sort((a, b) => a.path.localeCompare(b.path));
460
464
  return {
461
465
  baseBranch: committedDiff.baseBranch,
462
466
  stats: {
@@ -0,0 +1,30 @@
1
+ import { simpleGit } from 'simple-git';
2
+ /**
3
+ * Check which files from a list are ignored by git.
4
+ * Uses `git check-ignore` to determine ignored files.
5
+ */
6
+ export async function getIgnoredFiles(repoPath, files) {
7
+ if (files.length === 0)
8
+ return new Set();
9
+ const git = simpleGit(repoPath);
10
+ const ignoredFiles = new Set();
11
+ const batchSize = 100;
12
+ for (let i = 0; i < files.length; i += batchSize) {
13
+ const batch = files.slice(i, i + batchSize);
14
+ try {
15
+ const result = await git.raw(['check-ignore', ...batch]);
16
+ const ignored = result
17
+ .trim()
18
+ .split('\n')
19
+ .filter((f) => f.length > 0);
20
+ for (const f of ignored) {
21
+ ignoredFiles.add(f);
22
+ }
23
+ }
24
+ catch {
25
+ // check-ignore exits with code 1 if no files are ignored, which throws
26
+ // Just continue to next batch
27
+ }
28
+ }
29
+ return ignoredFiles;
30
+ }