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.
- package/.github/workflows/release.yml +8 -0
- package/CHANGELOG.md +36 -0
- package/bun.lock +89 -306
- package/dist/App.js +895 -520
- package/dist/FollowMode.js +85 -0
- package/dist/KeyBindings.js +178 -0
- package/dist/MouseHandlers.js +156 -0
- package/dist/core/ExplorerStateManager.js +632 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitStateManager.js +221 -86
- package/dist/git/diff.js +4 -0
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +2 -34
- package/dist/index.js +68 -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 +195 -0
- package/dist/types/tabs.js +4 -0
- package/dist/ui/Layout.js +252 -0
- package/dist/ui/PaneRenderers.js +56 -0
- package/dist/ui/modals/BaseBranchPicker.js +110 -0
- package/dist/ui/modals/DiscardConfirm.js +77 -0
- package/dist/ui/modals/FileFinder.js +232 -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 +238 -0
- package/dist/ui/widgets/DiffView.js +281 -0
- package/dist/ui/widgets/ExplorerContent.js +89 -0
- package/dist/ui/widgets/ExplorerView.js +204 -0
- package/dist/ui/widgets/FileList.js +185 -0
- package/dist/ui/widgets/Footer.js +50 -0
- package/dist/ui/widgets/Header.js +68 -0
- package/dist/ui/widgets/HistoryView.js +69 -0
- package/dist/utils/displayRows.js +185 -6
- package/dist/utils/explorerDisplayRows.js +1 -1
- package/dist/utils/fileCategories.js +37 -0
- package/dist/utils/fileTree.js +148 -0
- package/dist/utils/languageDetection.js +56 -0
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/wordDiff.js +50 -0
- package/eslint.metrics.js +16 -0
- package/metrics/.gitkeep +0 -0
- package/metrics/v0.2.1.json +268 -0
- package/package.json +14 -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
- package/dist/utils/mouseCoordinates.js +0 -165
- 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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
'
|
|
102
|
-
|
|
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',
|
|
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(() =>
|
|
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
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
302
|
+
selectFile(file) {
|
|
205
303
|
this.updateState({ selectedFile: file });
|
|
206
304
|
if (!this._state.status?.isRepo)
|
|
207
305
|
return;
|
|
208
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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.
|
|
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(
|
|
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
|
+
}
|