diffstalker 0.2.0 → 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.
- package/.github/workflows/release.yml +8 -0
- package/bun.lock +23 -0
- package/dist/App.js +225 -471
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +178 -0
- package/dist/MouseHandlers.js +156 -0
- package/dist/core/ExplorerStateManager.js +444 -78
- package/dist/core/GitStateManager.js +169 -93
- package/dist/git/diff.js +4 -0
- package/dist/index.js +54 -53
- package/dist/state/UIState.js +17 -4
- package/dist/ui/PaneRenderers.js +56 -0
- package/dist/ui/modals/FileFinder.js +232 -0
- package/dist/ui/widgets/CompareListView.js +86 -64
- package/dist/ui/widgets/DiffView.js +19 -17
- package/dist/ui/widgets/ExplorerContent.js +15 -28
- package/dist/ui/widgets/ExplorerView.js +140 -31
- package/dist/ui/widgets/Footer.js +6 -2
- package/dist/ui/widgets/Header.js +3 -46
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/eslint.metrics.js +16 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/package.json +4 -1
- package/dist/utils/ansiToBlessed.js +0 -125
- package/dist/utils/mouseCoordinates.js +0 -165
- package/dist/utils/rowCalculations.js +0 -246
|
@@ -1,5 +1,6 @@
|
|
|
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';
|
|
@@ -16,12 +17,12 @@ export class GitStateManager extends EventEmitter {
|
|
|
16
17
|
queue;
|
|
17
18
|
gitWatcher = null;
|
|
18
19
|
workingDirWatcher = null;
|
|
19
|
-
|
|
20
|
+
ignorers = new Map();
|
|
21
|
+
diffDebounceTimer = null;
|
|
20
22
|
// Current state
|
|
21
23
|
_state = {
|
|
22
24
|
status: null,
|
|
23
25
|
diff: null,
|
|
24
|
-
stagedDiff: '',
|
|
25
26
|
selectedFile: null,
|
|
26
27
|
isLoading: false,
|
|
27
28
|
error: null,
|
|
@@ -77,24 +78,49 @@ export class GitStateManager extends EventEmitter {
|
|
|
77
78
|
this.emit('compare-selection-change', this._compareSelectionState);
|
|
78
79
|
}
|
|
79
80
|
/**
|
|
80
|
-
* Load gitignore patterns from .gitignore and .git/info/exclude.
|
|
81
|
-
* Returns
|
|
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).
|
|
82
84
|
*/
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
//
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
if (fs.existsSync(
|
|
90
|
-
|
|
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'));
|
|
91
93
|
}
|
|
92
|
-
// Load .git/info/exclude if it exists (repo-specific ignores)
|
|
93
94
|
const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
|
|
94
95
|
if (fs.existsSync(excludePath)) {
|
|
95
|
-
|
|
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
|
+
}
|
|
96
119
|
}
|
|
97
|
-
|
|
120
|
+
catch {
|
|
121
|
+
// git ls-files failed — we still have the root ignorer
|
|
122
|
+
}
|
|
123
|
+
return ignorers;
|
|
98
124
|
}
|
|
99
125
|
/**
|
|
100
126
|
* Start watching for file changes.
|
|
@@ -117,19 +143,26 @@ export class GitStateManager extends EventEmitter {
|
|
|
117
143
|
interval: 100,
|
|
118
144
|
});
|
|
119
145
|
// --- Working directory watcher with gitignore support ---
|
|
120
|
-
this.
|
|
146
|
+
this.ignorers = this.loadGitignores();
|
|
121
147
|
this.workingDirWatcher = watch(this.repoPath, {
|
|
122
148
|
persistent: true,
|
|
123
149
|
ignoreInitial: true,
|
|
124
150
|
ignored: (filePath) => {
|
|
125
|
-
// Get path relative to repo root
|
|
126
151
|
const relativePath = path.relative(this.repoPath, filePath);
|
|
127
|
-
// Don't ignore the repo root itself
|
|
128
152
|
if (!relativePath)
|
|
129
153
|
return false;
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
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;
|
|
133
166
|
},
|
|
134
167
|
awaitWriteFinish: {
|
|
135
168
|
stabilityThreshold: 100,
|
|
@@ -140,7 +173,7 @@ export class GitStateManager extends EventEmitter {
|
|
|
140
173
|
this.gitWatcher.on('change', (filePath) => {
|
|
141
174
|
// Reload gitignore patterns if .gitignore changed
|
|
142
175
|
if (filePath === gitignorePath) {
|
|
143
|
-
this.
|
|
176
|
+
this.ignorers = this.loadGitignores();
|
|
144
177
|
}
|
|
145
178
|
scheduleRefresh();
|
|
146
179
|
});
|
|
@@ -162,15 +195,47 @@ export class GitStateManager extends EventEmitter {
|
|
|
162
195
|
* Stop watching and clean up resources.
|
|
163
196
|
*/
|
|
164
197
|
dispose() {
|
|
198
|
+
if (this.diffDebounceTimer)
|
|
199
|
+
clearTimeout(this.diffDebounceTimer);
|
|
165
200
|
this.gitWatcher?.close();
|
|
166
201
|
this.workingDirWatcher?.close();
|
|
167
202
|
removeQueueForRepo(this.repoPath);
|
|
168
203
|
}
|
|
169
204
|
/**
|
|
170
205
|
* Schedule a refresh (coalesced if one is already pending).
|
|
206
|
+
* Also refreshes history and compare data if they were previously loaded.
|
|
171
207
|
*/
|
|
172
208
|
scheduleRefresh() {
|
|
173
|
-
this.queue.scheduleRefresh(() =>
|
|
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
|
+
});
|
|
174
239
|
}
|
|
175
240
|
/**
|
|
176
241
|
* Immediately refresh git state.
|
|
@@ -186,17 +251,15 @@ export class GitStateManager extends EventEmitter {
|
|
|
186
251
|
this.updateState({
|
|
187
252
|
status: newStatus,
|
|
188
253
|
diff: null,
|
|
189
|
-
stagedDiff: '',
|
|
190
254
|
isLoading: false,
|
|
191
255
|
error: 'Not a git repository',
|
|
192
256
|
});
|
|
193
257
|
return;
|
|
194
258
|
}
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
]);
|
|
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);
|
|
200
263
|
// Determine display diff based on selected file
|
|
201
264
|
let displayDiff;
|
|
202
265
|
const currentSelectedFile = this._state.selectedFile;
|
|
@@ -211,26 +274,16 @@ export class GitStateManager extends EventEmitter {
|
|
|
211
274
|
}
|
|
212
275
|
}
|
|
213
276
|
else {
|
|
214
|
-
// File no longer exists - clear selection
|
|
215
|
-
displayDiff = allUnstagedDiff
|
|
277
|
+
// File no longer exists - clear selection, show unstaged diff
|
|
278
|
+
displayDiff = allUnstagedDiff;
|
|
216
279
|
this.updateState({ selectedFile: null });
|
|
217
280
|
}
|
|
218
281
|
}
|
|
219
282
|
else {
|
|
220
|
-
|
|
221
|
-
displayDiff = allUnstagedDiff;
|
|
222
|
-
}
|
|
223
|
-
else if (allStagedDiff.raw) {
|
|
224
|
-
displayDiff = allStagedDiff;
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
displayDiff = { raw: '', lines: [] };
|
|
228
|
-
}
|
|
283
|
+
displayDiff = allUnstagedDiff;
|
|
229
284
|
}
|
|
230
285
|
this.updateState({
|
|
231
|
-
status: newStatus,
|
|
232
286
|
diff: displayDiff,
|
|
233
|
-
stagedDiff: allStagedDiff.raw,
|
|
234
287
|
isLoading: false,
|
|
235
288
|
});
|
|
236
289
|
}
|
|
@@ -243,12 +296,36 @@ export class GitStateManager extends EventEmitter {
|
|
|
243
296
|
}
|
|
244
297
|
/**
|
|
245
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.
|
|
246
301
|
*/
|
|
247
|
-
|
|
302
|
+
selectFile(file) {
|
|
248
303
|
this.updateState({ selectedFile: file });
|
|
249
304
|
if (!this._state.status?.isRepo)
|
|
250
305
|
return;
|
|
251
|
-
|
|
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;
|
|
252
329
|
if (file) {
|
|
253
330
|
let fileDiff;
|
|
254
331
|
if (file.status === 'untracked') {
|
|
@@ -257,31 +334,30 @@ export class GitStateManager extends EventEmitter {
|
|
|
257
334
|
else {
|
|
258
335
|
fileDiff = await getDiff(this.repoPath, file.path, file.staged);
|
|
259
336
|
}
|
|
260
|
-
|
|
337
|
+
if (file === this._state.selectedFile) {
|
|
338
|
+
this.updateState({ diff: fileDiff });
|
|
339
|
+
}
|
|
261
340
|
}
|
|
262
341
|
else {
|
|
263
342
|
const allDiff = await getStagedDiff(this.repoPath);
|
|
264
|
-
this.
|
|
343
|
+
if (this._state.selectedFile === null) {
|
|
344
|
+
this.updateState({ diff: allDiff });
|
|
345
|
+
}
|
|
265
346
|
}
|
|
347
|
+
})
|
|
348
|
+
.catch((err) => {
|
|
349
|
+
this.updateState({
|
|
350
|
+
error: `Failed to load diff: ${err instanceof Error ? err.message : String(err)}`,
|
|
351
|
+
});
|
|
266
352
|
});
|
|
267
353
|
}
|
|
268
354
|
/**
|
|
269
|
-
* Stage a file
|
|
355
|
+
* Stage a file.
|
|
270
356
|
*/
|
|
271
357
|
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
358
|
try {
|
|
283
359
|
await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
|
|
284
|
-
this.
|
|
360
|
+
this.scheduleStatusRefresh();
|
|
285
361
|
}
|
|
286
362
|
catch (err) {
|
|
287
363
|
await this.refresh();
|
|
@@ -291,22 +367,12 @@ export class GitStateManager extends EventEmitter {
|
|
|
291
367
|
}
|
|
292
368
|
}
|
|
293
369
|
/**
|
|
294
|
-
* Unstage a file
|
|
370
|
+
* Unstage a file.
|
|
295
371
|
*/
|
|
296
372
|
async unstage(file) {
|
|
297
|
-
// Optimistic update
|
|
298
|
-
const currentStatus = this._state.status;
|
|
299
|
-
if (currentStatus) {
|
|
300
|
-
this.updateState({
|
|
301
|
-
status: {
|
|
302
|
-
...currentStatus,
|
|
303
|
-
files: currentStatus.files.map((f) => f.path === file.path && f.staged ? { ...f, staged: false } : f),
|
|
304
|
-
},
|
|
305
|
-
});
|
|
306
|
-
}
|
|
307
373
|
try {
|
|
308
374
|
await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
|
|
309
|
-
this.
|
|
375
|
+
this.scheduleStatusRefresh();
|
|
310
376
|
}
|
|
311
377
|
catch (err) {
|
|
312
378
|
await this.refresh();
|
|
@@ -385,27 +451,7 @@ export class GitStateManager extends EventEmitter {
|
|
|
385
451
|
async refreshCompareDiff(includeUncommitted = false) {
|
|
386
452
|
this.updateCompareState({ compareLoading: true, compareError: null });
|
|
387
453
|
try {
|
|
388
|
-
await this.queue.enqueue(
|
|
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
|
-
});
|
|
454
|
+
await this.queue.enqueue(() => this.doRefreshCompareDiff(includeUncommitted));
|
|
409
455
|
}
|
|
410
456
|
catch (err) {
|
|
411
457
|
this.updateCompareState({
|
|
@@ -414,6 +460,30 @@ export class GitStateManager extends EventEmitter {
|
|
|
414
460
|
});
|
|
415
461
|
}
|
|
416
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
|
+
}
|
|
417
487
|
/**
|
|
418
488
|
* Get candidate base branches for branch comparison.
|
|
419
489
|
*/
|
|
@@ -435,8 +505,7 @@ export class GitStateManager extends EventEmitter {
|
|
|
435
505
|
async loadHistory(count = 100) {
|
|
436
506
|
this.updateHistoryState({ isLoading: true });
|
|
437
507
|
try {
|
|
438
|
-
|
|
439
|
-
this.updateHistoryState({ commits, isLoading: false });
|
|
508
|
+
await this.queue.enqueue(() => this.doLoadHistory(count));
|
|
440
509
|
}
|
|
441
510
|
catch (err) {
|
|
442
511
|
this.updateHistoryState({ isLoading: false });
|
|
@@ -445,6 +514,13 @@ export class GitStateManager extends EventEmitter {
|
|
|
445
514
|
});
|
|
446
515
|
}
|
|
447
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
|
+
}
|
|
448
524
|
/**
|
|
449
525
|
* Select a commit in history view and load its diff.
|
|
450
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: {
|