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
@@ -1 +1,525 @@
1
- import*as h from"node:path";import*as m from"node:fs";import{watch as l}from"chokidar";import{EventEmitter as g}from"node:events";import{getQueueForRepo as S,removeQueueForRepo as y}from"./GitOperationQueue.js";import{getStatus as w,stageFile as D,unstageFile as C,stageAll as _,unstageAll as F,discardChanges as q,commit as P,getHeadMessage as B}from"../git/status.js";import{getDiff as c,getDiffForUntracked as p,getStagedDiff as f,getDefaultBaseBranch as E,getCandidateBaseBranches as R,getDiffBetweenRefs as W,getCompareDiffWithUncommitted as k,getCommitDiff as d}from"../git/diff.js";import{getCachedBaseBranch as $,setCachedBaseBranch as M}from"../utils/baseBranchCache.js";export class GitStateManager extends g{repoPath;queue;gitWatcher=null;workingDirWatcher=null;_state={status:null,diff:null,stagedDiff:"",selectedFile:null,isLoading:!1,error:null};_compareState={compareDiff:null,compareBaseBranch:null,compareLoading:!1,compareError:null};_historyState={selectedCommit:null,commitDiff:null};_compareSelectionState={type:null,index:0,diff:null};constructor(t){super(),this.repoPath=t,this.queue=S(t)}get state(){return this._state}get compareState(){return this._compareState}get historyState(){return this._historyState}get compareSelectionState(){return this._compareSelectionState}updateState(t){this._state={...this._state,...t},this.emit("state-change",this._state)}updateCompareState(t){this._compareState={...this._compareState,...t},this.emit("compare-state-change",this._compareState)}updateHistoryState(t){this._historyState={...this._historyState,...t},this.emit("history-state-change",this._historyState)}updateCompareSelectionState(t){this._compareSelectionState={...this._compareSelectionState,...t},this.emit("compare-selection-change",this._compareSelectionState)}startWatching(){const t=h.join(this.repoPath,".git");if(!m.existsSync(t))return;const e=h.join(t,"index"),a=h.join(t,"HEAD"),s=h.join(t,"refs");this.gitWatcher=l([e,a,s],{persistent:!0,ignoreInitial:!0,awaitWriteFinish:{stabilityThreshold:100,pollInterval:50}}),this.workingDirWatcher=l(this.repoPath,{persistent:!0,ignoreInitial:!0,ignored:["**/node_modules/**","**/.git/**","**/dist/**","**/build/**","**/*.log","**/.DS_Store"],awaitWriteFinish:{stabilityThreshold:100,pollInterval:50},depth:10});const i=()=>this.scheduleRefresh();this.gitWatcher.on("change",i),this.gitWatcher.on("add",i),this.gitWatcher.on("unlink",i),this.workingDirWatcher.on("change",i),this.workingDirWatcher.on("add",i),this.workingDirWatcher.on("unlink",i)}dispose(){this.gitWatcher?.close(),this.workingDirWatcher?.close(),y(this.repoPath)}scheduleRefresh(){this.queue.scheduleRefresh(()=>this.doRefresh())}async refresh(){await this.queue.enqueue(()=>this.doRefresh())}async doRefresh(){this.updateState({isLoading:!0,error:null});try{const t=await w(this.repoPath);if(!t.isRepo){this.updateState({status:t,diff:null,stagedDiff:"",isLoading:!1,error:"Not a git repository"});return}const[e,a]=await Promise.all([f(this.repoPath),c(this.repoPath,void 0,!1)]);let s;const i=this._state.selectedFile;if(i){const o=t.files.find(u=>u.path===i.path&&u.staged===i.staged);o?o.status==="untracked"?s=await p(this.repoPath,o.path):s=await c(this.repoPath,o.path,o.staged):(s=a.raw?a:e,this.updateState({selectedFile:null}))}else a.raw?s=a:e.raw?s=e:s={raw:"",lines:[]};this.updateState({status:t,diff:s,stagedDiff:e.raw,isLoading:!1})}catch(t){this.updateState({isLoading:!1,error:t instanceof Error?t.message:"Unknown error"})}}async selectFile(t){this.updateState({selectedFile:t}),this._state.status?.isRepo&&await this.queue.enqueue(async()=>{if(t){let e;t.status==="untracked"?e=await p(this.repoPath,t.path):e=await c(this.repoPath,t.path,t.staged),this.updateState({diff:e})}else{const e=await f(this.repoPath);this.updateState({diff:e})}})}async stage(t){const e=this._state.status;e&&this.updateState({status:{...e,files:e.files.map(a=>a.path===t.path&&!a.staged?{...a,staged:!0}:a)}});try{await this.queue.enqueueMutation(()=>D(this.repoPath,t.path)),this.scheduleRefresh()}catch(a){await this.refresh(),this.updateState({error:`Failed to stage ${t.path}: ${a instanceof Error?a.message:String(a)}`})}}async unstage(t){const e=this._state.status;e&&this.updateState({status:{...e,files:e.files.map(a=>a.path===t.path&&a.staged?{...a,staged:!1}:a)}});try{await this.queue.enqueueMutation(()=>C(this.repoPath,t.path)),this.scheduleRefresh()}catch(a){await this.refresh(),this.updateState({error:`Failed to unstage ${t.path}: ${a instanceof Error?a.message:String(a)}`})}}async discard(t){if(!(t.staged||t.status==="untracked"))try{await this.queue.enqueueMutation(()=>q(this.repoPath,t.path)),await this.refresh()}catch(e){this.updateState({error:`Failed to discard ${t.path}: ${e instanceof Error?e.message:String(e)}`})}}async stageAll(){try{await this.queue.enqueueMutation(()=>_(this.repoPath)),await this.refresh()}catch(t){this.updateState({error:`Failed to stage all: ${t instanceof Error?t.message:String(t)}`})}}async unstageAll(){try{await this.queue.enqueueMutation(()=>F(this.repoPath)),await this.refresh()}catch(t){this.updateState({error:`Failed to unstage all: ${t instanceof Error?t.message:String(t)}`})}}async commit(t,e=!1){try{await this.queue.enqueue(()=>P(this.repoPath,t,e)),await this.refresh()}catch(a){this.updateState({error:`Failed to commit: ${a instanceof Error?a.message:String(a)}`})}}async getHeadCommitMessage(){return this.queue.enqueue(()=>B(this.repoPath))}async refreshCompareDiff(t=!1){this.updateCompareState({compareLoading:!0,compareError:null});try{await this.queue.enqueue(async()=>{let e=this._compareState.compareBaseBranch;if(e||(e=$(this.repoPath)??await E(this.repoPath),this.updateCompareState({compareBaseBranch:e})),e){const a=t?await k(this.repoPath,e):await W(this.repoPath,e);this.updateCompareState({compareDiff:a,compareLoading:!1})}else this.updateCompareState({compareDiff:null,compareLoading:!1,compareError:"No base branch found"})})}catch(e){this.updateCompareState({compareLoading:!1,compareError:`Failed to load compare diff: ${e instanceof Error?e.message:String(e)}`})}}async getCandidateBaseBranches(){return R(this.repoPath)}async setCompareBaseBranch(t,e=!1){this.updateCompareState({compareBaseBranch:t}),M(this.repoPath,t),await this.refreshCompareDiff(e)}async selectHistoryCommit(t){if(this.updateHistoryState({selectedCommit:t,commitDiff:null}),!!t)try{await this.queue.enqueue(async()=>{const e=await d(this.repoPath,t.hash);this.updateHistoryState({commitDiff:e})})}catch(e){this.updateState({error:`Failed to load commit diff: ${e instanceof Error?e.message:String(e)}`})}}async selectCompareCommit(t){const e=this._compareState.compareDiff;if(!e||t<0||t>=e.commits.length){this.updateCompareSelectionState({type:null,index:0,diff:null});return}const a=e.commits[t];this.updateCompareSelectionState({type:"commit",index:t,diff:null});try{await this.queue.enqueue(async()=>{const s=await d(this.repoPath,a.hash);this.updateCompareSelectionState({diff:s})})}catch(s){this.updateState({error:`Failed to load commit diff: ${s instanceof Error?s.message:String(s)}`})}}selectCompareFile(t){const e=this._compareState.compareDiff;if(!e||t<0||t>=e.files.length){this.updateCompareSelectionState({type:null,index:0,diff:null});return}const a=e.files[t];this.updateCompareSelectionState({type:"file",index:t,diff:a.diff})}}const n=new Map;export function getManagerForRepo(r){let t=n.get(r);return t||(t=new GitStateManager(r),n.set(r,t)),t}export function removeManagerForRepo(r){const t=n.get(r);t&&(t.dispose(),n.delete(r))}
1
+ import * as path from 'node:path';
2
+ import * as fs from 'node:fs';
3
+ import { watch } from 'chokidar';
4
+ import { EventEmitter } from 'node:events';
5
+ import ignore from 'ignore';
6
+ import { getQueueForRepo, removeQueueForRepo } from './GitOperationQueue.js';
7
+ import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, } from '../git/status.js';
8
+ import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, } from '../git/diff.js';
9
+ import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
10
+ /**
11
+ * GitStateManager manages git state independent of React.
12
+ * It owns the operation queue, file watchers, and emits events on state changes.
13
+ */
14
+ export class GitStateManager extends EventEmitter {
15
+ repoPath;
16
+ queue;
17
+ gitWatcher = null;
18
+ workingDirWatcher = null;
19
+ ignorer = null;
20
+ // Current state
21
+ _state = {
22
+ status: null,
23
+ diff: null,
24
+ stagedDiff: '',
25
+ selectedFile: null,
26
+ isLoading: false,
27
+ error: null,
28
+ };
29
+ _compareState = {
30
+ compareDiff: null,
31
+ compareBaseBranch: null,
32
+ compareLoading: false,
33
+ compareError: null,
34
+ };
35
+ _historyState = {
36
+ commits: [],
37
+ selectedCommit: null,
38
+ commitDiff: null,
39
+ isLoading: false,
40
+ };
41
+ _compareSelectionState = {
42
+ type: null,
43
+ index: 0,
44
+ diff: null,
45
+ };
46
+ constructor(repoPath) {
47
+ super();
48
+ this.repoPath = repoPath;
49
+ this.queue = getQueueForRepo(repoPath);
50
+ }
51
+ get state() {
52
+ return this._state;
53
+ }
54
+ get compareState() {
55
+ return this._compareState;
56
+ }
57
+ get historyState() {
58
+ return this._historyState;
59
+ }
60
+ get compareSelectionState() {
61
+ return this._compareSelectionState;
62
+ }
63
+ updateState(partial) {
64
+ this._state = { ...this._state, ...partial };
65
+ this.emit('state-change', this._state);
66
+ }
67
+ updateCompareState(partial) {
68
+ this._compareState = { ...this._compareState, ...partial };
69
+ this.emit('compare-state-change', this._compareState);
70
+ }
71
+ updateHistoryState(partial) {
72
+ this._historyState = { ...this._historyState, ...partial };
73
+ this.emit('history-state-change', this._historyState);
74
+ }
75
+ updateCompareSelectionState(partial) {
76
+ this._compareSelectionState = { ...this._compareSelectionState, ...partial };
77
+ this.emit('compare-selection-change', this._compareSelectionState);
78
+ }
79
+ /**
80
+ * Load gitignore patterns from .gitignore and .git/info/exclude.
81
+ * Returns an Ignore instance that can test paths.
82
+ */
83
+ loadGitignore() {
84
+ const ig = ignore();
85
+ // Always ignore .git directory (has its own dedicated watcher)
86
+ ig.add('.git');
87
+ // Load .gitignore if it exists
88
+ const gitignorePath = path.join(this.repoPath, '.gitignore');
89
+ if (fs.existsSync(gitignorePath)) {
90
+ ig.add(fs.readFileSync(gitignorePath, 'utf-8'));
91
+ }
92
+ // Load .git/info/exclude if it exists (repo-specific ignores)
93
+ const excludePath = path.join(this.repoPath, '.git', 'info', 'exclude');
94
+ if (fs.existsSync(excludePath)) {
95
+ ig.add(fs.readFileSync(excludePath, 'utf-8'));
96
+ }
97
+ return ig;
98
+ }
99
+ /**
100
+ * Start watching for file changes.
101
+ */
102
+ startWatching() {
103
+ const gitDir = path.join(this.repoPath, '.git');
104
+ if (!fs.existsSync(gitDir))
105
+ return;
106
+ // --- Git internals watcher ---
107
+ const indexFile = path.join(gitDir, 'index');
108
+ const headFile = path.join(gitDir, 'HEAD');
109
+ const refsDir = path.join(gitDir, 'refs');
110
+ const gitignorePath = path.join(this.repoPath, '.gitignore');
111
+ // Git uses atomic writes (write to temp, then rename). We use polling
112
+ // for reliable detection of these atomic operations.
113
+ this.gitWatcher = watch([indexFile, headFile, refsDir, gitignorePath], {
114
+ persistent: true,
115
+ ignoreInitial: true,
116
+ usePolling: true,
117
+ interval: 100,
118
+ });
119
+ // --- Working directory watcher with gitignore support ---
120
+ this.ignorer = this.loadGitignore();
121
+ this.workingDirWatcher = watch(this.repoPath, {
122
+ persistent: true,
123
+ ignoreInitial: true,
124
+ ignored: (filePath) => {
125
+ // Get path relative to repo root
126
+ const relativePath = path.relative(this.repoPath, filePath);
127
+ // Don't ignore the repo root itself
128
+ if (!relativePath)
129
+ return false;
130
+ // Check against gitignore patterns
131
+ // When this returns true for a directory, chokidar won't recurse into it
132
+ return this.ignorer?.ignores(relativePath) ?? false;
133
+ },
134
+ awaitWriteFinish: {
135
+ stabilityThreshold: 100,
136
+ pollInterval: 50,
137
+ },
138
+ });
139
+ const scheduleRefresh = () => this.scheduleRefresh();
140
+ this.gitWatcher.on('change', (filePath) => {
141
+ // Reload gitignore patterns if .gitignore changed
142
+ if (filePath === gitignorePath) {
143
+ this.ignorer = this.loadGitignore();
144
+ }
145
+ scheduleRefresh();
146
+ });
147
+ this.gitWatcher.on('add', scheduleRefresh);
148
+ this.gitWatcher.on('unlink', scheduleRefresh);
149
+ this.gitWatcher.on('error', (err) => {
150
+ const message = err instanceof Error ? err.message : String(err);
151
+ this.emit('error', `Git watcher error: ${message}`);
152
+ });
153
+ this.workingDirWatcher.on('change', scheduleRefresh);
154
+ this.workingDirWatcher.on('add', scheduleRefresh);
155
+ this.workingDirWatcher.on('unlink', scheduleRefresh);
156
+ this.workingDirWatcher.on('error', (err) => {
157
+ const message = err instanceof Error ? err.message : String(err);
158
+ this.emit('error', `Working dir watcher error: ${message}`);
159
+ });
160
+ }
161
+ /**
162
+ * Stop watching and clean up resources.
163
+ */
164
+ dispose() {
165
+ this.gitWatcher?.close();
166
+ this.workingDirWatcher?.close();
167
+ removeQueueForRepo(this.repoPath);
168
+ }
169
+ /**
170
+ * Schedule a refresh (coalesced if one is already pending).
171
+ */
172
+ scheduleRefresh() {
173
+ this.queue.scheduleRefresh(() => this.doRefresh());
174
+ }
175
+ /**
176
+ * Immediately refresh git state.
177
+ */
178
+ async refresh() {
179
+ await this.queue.enqueue(() => this.doRefresh());
180
+ }
181
+ async doRefresh() {
182
+ this.updateState({ isLoading: true, error: null });
183
+ try {
184
+ const newStatus = await getStatus(this.repoPath);
185
+ if (!newStatus.isRepo) {
186
+ this.updateState({
187
+ status: newStatus,
188
+ diff: null,
189
+ stagedDiff: '',
190
+ isLoading: false,
191
+ error: 'Not a git repository',
192
+ });
193
+ return;
194
+ }
195
+ // Fetch all diffs atomically
196
+ const [allStagedDiff, allUnstagedDiff] = await Promise.all([
197
+ getStagedDiff(this.repoPath),
198
+ getDiff(this.repoPath, undefined, false),
199
+ ]);
200
+ // Determine display diff based on selected file
201
+ let displayDiff;
202
+ const currentSelectedFile = this._state.selectedFile;
203
+ if (currentSelectedFile) {
204
+ const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged);
205
+ if (currentFile) {
206
+ if (currentFile.status === 'untracked') {
207
+ displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
208
+ }
209
+ else {
210
+ displayDiff = await getDiff(this.repoPath, currentFile.path, currentFile.staged);
211
+ }
212
+ }
213
+ else {
214
+ // File no longer exists - clear selection
215
+ displayDiff = allUnstagedDiff.raw ? allUnstagedDiff : allStagedDiff;
216
+ this.updateState({ selectedFile: null });
217
+ }
218
+ }
219
+ else {
220
+ if (allUnstagedDiff.raw) {
221
+ displayDiff = allUnstagedDiff;
222
+ }
223
+ else if (allStagedDiff.raw) {
224
+ displayDiff = allStagedDiff;
225
+ }
226
+ else {
227
+ displayDiff = { raw: '', lines: [] };
228
+ }
229
+ }
230
+ this.updateState({
231
+ status: newStatus,
232
+ diff: displayDiff,
233
+ stagedDiff: allStagedDiff.raw,
234
+ isLoading: false,
235
+ });
236
+ }
237
+ catch (err) {
238
+ this.updateState({
239
+ isLoading: false,
240
+ error: err instanceof Error ? err.message : 'Unknown error',
241
+ });
242
+ }
243
+ }
244
+ /**
245
+ * Select a file and update the diff display.
246
+ */
247
+ async selectFile(file) {
248
+ this.updateState({ selectedFile: file });
249
+ if (!this._state.status?.isRepo)
250
+ return;
251
+ await this.queue.enqueue(async () => {
252
+ if (file) {
253
+ let fileDiff;
254
+ if (file.status === 'untracked') {
255
+ fileDiff = await getDiffForUntracked(this.repoPath, file.path);
256
+ }
257
+ else {
258
+ fileDiff = await getDiff(this.repoPath, file.path, file.staged);
259
+ }
260
+ this.updateState({ diff: fileDiff });
261
+ }
262
+ else {
263
+ const allDiff = await getStagedDiff(this.repoPath);
264
+ this.updateState({ diff: allDiff });
265
+ }
266
+ });
267
+ }
268
+ /**
269
+ * Stage a file with optimistic update.
270
+ */
271
+ 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
+ try {
283
+ await this.queue.enqueueMutation(() => stageFile(this.repoPath, file.path));
284
+ this.scheduleRefresh();
285
+ }
286
+ catch (err) {
287
+ await this.refresh();
288
+ this.updateState({
289
+ error: `Failed to stage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
290
+ });
291
+ }
292
+ }
293
+ /**
294
+ * Unstage a file with optimistic update.
295
+ */
296
+ 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
+ try {
308
+ await this.queue.enqueueMutation(() => unstageFile(this.repoPath, file.path));
309
+ this.scheduleRefresh();
310
+ }
311
+ catch (err) {
312
+ await this.refresh();
313
+ this.updateState({
314
+ error: `Failed to unstage ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
315
+ });
316
+ }
317
+ }
318
+ /**
319
+ * Discard changes to a file.
320
+ */
321
+ async discard(file) {
322
+ if (file.staged || file.status === 'untracked')
323
+ return;
324
+ try {
325
+ await this.queue.enqueueMutation(() => gitDiscardChanges(this.repoPath, file.path));
326
+ await this.refresh();
327
+ }
328
+ catch (err) {
329
+ this.updateState({
330
+ error: `Failed to discard ${file.path}: ${err instanceof Error ? err.message : String(err)}`,
331
+ });
332
+ }
333
+ }
334
+ /**
335
+ * Stage all files.
336
+ */
337
+ async stageAll() {
338
+ try {
339
+ await this.queue.enqueueMutation(() => gitStageAll(this.repoPath));
340
+ await this.refresh();
341
+ }
342
+ catch (err) {
343
+ this.updateState({
344
+ error: `Failed to stage all: ${err instanceof Error ? err.message : String(err)}`,
345
+ });
346
+ }
347
+ }
348
+ /**
349
+ * Unstage all files.
350
+ */
351
+ async unstageAll() {
352
+ try {
353
+ await this.queue.enqueueMutation(() => gitUnstageAll(this.repoPath));
354
+ await this.refresh();
355
+ }
356
+ catch (err) {
357
+ this.updateState({
358
+ error: `Failed to unstage all: ${err instanceof Error ? err.message : String(err)}`,
359
+ });
360
+ }
361
+ }
362
+ /**
363
+ * Create a commit.
364
+ */
365
+ async commit(message, amend = false) {
366
+ try {
367
+ await this.queue.enqueue(() => gitCommit(this.repoPath, message, amend));
368
+ await this.refresh();
369
+ }
370
+ catch (err) {
371
+ this.updateState({
372
+ error: `Failed to commit: ${err instanceof Error ? err.message : String(err)}`,
373
+ });
374
+ }
375
+ }
376
+ /**
377
+ * Get the HEAD commit message.
378
+ */
379
+ async getHeadCommitMessage() {
380
+ return this.queue.enqueue(() => getHeadMessage(this.repoPath));
381
+ }
382
+ /**
383
+ * Refresh compare diff.
384
+ */
385
+ async refreshCompareDiff(includeUncommitted = false) {
386
+ this.updateCompareState({ compareLoading: true, compareError: null });
387
+ try {
388
+ await this.queue.enqueue(async () => {
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
+ });
409
+ }
410
+ catch (err) {
411
+ this.updateCompareState({
412
+ compareLoading: false,
413
+ compareError: `Failed to load compare diff: ${err instanceof Error ? err.message : String(err)}`,
414
+ });
415
+ }
416
+ }
417
+ /**
418
+ * Get candidate base branches for branch comparison.
419
+ */
420
+ async getCandidateBaseBranches() {
421
+ return getCandidateBaseBranches(this.repoPath);
422
+ }
423
+ /**
424
+ * Set the base branch for branch comparison and refresh.
425
+ * Also saves the selection to the cache for future sessions.
426
+ */
427
+ async setCompareBaseBranch(branch, includeUncommitted = false) {
428
+ this.updateCompareState({ compareBaseBranch: branch });
429
+ setCachedBaseBranch(this.repoPath, branch);
430
+ await this.refreshCompareDiff(includeUncommitted);
431
+ }
432
+ /**
433
+ * Load commit history for the history view.
434
+ */
435
+ async loadHistory(count = 100) {
436
+ this.updateHistoryState({ isLoading: true });
437
+ try {
438
+ const commits = await this.queue.enqueue(() => getCommitHistory(this.repoPath, count));
439
+ this.updateHistoryState({ commits, isLoading: false });
440
+ }
441
+ catch (err) {
442
+ this.updateHistoryState({ isLoading: false });
443
+ this.updateState({
444
+ error: `Failed to load history: ${err instanceof Error ? err.message : String(err)}`,
445
+ });
446
+ }
447
+ }
448
+ /**
449
+ * Select a commit in history view and load its diff.
450
+ */
451
+ async selectHistoryCommit(commit) {
452
+ this.updateHistoryState({ selectedCommit: commit, commitDiff: null });
453
+ if (!commit)
454
+ return;
455
+ try {
456
+ await this.queue.enqueue(async () => {
457
+ const diff = await getCommitDiff(this.repoPath, commit.hash);
458
+ this.updateHistoryState({ commitDiff: diff });
459
+ });
460
+ }
461
+ catch (err) {
462
+ this.updateState({
463
+ error: `Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`,
464
+ });
465
+ }
466
+ }
467
+ /**
468
+ * Select a commit in compare view and load its diff.
469
+ */
470
+ async selectCompareCommit(index) {
471
+ const compareDiff = this._compareState.compareDiff;
472
+ if (!compareDiff || index < 0 || index >= compareDiff.commits.length) {
473
+ this.updateCompareSelectionState({ type: null, index: 0, diff: null });
474
+ return;
475
+ }
476
+ const commit = compareDiff.commits[index];
477
+ this.updateCompareSelectionState({ type: 'commit', index, diff: null });
478
+ try {
479
+ await this.queue.enqueue(async () => {
480
+ const diff = await getCommitDiff(this.repoPath, commit.hash);
481
+ this.updateCompareSelectionState({ diff });
482
+ });
483
+ }
484
+ catch (err) {
485
+ this.updateState({
486
+ error: `Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`,
487
+ });
488
+ }
489
+ }
490
+ /**
491
+ * Select a file in compare view and show its diff.
492
+ */
493
+ selectCompareFile(index) {
494
+ const compareDiff = this._compareState.compareDiff;
495
+ if (!compareDiff || index < 0 || index >= compareDiff.files.length) {
496
+ this.updateCompareSelectionState({ type: null, index: 0, diff: null });
497
+ return;
498
+ }
499
+ const file = compareDiff.files[index];
500
+ this.updateCompareSelectionState({ type: 'file', index, diff: file.diff });
501
+ }
502
+ }
503
+ // Registry of managers per repo path
504
+ const managerRegistry = new Map();
505
+ /**
506
+ * Get the state manager for a specific repository.
507
+ */
508
+ export function getManagerForRepo(repoPath) {
509
+ let manager = managerRegistry.get(repoPath);
510
+ if (!manager) {
511
+ manager = new GitStateManager(repoPath);
512
+ managerRegistry.set(repoPath, manager);
513
+ }
514
+ return manager;
515
+ }
516
+ /**
517
+ * Remove a manager from the registry.
518
+ */
519
+ export function removeManagerForRepo(repoPath) {
520
+ const manager = managerRegistry.get(repoPath);
521
+ if (manager) {
522
+ manager.dispose();
523
+ managerRegistry.delete(repoPath);
524
+ }
525
+ }