diffstalker 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/bun.lock +72 -312
  3. package/dist/App.js +1136 -515
  4. package/dist/core/ExplorerStateManager.js +266 -0
  5. package/dist/core/FilePathWatcher.js +133 -0
  6. package/dist/core/GitStateManager.js +75 -16
  7. package/dist/git/ignoreUtils.js +30 -0
  8. package/dist/git/status.js +2 -34
  9. package/dist/index.js +67 -53
  10. package/dist/ipc/CommandClient.js +165 -0
  11. package/dist/ipc/CommandServer.js +152 -0
  12. package/dist/state/CommitFlowState.js +86 -0
  13. package/dist/state/UIState.js +182 -0
  14. package/dist/types/tabs.js +4 -0
  15. package/dist/ui/Layout.js +252 -0
  16. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  17. package/dist/ui/modals/DiscardConfirm.js +77 -0
  18. package/dist/ui/modals/HotkeysModal.js +209 -0
  19. package/dist/ui/modals/ThemePicker.js +107 -0
  20. package/dist/ui/widgets/CommitPanel.js +58 -0
  21. package/dist/ui/widgets/CompareListView.js +216 -0
  22. package/dist/ui/widgets/DiffView.js +279 -0
  23. package/dist/ui/widgets/ExplorerContent.js +102 -0
  24. package/dist/ui/widgets/ExplorerView.js +95 -0
  25. package/dist/ui/widgets/FileList.js +185 -0
  26. package/dist/ui/widgets/Footer.js +46 -0
  27. package/dist/ui/widgets/Header.js +111 -0
  28. package/dist/ui/widgets/HistoryView.js +69 -0
  29. package/dist/utils/ansiToBlessed.js +125 -0
  30. package/dist/utils/displayRows.js +185 -6
  31. package/dist/utils/explorerDisplayRows.js +1 -1
  32. package/dist/utils/languageDetection.js +56 -0
  33. package/dist/utils/pathUtils.js +27 -0
  34. package/dist/utils/rowCalculations.js +37 -0
  35. package/dist/utils/wordDiff.js +50 -0
  36. package/package.json +11 -12
  37. package/dist/components/BaseBranchPicker.js +0 -60
  38. package/dist/components/BottomPane.js +0 -101
  39. package/dist/components/CommitPanel.js +0 -58
  40. package/dist/components/CompareListView.js +0 -110
  41. package/dist/components/ExplorerContentView.js +0 -80
  42. package/dist/components/ExplorerView.js +0 -37
  43. package/dist/components/FileList.js +0 -131
  44. package/dist/components/Footer.js +0 -6
  45. package/dist/components/Header.js +0 -107
  46. package/dist/components/HistoryView.js +0 -21
  47. package/dist/components/HotkeysModal.js +0 -108
  48. package/dist/components/Modal.js +0 -19
  49. package/dist/components/ScrollableList.js +0 -125
  50. package/dist/components/ThemePicker.js +0 -42
  51. package/dist/components/TopPane.js +0 -14
  52. package/dist/components/UnifiedDiffView.js +0 -115
  53. package/dist/hooks/useCommitFlow.js +0 -66
  54. package/dist/hooks/useCompareState.js +0 -123
  55. package/dist/hooks/useExplorerState.js +0 -248
  56. package/dist/hooks/useGit.js +0 -156
  57. package/dist/hooks/useHistoryState.js +0 -62
  58. package/dist/hooks/useKeymap.js +0 -167
  59. package/dist/hooks/useLayout.js +0 -154
  60. package/dist/hooks/useMouse.js +0 -87
  61. package/dist/hooks/useTerminalSize.js +0 -20
  62. package/dist/hooks/useWatcher.js +0 -137
@@ -0,0 +1,266 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { EventEmitter } from 'node:events';
4
+ import { getIgnoredFiles } from '../git/ignoreUtils.js';
5
+ const MAX_FILE_SIZE = 1024 * 1024; // 1MB
6
+ const WARN_FILE_SIZE = 100 * 1024; // 100KB
7
+ /**
8
+ * Check if content appears to be binary.
9
+ */
10
+ function isBinaryContent(buffer) {
11
+ // Check first 8KB for null bytes (common in binary files)
12
+ const checkLength = Math.min(buffer.length, 8192);
13
+ for (let i = 0; i < checkLength; i++) {
14
+ if (buffer[i] === 0)
15
+ return true;
16
+ }
17
+ return false;
18
+ }
19
+ /**
20
+ * ExplorerStateManager manages file explorer state independent of React.
21
+ * It handles directory loading, file selection, and navigation.
22
+ */
23
+ export class ExplorerStateManager extends EventEmitter {
24
+ repoPath;
25
+ options;
26
+ _state = {
27
+ currentPath: '',
28
+ items: [],
29
+ selectedIndex: 0,
30
+ selectedFile: null,
31
+ isLoading: false,
32
+ error: null,
33
+ };
34
+ constructor(repoPath, options) {
35
+ super();
36
+ this.repoPath = repoPath;
37
+ this.options = options;
38
+ }
39
+ get state() {
40
+ return this._state;
41
+ }
42
+ updateState(partial) {
43
+ this._state = { ...this._state, ...partial };
44
+ this.emit('state-change', this._state);
45
+ }
46
+ /**
47
+ * Set filtering options and reload directory.
48
+ */
49
+ async setOptions(options) {
50
+ this.options = { ...this.options, ...options };
51
+ await this.loadDirectory(this._state.currentPath);
52
+ }
53
+ /**
54
+ * Load a directory's contents.
55
+ */
56
+ async loadDirectory(relativePath) {
57
+ this.updateState({ isLoading: true, error: null, currentPath: relativePath });
58
+ try {
59
+ const fullPath = path.join(this.repoPath, relativePath);
60
+ const entries = await fs.promises.readdir(fullPath, { withFileTypes: true });
61
+ // Build list of paths for gitignore check
62
+ const pathsToCheck = entries.map((e) => relativePath ? path.join(relativePath, e.name) : e.name);
63
+ // Get ignored files (only if we need to filter them)
64
+ const ignoredFiles = this.options.hideGitignored
65
+ ? await getIgnoredFiles(this.repoPath, pathsToCheck)
66
+ : new Set();
67
+ // Filter and map entries
68
+ const explorerItems = entries
69
+ .filter((entry) => {
70
+ // Filter dot-prefixed hidden files
71
+ if (this.options.hideHidden && entry.name.startsWith('.')) {
72
+ return false;
73
+ }
74
+ // Filter gitignored files
75
+ if (this.options.hideGitignored) {
76
+ const entryPath = relativePath ? path.join(relativePath, entry.name) : entry.name;
77
+ if (ignoredFiles.has(entryPath)) {
78
+ return false;
79
+ }
80
+ }
81
+ return true;
82
+ })
83
+ .map((entry) => ({
84
+ name: entry.name,
85
+ path: relativePath ? path.join(relativePath, entry.name) : entry.name,
86
+ isDirectory: entry.isDirectory(),
87
+ }));
88
+ // Sort: directories first (alphabetical), then files (alphabetical)
89
+ explorerItems.sort((a, b) => {
90
+ if (a.isDirectory && !b.isDirectory)
91
+ return -1;
92
+ if (!a.isDirectory && b.isDirectory)
93
+ return 1;
94
+ return a.name.localeCompare(b.name);
95
+ });
96
+ // Add ".." at the beginning if not at root
97
+ if (relativePath) {
98
+ explorerItems.unshift({
99
+ name: '..',
100
+ path: path.dirname(relativePath) || '',
101
+ isDirectory: true,
102
+ });
103
+ }
104
+ this.updateState({
105
+ items: explorerItems,
106
+ selectedIndex: 0,
107
+ selectedFile: null,
108
+ isLoading: false,
109
+ });
110
+ }
111
+ catch (err) {
112
+ this.updateState({
113
+ error: err instanceof Error ? err.message : 'Failed to read directory',
114
+ items: [],
115
+ isLoading: false,
116
+ });
117
+ }
118
+ }
119
+ /**
120
+ * Load a file's contents.
121
+ */
122
+ async loadFile(itemPath) {
123
+ try {
124
+ const fullPath = path.join(this.repoPath, itemPath);
125
+ const stats = await fs.promises.stat(fullPath);
126
+ // Check file size
127
+ if (stats.size > MAX_FILE_SIZE) {
128
+ this.updateState({
129
+ selectedFile: {
130
+ path: itemPath,
131
+ content: `File too large to display (${(stats.size / 1024 / 1024).toFixed(2)} MB).\nMaximum size: 1 MB`,
132
+ truncated: true,
133
+ },
134
+ });
135
+ return;
136
+ }
137
+ const buffer = await fs.promises.readFile(fullPath);
138
+ // Check if binary
139
+ if (isBinaryContent(buffer)) {
140
+ this.updateState({
141
+ selectedFile: {
142
+ path: itemPath,
143
+ content: 'Binary file - cannot display',
144
+ },
145
+ });
146
+ return;
147
+ }
148
+ let content = buffer.toString('utf-8');
149
+ let truncated = false;
150
+ // Warn about large files
151
+ if (stats.size > WARN_FILE_SIZE) {
152
+ const warning = `⚠ Large file (${(stats.size / 1024).toFixed(1)} KB)\n\n`;
153
+ content = warning + content;
154
+ }
155
+ // Truncate if needed
156
+ const maxLines = 5000;
157
+ const lines = content.split('\n');
158
+ if (lines.length > maxLines) {
159
+ content =
160
+ lines.slice(0, maxLines).join('\n') +
161
+ `\n\n... (truncated, ${lines.length - maxLines} more lines)`;
162
+ truncated = true;
163
+ }
164
+ this.updateState({
165
+ selectedFile: {
166
+ path: itemPath,
167
+ content,
168
+ truncated,
169
+ },
170
+ });
171
+ }
172
+ catch (err) {
173
+ this.updateState({
174
+ selectedFile: {
175
+ path: itemPath,
176
+ content: err instanceof Error ? `Error: ${err.message}` : 'Failed to read file',
177
+ },
178
+ });
179
+ }
180
+ }
181
+ /**
182
+ * Select an item by index.
183
+ */
184
+ async selectIndex(index) {
185
+ if (index < 0 || index >= this._state.items.length)
186
+ return;
187
+ const selected = this._state.items[index];
188
+ this.updateState({ selectedIndex: index });
189
+ if (selected && !selected.isDirectory) {
190
+ await this.loadFile(selected.path);
191
+ }
192
+ else {
193
+ this.updateState({ selectedFile: null });
194
+ }
195
+ }
196
+ /**
197
+ * Navigate to previous item.
198
+ * Returns the new scroll offset if scrolling is needed, or null if not.
199
+ */
200
+ navigateUp(currentScrollOffset) {
201
+ const newIndex = Math.max(0, this._state.selectedIndex - 1);
202
+ if (newIndex === this._state.selectedIndex)
203
+ return null;
204
+ // Don't await - fire and forget for responsiveness
205
+ this.selectIndex(newIndex);
206
+ // Return new scroll offset if we need to scroll up
207
+ if (newIndex < currentScrollOffset) {
208
+ return newIndex;
209
+ }
210
+ return null;
211
+ }
212
+ /**
213
+ * Navigate to next item.
214
+ * Returns the new scroll offset if scrolling is needed, or null if not.
215
+ */
216
+ navigateDown(currentScrollOffset, visibleHeight) {
217
+ const newIndex = Math.min(this._state.items.length - 1, this._state.selectedIndex + 1);
218
+ if (newIndex === this._state.selectedIndex)
219
+ return null;
220
+ // Don't await - fire and forget for responsiveness
221
+ this.selectIndex(newIndex);
222
+ // Calculate visible area accounting for scroll indicators
223
+ const needsScrolling = this._state.items.length > visibleHeight;
224
+ const availableHeight = needsScrolling ? visibleHeight - 2 : visibleHeight;
225
+ const visibleEnd = currentScrollOffset + availableHeight;
226
+ if (newIndex >= visibleEnd) {
227
+ return currentScrollOffset + 1;
228
+ }
229
+ return null;
230
+ }
231
+ /**
232
+ * Enter the selected directory or go to parent if ".." is selected.
233
+ */
234
+ async enterDirectory() {
235
+ const selected = this._state.items[this._state.selectedIndex];
236
+ if (!selected)
237
+ return;
238
+ if (selected.isDirectory) {
239
+ if (selected.name === '..') {
240
+ const parent = path.dirname(this._state.currentPath);
241
+ // path.dirname returns "." for top-level paths, normalize to ""
242
+ await this.loadDirectory(parent === '.' ? '' : parent);
243
+ }
244
+ else {
245
+ await this.loadDirectory(selected.path);
246
+ }
247
+ }
248
+ // If it's a file, do nothing (file content is already shown)
249
+ }
250
+ /**
251
+ * Go to parent directory (backspace navigation).
252
+ */
253
+ async goUp() {
254
+ if (this._state.currentPath) {
255
+ const parent = path.dirname(this._state.currentPath);
256
+ // path.dirname returns "." for top-level paths, normalize to ""
257
+ await this.loadDirectory(parent === '.' ? '' : parent);
258
+ }
259
+ }
260
+ /**
261
+ * Clean up resources.
262
+ */
263
+ dispose() {
264
+ this.removeAllListeners();
265
+ }
266
+ }
@@ -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
+ }
@@ -2,8 +2,9 @@ import * as path from 'node:path';
2
2
  import * as fs from 'node:fs';
3
3
  import { watch } from 'chokidar';
4
4
  import { EventEmitter } from 'node:events';
5
+ import ignore from 'ignore';
5
6
  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';
7
+ import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, } from '../git/status.js';
7
8
  import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, } from '../git/diff.js';
8
9
  import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
9
10
  /**
@@ -15,6 +16,7 @@ export class GitStateManager extends EventEmitter {
15
16
  queue;
16
17
  gitWatcher = null;
17
18
  workingDirWatcher = null;
19
+ ignorer = null;
18
20
  // Current state
19
21
  _state = {
20
22
  status: null,
@@ -31,8 +33,10 @@ export class GitStateManager extends EventEmitter {
31
33
  compareError: null,
32
34
  };
33
35
  _historyState = {
36
+ commits: [],
34
37
  selectedCommit: null,
35
38
  commitDiff: null,
39
+ isLoading: false,
36
40
  };
37
41
  _compareSelectionState = {
38
42
  type: null,
@@ -72,6 +76,26 @@ export class GitStateManager extends EventEmitter {
72
76
  this._compareSelectionState = { ...this._compareSelectionState, ...partial };
73
77
  this.emit('compare-selection-change', this._compareSelectionState);
74
78
  }
79
+ /**
80
+ * Load gitignore patterns from .gitignore and .git/info/exclude.
81
+ * Returns an Ignore instance that can test paths.
82
+ */
83
+ loadGitignore() {
84
+ const ig = ignore();
85
+ // Always ignore .git directory (has its own dedicated watcher)
86
+ ig.add('.git');
87
+ // Load .gitignore if it exists
88
+ const gitignorePath = path.join(this.repoPath, '.gitignore');
89
+ if (fs.existsSync(gitignorePath)) {
90
+ ig.add(fs.readFileSync(gitignorePath, 'utf-8'));
91
+ }
92
+ // Load .git/info/exclude if it exists (repo-specific ignores)
93
+ const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
94
+ if (fs.existsSync(excludePath)) {
95
+ ig.add(fs.readFileSync(excludePath, 'utf-8'));
96
+ }
97
+ return ig;
98
+ }
75
99
  /**
76
100
  * Start watching for file changes.
77
101
  */
@@ -79,41 +103,60 @@ export class GitStateManager extends EventEmitter {
79
103
  const gitDir = path.join(this.repoPath, '.git');
80
104
  if (!fs.existsSync(gitDir))
81
105
  return;
106
+ // --- Git internals watcher ---
82
107
  const indexFile = path.join(gitDir, 'index');
83
108
  const headFile = path.join(gitDir, 'HEAD');
84
109
  const refsDir = path.join(gitDir, 'refs');
85
- this.gitWatcher = watch([indexFile, headFile, refsDir], {
110
+ const gitignorePath = path.join(this.repoPath, '.gitignore');
111
+ // Git uses atomic writes (write to temp, then rename). We use polling
112
+ // for reliable detection of these atomic operations.
113
+ this.gitWatcher = watch([indexFile, headFile, refsDir, gitignorePath], {
86
114
  persistent: true,
87
115
  ignoreInitial: true,
88
- awaitWriteFinish: {
89
- stabilityThreshold: 100,
90
- pollInterval: 50,
91
- },
116
+ usePolling: true,
117
+ interval: 100,
92
118
  });
119
+ // --- Working directory watcher with gitignore support ---
120
+ this.ignorer = this.loadGitignore();
93
121
  this.workingDirWatcher = watch(this.repoPath, {
94
122
  persistent: true,
95
123
  ignoreInitial: true,
96
- ignored: [
97
- '**/node_modules/**',
98
- '**/.git/**',
99
- '**/dist/**',
100
- '**/build/**',
101
- '**/*.log',
102
- '**/.DS_Store',
103
- ],
124
+ ignored: (filePath) => {
125
+ // Get path relative to repo root
126
+ const relativePath = path.relative(this.repoPath, filePath);
127
+ // Don't ignore the repo root itself
128
+ if (!relativePath)
129
+ return false;
130
+ // Check against gitignore patterns
131
+ // When this returns true for a directory, chokidar won't recurse into it
132
+ return this.ignorer?.ignores(relativePath) ?? false;
133
+ },
104
134
  awaitWriteFinish: {
105
135
  stabilityThreshold: 100,
106
136
  pollInterval: 50,
107
137
  },
108
- depth: 10,
109
138
  });
110
139
  const scheduleRefresh = () => this.scheduleRefresh();
111
- this.gitWatcher.on('change', scheduleRefresh);
140
+ this.gitWatcher.on('change', (filePath) => {
141
+ // Reload gitignore patterns if .gitignore changed
142
+ if (filePath === gitignorePath) {
143
+ this.ignorer = this.loadGitignore();
144
+ }
145
+ scheduleRefresh();
146
+ });
112
147
  this.gitWatcher.on('add', scheduleRefresh);
113
148
  this.gitWatcher.on('unlink', scheduleRefresh);
149
+ this.gitWatcher.on('error', (err) => {
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ this.emit('error', `Git watcher error: ${message}`);
152
+ });
114
153
  this.workingDirWatcher.on('change', scheduleRefresh);
115
154
  this.workingDirWatcher.on('add', scheduleRefresh);
116
155
  this.workingDirWatcher.on('unlink', scheduleRefresh);
156
+ this.workingDirWatcher.on('error', (err) => {
157
+ const message = err instanceof Error ? err.message : String(err);
158
+ this.emit('error', `Working dir watcher error: ${message}`);
159
+ });
117
160
  }
118
161
  /**
119
162
  * Stop watching and clean up resources.
@@ -386,6 +429,22 @@ export class GitStateManager extends EventEmitter {
386
429
  setCachedBaseBranch(this.repoPath, branch);
387
430
  await this.refreshCompareDiff(includeUncommitted);
388
431
  }
432
+ /**
433
+ * Load commit history for the history view.
434
+ */
435
+ async loadHistory(count = 100) {
436
+ this.updateHistoryState({ isLoading: true });
437
+ try {
438
+ const commits = await this.queue.enqueue(() => getCommitHistory(this.repoPath, count));
439
+ this.updateHistoryState({ commits, isLoading: false });
440
+ }
441
+ catch (err) {
442
+ this.updateHistoryState({ isLoading: false });
443
+ this.updateState({
444
+ error: `Failed to load history: ${err instanceof Error ? err.message : String(err)}`,
445
+ });
446
+ }
447
+ }
389
448
  /**
390
449
  * Select a commit in history view and load its diff.
391
450
  */
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import { simpleGit } from 'simple-git';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ import { getIgnoredFiles } from './ignoreUtils.js';
4
5
  // Parse git diff --numstat output into a map of path -> stats
5
6
  export function parseNumstat(output) {
6
7
  const stats = new Map();
@@ -29,39 +30,6 @@ async function countFileLines(repoPath, filePath) {
29
30
  return 0;
30
31
  }
31
32
  }
32
- // Check which files from a list are ignored by git
33
- async function getIgnoredFiles(git, files) {
34
- if (files.length === 0)
35
- return new Set();
36
- try {
37
- // git check-ignore returns the list of ignored files (one per line)
38
- // Pass files as arguments (limit batch size to avoid command line length issues)
39
- const ignoredFiles = new Set();
40
- const batchSize = 100;
41
- for (let i = 0; i < files.length; i += batchSize) {
42
- const batch = files.slice(i, i + batchSize);
43
- try {
44
- const result = await git.raw(['check-ignore', ...batch]);
45
- const ignored = result
46
- .trim()
47
- .split('\n')
48
- .filter((f) => f.length > 0);
49
- for (const f of ignored) {
50
- ignoredFiles.add(f);
51
- }
52
- }
53
- catch {
54
- // check-ignore exits with code 1 if no files are ignored, which throws
55
- // Just continue to next batch
56
- }
57
- }
58
- return ignoredFiles;
59
- }
60
- catch {
61
- // If check-ignore fails entirely, return empty set
62
- return new Set();
63
- }
64
- }
65
33
  export function parseStatusCode(code) {
66
34
  switch (code) {
67
35
  case 'M':
@@ -145,7 +113,7 @@ export async function getStatus(repoPath) {
145
113
  // Collect untracked files to check if they're ignored
146
114
  const untrackedPaths = status.files.filter((f) => f.working_dir === '?').map((f) => f.path);
147
115
  // Get the set of ignored files
148
- const ignoredFiles = await getIgnoredFiles(git, untrackedPaths);
116
+ const ignoredFiles = await getIgnoredFiles(repoPath, untrackedPaths);
149
117
  for (const file of status.files) {
150
118
  // Skip ignored files (marked with '!' in either column, or detected by check-ignore)
151
119
  if (file.index === '!' || file.working_dir === '!' || ignoredFiles.has(file.path)) {