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.
- package/CHANGELOG.md +36 -0
- package/bun.lock +72 -312
- package/dist/App.js +1136 -515
- package/dist/core/ExplorerStateManager.js +266 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitStateManager.js +75 -16
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +2 -34
- package/dist/index.js +67 -53
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +182 -0
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/HotkeysModal.js +209 -0
- package/dist/ui/modals/ThemePicker.js +107 -0
- package/dist/ui/widgets/CommitPanel.js +58 -0
- package/dist/ui/widgets/CompareListView.js +216 -0
- package/dist/ui/widgets/DiffView.js +279 -0
- package/dist/ui/widgets/ExplorerContent.js +102 -0
- package/dist/ui/widgets/ExplorerView.js +95 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +46 -0
- package/dist/ui/widgets/Header.js +111 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/ansiToBlessed.js +125 -0
- package/dist/utils/displayRows.js +185 -6
- package/dist/utils/explorerDisplayRows.js +1 -1
- package/dist/utils/languageDetection.js +56 -0
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/rowCalculations.js +37 -0
- package/dist/utils/wordDiff.js +50 -0
- package/package.json +11 -12
- package/dist/components/BaseBranchPicker.js +0 -60
- package/dist/components/BottomPane.js +0 -101
- package/dist/components/CommitPanel.js +0 -58
- package/dist/components/CompareListView.js +0 -110
- package/dist/components/ExplorerContentView.js +0 -80
- package/dist/components/ExplorerView.js +0 -37
- package/dist/components/FileList.js +0 -131
- package/dist/components/Footer.js +0 -6
- package/dist/components/Header.js +0 -107
- package/dist/components/HistoryView.js +0 -21
- package/dist/components/HotkeysModal.js +0 -108
- package/dist/components/Modal.js +0 -19
- package/dist/components/ScrollableList.js +0 -125
- package/dist/components/ThemePicker.js +0 -42
- package/dist/components/TopPane.js +0 -14
- package/dist/components/UnifiedDiffView.js +0 -115
- package/dist/hooks/useCommitFlow.js +0 -66
- package/dist/hooks/useCompareState.js +0 -123
- package/dist/hooks/useExplorerState.js +0 -248
- package/dist/hooks/useGit.js +0 -156
- package/dist/hooks/useHistoryState.js +0 -62
- package/dist/hooks/useKeymap.js +0 -167
- package/dist/hooks/useLayout.js +0 -154
- package/dist/hooks/useMouse.js +0 -87
- package/dist/hooks/useTerminalSize.js +0 -20
- 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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
'
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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',
|
|
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
|
+
}
|
package/dist/git/status.js
CHANGED
|
@@ -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(
|
|
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)) {
|