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.
Files changed (79) hide show
  1. package/.github/workflows/release.yml +5 -3
  2. package/CHANGELOG.md +36 -0
  3. package/bun.lock +378 -0
  4. package/dist/App.js +1162 -1
  5. package/dist/config.js +83 -2
  6. package/dist/core/ExplorerStateManager.js +266 -0
  7. package/dist/core/FilePathWatcher.js +133 -0
  8. package/dist/core/GitOperationQueue.js +109 -1
  9. package/dist/core/GitStateManager.js +525 -1
  10. package/dist/git/diff.js +471 -10
  11. package/dist/git/ignoreUtils.js +30 -0
  12. package/dist/git/status.js +237 -5
  13. package/dist/index.js +70 -16
  14. package/dist/ipc/CommandClient.js +165 -0
  15. package/dist/ipc/CommandServer.js +152 -0
  16. package/dist/services/commitService.js +22 -1
  17. package/dist/state/CommitFlowState.js +86 -0
  18. package/dist/state/UIState.js +182 -0
  19. package/dist/themes.js +127 -1
  20. package/dist/types/tabs.js +4 -0
  21. package/dist/ui/Layout.js +252 -0
  22. package/dist/ui/modals/BaseBranchPicker.js +110 -0
  23. package/dist/ui/modals/DiscardConfirm.js +77 -0
  24. package/dist/ui/modals/HotkeysModal.js +209 -0
  25. package/dist/ui/modals/ThemePicker.js +107 -0
  26. package/dist/ui/widgets/CommitPanel.js +58 -0
  27. package/dist/ui/widgets/CompareListView.js +216 -0
  28. package/dist/ui/widgets/DiffView.js +279 -0
  29. package/dist/ui/widgets/ExplorerContent.js +102 -0
  30. package/dist/ui/widgets/ExplorerView.js +95 -0
  31. package/dist/ui/widgets/FileList.js +185 -0
  32. package/dist/ui/widgets/Footer.js +46 -0
  33. package/dist/ui/widgets/Header.js +111 -0
  34. package/dist/ui/widgets/HistoryView.js +69 -0
  35. package/dist/utils/ansiToBlessed.js +125 -0
  36. package/dist/utils/ansiTruncate.js +108 -0
  37. package/dist/utils/baseBranchCache.js +44 -2
  38. package/dist/utils/commitFormat.js +38 -1
  39. package/dist/utils/diffFilters.js +21 -1
  40. package/dist/utils/diffRowCalculations.js +113 -1
  41. package/dist/utils/displayRows.js +351 -2
  42. package/dist/utils/explorerDisplayRows.js +169 -0
  43. package/dist/utils/fileCategories.js +26 -1
  44. package/dist/utils/formatDate.js +39 -1
  45. package/dist/utils/formatPath.js +58 -1
  46. package/dist/utils/languageDetection.js +236 -0
  47. package/dist/utils/layoutCalculations.js +98 -1
  48. package/dist/utils/lineBreaking.js +88 -5
  49. package/dist/utils/mouseCoordinates.js +165 -1
  50. package/dist/utils/pathUtils.js +27 -0
  51. package/dist/utils/rowCalculations.js +246 -4
  52. package/dist/utils/wordDiff.js +50 -0
  53. package/package.json +15 -19
  54. package/dist/components/BaseBranchPicker.js +0 -1
  55. package/dist/components/BottomPane.js +0 -1
  56. package/dist/components/CommitPanel.js +0 -1
  57. package/dist/components/CompareListView.js +0 -1
  58. package/dist/components/ExplorerContentView.js +0 -3
  59. package/dist/components/ExplorerView.js +0 -1
  60. package/dist/components/FileList.js +0 -1
  61. package/dist/components/Footer.js +0 -1
  62. package/dist/components/Header.js +0 -1
  63. package/dist/components/HistoryView.js +0 -1
  64. package/dist/components/HotkeysModal.js +0 -1
  65. package/dist/components/Modal.js +0 -1
  66. package/dist/components/ScrollableList.js +0 -1
  67. package/dist/components/ThemePicker.js +0 -1
  68. package/dist/components/TopPane.js +0 -1
  69. package/dist/components/UnifiedDiffView.js +0 -1
  70. package/dist/hooks/useCommitFlow.js +0 -1
  71. package/dist/hooks/useCompareState.js +0 -1
  72. package/dist/hooks/useExplorerState.js +0 -9
  73. package/dist/hooks/useGit.js +0 -1
  74. package/dist/hooks/useHistoryState.js +0 -1
  75. package/dist/hooks/useKeymap.js +0 -1
  76. package/dist/hooks/useLayout.js +0 -1
  77. package/dist/hooks/useMouse.js +0 -1
  78. package/dist/hooks/useTerminalSize.js +0 -1
  79. package/dist/hooks/useWatcher.js +0 -11
package/dist/config.js CHANGED
@@ -1,2 +1,83 @@
1
- import*as t from"node:fs";import*as n from"node:path";import*as s from"node:os";const a={targetFile:n.join(s.homedir(),".cache","diffstalker","target"),watcherEnabled:!1,debug:!1,theme:"dark"},r=n.join(s.homedir(),".config","diffstalker","config.json");export const VALID_THEMES=["dark","light","dark-colorblind","light-colorblind","dark-ansi","light-ansi"];export function isValidTheme(i){return typeof i=="string"&&VALID_THEMES.includes(i)}export function loadConfig(){const i={...a};if(process.env.DIFFSTALKER_PAGER&&(i.pager=process.env.DIFFSTALKER_PAGER),t.existsSync(r))try{const e=JSON.parse(t.readFileSync(r,"utf-8"));e.pager&&(i.pager=e.pager),e.targetFile&&(i.targetFile=e.targetFile),isValidTheme(e.theme)&&(i.theme=e.theme),typeof e.splitRatio=="number"&&e.splitRatio>=.15&&e.splitRatio<=.85&&(i.splitRatio=e.splitRatio)}catch{}return i}export function saveConfig(i){const e=n.dirname(r);t.existsSync(e)||t.mkdirSync(e,{recursive:!0});let o={};if(t.existsSync(r))try{o=JSON.parse(t.readFileSync(r,"utf-8"))}catch{}Object.assign(o,i),t.writeFileSync(r,JSON.stringify(o,null,2)+`
2
- `)}export function ensureTargetDir(i){const e=n.dirname(i);t.existsSync(e)||t.mkdirSync(e,{recursive:!0})}export function abbreviateHomePath(i){const e=s.homedir();return i.startsWith(e)?"~"+i.slice(e.length):i}
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
- export class GitOperationQueue{queue=[];isProcessing=!1;pendingMutations=0;refreshScheduled=!1;enqueue(e){return new Promise((s,r)=>{this.queue.push({execute:e,resolve:s,reject:r}),this.processNext()})}enqueueMutation(e){return this.pendingMutations++,this.enqueue(e).finally(()=>{this.pendingMutations--})}hasPendingMutations(){return this.pendingMutations>0}scheduleRefresh(e){this.pendingMutations>0||this.refreshScheduled||(this.refreshScheduled=!0,this.enqueue(async()=>{this.refreshScheduled=!1,await e()}).catch(()=>{this.refreshScheduled=!1}))}isBusy(){return this.isProcessing||this.queue.length>0}async processNext(){if(this.isProcessing||this.queue.length===0)return;this.isProcessing=!0;const e=this.queue.shift();try{const s=await e.execute();e.resolve(s)}catch(s){e.reject(s instanceof Error?s:new Error(String(s)))}finally{this.isProcessing=!1,this.processNext()}}}const i=new Map;export function getQueueForRepo(t){let e=i.get(t);return e||(e=new GitOperationQueue,i.set(t,e)),e}export function removeQueueForRepo(t){i.delete(t)}
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
+ }