diffstalker 0.1.6 → 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/.github/workflows/release.yml +5 -3
- package/CHANGELOG.md +36 -0
- package/bun.lock +378 -0
- package/dist/App.js +1162 -1
- package/dist/config.js +83 -2
- package/dist/core/ExplorerStateManager.js +266 -0
- package/dist/core/FilePathWatcher.js +133 -0
- package/dist/core/GitOperationQueue.js +109 -1
- package/dist/core/GitStateManager.js +525 -1
- package/dist/git/diff.js +471 -10
- package/dist/git/ignoreUtils.js +30 -0
- package/dist/git/status.js +237 -5
- package/dist/index.js +70 -16
- package/dist/ipc/CommandClient.js +165 -0
- package/dist/ipc/CommandServer.js +152 -0
- package/dist/services/commitService.js +22 -1
- package/dist/state/CommitFlowState.js +86 -0
- package/dist/state/UIState.js +182 -0
- package/dist/themes.js +127 -1
- 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/ansiTruncate.js +108 -0
- package/dist/utils/baseBranchCache.js +44 -2
- package/dist/utils/commitFormat.js +38 -1
- package/dist/utils/diffFilters.js +21 -1
- package/dist/utils/diffRowCalculations.js +113 -1
- package/dist/utils/displayRows.js +351 -2
- package/dist/utils/explorerDisplayRows.js +169 -0
- package/dist/utils/fileCategories.js +26 -1
- package/dist/utils/formatDate.js +39 -1
- package/dist/utils/formatPath.js +58 -1
- package/dist/utils/languageDetection.js +236 -0
- package/dist/utils/layoutCalculations.js +98 -1
- package/dist/utils/lineBreaking.js +88 -5
- package/dist/utils/mouseCoordinates.js +165 -1
- package/dist/utils/pathUtils.js +27 -0
- package/dist/utils/rowCalculations.js +246 -4
- package/dist/utils/wordDiff.js +50 -0
- package/package.json +15 -19
- package/dist/components/BaseBranchPicker.js +0 -1
- package/dist/components/BottomPane.js +0 -1
- package/dist/components/CommitPanel.js +0 -1
- package/dist/components/CompareListView.js +0 -1
- package/dist/components/ExplorerContentView.js +0 -3
- package/dist/components/ExplorerView.js +0 -1
- package/dist/components/FileList.js +0 -1
- package/dist/components/Footer.js +0 -1
- package/dist/components/Header.js +0 -1
- package/dist/components/HistoryView.js +0 -1
- package/dist/components/HotkeysModal.js +0 -1
- package/dist/components/Modal.js +0 -1
- package/dist/components/ScrollableList.js +0 -1
- package/dist/components/ThemePicker.js +0 -1
- package/dist/components/TopPane.js +0 -1
- package/dist/components/UnifiedDiffView.js +0 -1
- package/dist/hooks/useCommitFlow.js +0 -1
- package/dist/hooks/useCompareState.js +0 -1
- package/dist/hooks/useExplorerState.js +0 -9
- package/dist/hooks/useGit.js +0 -1
- package/dist/hooks/useHistoryState.js +0 -1
- package/dist/hooks/useKeymap.js +0 -1
- package/dist/hooks/useLayout.js +0 -1
- package/dist/hooks/useMouse.js +0 -1
- package/dist/hooks/useTerminalSize.js +0 -1
- package/dist/hooks/useWatcher.js +0 -11
package/dist/config.js
CHANGED
|
@@ -1,2 +1,83 @@
|
|
|
1
|
-
import*as
|
|
2
|
-
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
const defaultConfig = {
|
|
5
|
+
targetFile: path.join(os.homedir(), '.cache', 'diffstalker', 'target'),
|
|
6
|
+
watcherEnabled: false, // Watcher is opt-in via --follow
|
|
7
|
+
debug: false,
|
|
8
|
+
theme: 'dark',
|
|
9
|
+
};
|
|
10
|
+
const CONFIG_PATH = path.join(os.homedir(), '.config', 'diffstalker', 'config.json');
|
|
11
|
+
export const VALID_THEMES = [
|
|
12
|
+
'dark',
|
|
13
|
+
'light',
|
|
14
|
+
'dark-colorblind',
|
|
15
|
+
'light-colorblind',
|
|
16
|
+
'dark-ansi',
|
|
17
|
+
'light-ansi',
|
|
18
|
+
];
|
|
19
|
+
export function isValidTheme(theme) {
|
|
20
|
+
return typeof theme === 'string' && VALID_THEMES.includes(theme);
|
|
21
|
+
}
|
|
22
|
+
export function loadConfig() {
|
|
23
|
+
const config = { ...defaultConfig };
|
|
24
|
+
// Override from environment
|
|
25
|
+
if (process.env.DIFFSTALKER_PAGER) {
|
|
26
|
+
config.pager = process.env.DIFFSTALKER_PAGER;
|
|
27
|
+
}
|
|
28
|
+
// Try to load from config file
|
|
29
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
30
|
+
try {
|
|
31
|
+
const fileConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
32
|
+
if (fileConfig.pager)
|
|
33
|
+
config.pager = fileConfig.pager;
|
|
34
|
+
if (fileConfig.targetFile)
|
|
35
|
+
config.targetFile = fileConfig.targetFile;
|
|
36
|
+
if (isValidTheme(fileConfig.theme))
|
|
37
|
+
config.theme = fileConfig.theme;
|
|
38
|
+
if (typeof fileConfig.splitRatio === 'number' &&
|
|
39
|
+
fileConfig.splitRatio >= 0.15 &&
|
|
40
|
+
fileConfig.splitRatio <= 0.85) {
|
|
41
|
+
config.splitRatio = fileConfig.splitRatio;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Ignore config file errors
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return config;
|
|
49
|
+
}
|
|
50
|
+
export function saveConfig(updates) {
|
|
51
|
+
// Ensure config directory exists
|
|
52
|
+
const configDir = path.dirname(CONFIG_PATH);
|
|
53
|
+
if (!fs.existsSync(configDir)) {
|
|
54
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
55
|
+
}
|
|
56
|
+
// Load existing config or start fresh
|
|
57
|
+
let fileConfig = {};
|
|
58
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
59
|
+
try {
|
|
60
|
+
fileConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Start fresh if file is corrupted
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Apply updates
|
|
67
|
+
Object.assign(fileConfig, updates);
|
|
68
|
+
// Write back
|
|
69
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(fileConfig, null, 2) + '\n');
|
|
70
|
+
}
|
|
71
|
+
export function ensureTargetDir(targetFile) {
|
|
72
|
+
const dir = path.dirname(targetFile);
|
|
73
|
+
if (!fs.existsSync(dir)) {
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
export function abbreviateHomePath(fullPath) {
|
|
78
|
+
const home = os.homedir();
|
|
79
|
+
if (fullPath.startsWith(home)) {
|
|
80
|
+
return '~' + fullPath.slice(home.length);
|
|
81
|
+
}
|
|
82
|
+
return fullPath;
|
|
83
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -1 +1,109 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* GitOperationQueue - Serializes git operations to prevent index.lock conflicts.
|
|
3
|
+
*
|
|
4
|
+
* All git operations must go through this queue to ensure they execute
|
|
5
|
+
* sequentially, preventing concurrent access to the git index.
|
|
6
|
+
*/
|
|
7
|
+
export class GitOperationQueue {
|
|
8
|
+
queue = [];
|
|
9
|
+
isProcessing = false;
|
|
10
|
+
pendingMutations = 0; // Track pending stage/unstage operations
|
|
11
|
+
refreshScheduled = false; // Avoid duplicate refresh enqueues
|
|
12
|
+
/**
|
|
13
|
+
* Enqueue a git operation to be executed sequentially.
|
|
14
|
+
* Returns a promise that resolves when the operation completes.
|
|
15
|
+
*/
|
|
16
|
+
enqueue(operation) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
this.queue.push({
|
|
19
|
+
execute: operation,
|
|
20
|
+
resolve: resolve,
|
|
21
|
+
reject,
|
|
22
|
+
});
|
|
23
|
+
this.processNext();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Enqueue a mutation operation (stage/unstage) that should block refreshes.
|
|
28
|
+
* Refreshes are suppressed while any mutations are pending.
|
|
29
|
+
*/
|
|
30
|
+
enqueueMutation(operation) {
|
|
31
|
+
this.pendingMutations++;
|
|
32
|
+
return this.enqueue(operation).finally(() => {
|
|
33
|
+
this.pendingMutations--;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Check if there are pending mutation operations.
|
|
38
|
+
*/
|
|
39
|
+
hasPendingMutations() {
|
|
40
|
+
return this.pendingMutations > 0;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Schedule a refresh operation.
|
|
44
|
+
* Skips if there are pending mutations (the last mutation will trigger refresh).
|
|
45
|
+
* Skips if a refresh is already scheduled/queued.
|
|
46
|
+
*/
|
|
47
|
+
scheduleRefresh(callback) {
|
|
48
|
+
// If there are pending mutations, skip - the last mutation will trigger refresh
|
|
49
|
+
if (this.pendingMutations > 0) {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// If a refresh is already scheduled, skip
|
|
53
|
+
if (this.refreshScheduled) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this.refreshScheduled = true;
|
|
57
|
+
this.enqueue(async () => {
|
|
58
|
+
this.refreshScheduled = false;
|
|
59
|
+
await callback();
|
|
60
|
+
}).catch(() => {
|
|
61
|
+
this.refreshScheduled = false;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Check if the queue is currently processing or has pending operations.
|
|
66
|
+
*/
|
|
67
|
+
isBusy() {
|
|
68
|
+
return this.isProcessing || this.queue.length > 0;
|
|
69
|
+
}
|
|
70
|
+
async processNext() {
|
|
71
|
+
if (this.isProcessing || this.queue.length === 0) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
this.isProcessing = true;
|
|
75
|
+
const operation = this.queue.shift();
|
|
76
|
+
try {
|
|
77
|
+
const result = await operation.execute();
|
|
78
|
+
operation.resolve(result);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
operation.reject(error instanceof Error ? error : new Error(String(error)));
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
this.isProcessing = false;
|
|
85
|
+
// Process next operation if any
|
|
86
|
+
this.processNext();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// Global registry of queues per repo path to ensure single queue per repo
|
|
91
|
+
const queueRegistry = new Map();
|
|
92
|
+
/**
|
|
93
|
+
* Get the operation queue for a specific repository.
|
|
94
|
+
* Creates a new queue if one doesn't exist for this path.
|
|
95
|
+
*/
|
|
96
|
+
export function getQueueForRepo(repoPath) {
|
|
97
|
+
let queue = queueRegistry.get(repoPath);
|
|
98
|
+
if (!queue) {
|
|
99
|
+
queue = new GitOperationQueue();
|
|
100
|
+
queueRegistry.set(repoPath, queue);
|
|
101
|
+
}
|
|
102
|
+
return queue;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Remove a queue from the registry (for cleanup).
|
|
106
|
+
*/
|
|
107
|
+
export function removeQueueForRepo(repoPath) {
|
|
108
|
+
queueRegistry.delete(repoPath);
|
|
109
|
+
}
|