diffstalker 0.2.2 → 0.2.3

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/dist/App.js CHANGED
@@ -15,6 +15,10 @@ import { HotkeysModal } from './ui/modals/HotkeysModal.js';
15
15
  import { BaseBranchPicker } from './ui/modals/BaseBranchPicker.js';
16
16
  import { DiscardConfirm } from './ui/modals/DiscardConfirm.js';
17
17
  import { FileFinder } from './ui/modals/FileFinder.js';
18
+ import { StashListModal } from './ui/modals/StashListModal.js';
19
+ import { BranchPicker } from './ui/modals/BranchPicker.js';
20
+ import { SoftResetConfirm } from './ui/modals/SoftResetConfirm.js';
21
+ import { CommitActionConfirm } from './ui/modals/CommitActionConfirm.js';
18
22
  import { CommitFlowState } from './state/CommitFlowState.js';
19
23
  import { UIState } from './state/UIState.js';
20
24
  import { getManagerForRepo, removeManagerForRepo, } from './core/GitStateManager.js';
@@ -43,6 +47,8 @@ export class App {
43
47
  commitTextarea = null;
44
48
  // Active modals
45
49
  activeModal = null;
50
+ // Auto-clear timer for remote operation status
51
+ remoteClearTimer = null;
46
52
  // Cached total rows and hunk info for scroll bounds (single source of truth from render)
47
53
  bottomPaneTotalRows = 0;
48
54
  bottomPaneHunkCount = 0;
@@ -170,11 +176,22 @@ export class App {
170
176
  toggleCurrentHunk: () => this.toggleCurrentHunk(),
171
177
  navigateNextHunk: () => this.navigateNextHunk(),
172
178
  navigatePrevHunk: () => this.navigatePrevHunk(),
179
+ push: () => this.gitManager?.push(),
180
+ fetchRemote: () => this.gitManager?.fetchRemote(),
181
+ pullRebase: () => this.gitManager?.pullRebase(),
182
+ stash: () => this.gitManager?.stash(),
183
+ stashPop: () => this.gitManager?.stashPop(),
184
+ openStashListModal: () => this.openStashListModal(),
185
+ openBranchPicker: () => this.openBranchPicker(),
186
+ showSoftResetConfirm: () => this.showSoftResetConfirm(),
187
+ cherryPickSelected: () => this.cherryPickSelected(),
188
+ revertSelected: () => this.revertSelected(),
173
189
  }, {
174
190
  hasActiveModal: () => this.activeModal !== null,
175
191
  getBottomTab: () => this.uiState.state.bottomTab,
176
192
  getCurrentPane: () => this.uiState.state.currentPane,
177
193
  isCommitInputFocused: () => this.commitFlowState.state.inputFocused,
194
+ isRemoteInProgress: () => this.gitManager?.remoteState.inProgress ?? false,
178
195
  getStatusFiles: () => this.gitManager?.state.status?.files ?? [],
179
196
  getSelectedIndex: () => this.uiState.state.selectedIndex,
180
197
  uiState: this.uiState,
@@ -195,6 +212,11 @@ export class App {
195
212
  toggleMouseMode: () => this.toggleMouseMode(),
196
213
  toggleFollow: () => this.toggleFollow(),
197
214
  selectHunkAtRow: (row) => this.selectHunkAtRow(row),
215
+ focusCommitInput: () => this.focusCommitInput(),
216
+ toggleAmend: () => {
217
+ this.commitFlowState.toggleAmend();
218
+ this.render();
219
+ },
198
220
  render: () => this.render(),
199
221
  }, {
200
222
  uiState: this.uiState,
@@ -265,6 +287,13 @@ export class App {
265
287
  this.explorerManager?.loadDirectory('');
266
288
  }
267
289
  }
290
+ else if (tab === 'commit') {
291
+ this.gitManager?.loadStashList();
292
+ // Also load history if needed for HEAD commit display
293
+ if (!this.gitManager?.historyState.commits.length) {
294
+ this.gitManager?.loadHistory();
295
+ }
296
+ }
268
297
  });
269
298
  // Handle modal opening/closing
270
299
  this.uiState.on('modal-change', (modal) => {
@@ -370,6 +399,22 @@ export class App {
370
399
  this.gitManager.on('compare-selection-change', () => {
371
400
  this.render();
372
401
  });
402
+ this.gitManager.on('remote-state-change', (remoteState) => {
403
+ // Auto-clear success after 3s, error after 5s
404
+ if (this.remoteClearTimer)
405
+ clearTimeout(this.remoteClearTimer);
406
+ if (remoteState.lastResult && !remoteState.inProgress) {
407
+ this.remoteClearTimer = setTimeout(() => {
408
+ this.gitManager?.clearRemoteState();
409
+ }, 3000);
410
+ }
411
+ else if (remoteState.error) {
412
+ this.remoteClearTimer = setTimeout(() => {
413
+ this.gitManager?.clearRemoteState();
414
+ }, 5000);
415
+ }
416
+ this.render();
417
+ });
373
418
  // Start watching and do initial refresh
374
419
  this.gitManager.startWatching();
375
420
  this.gitManager.refresh();
@@ -439,6 +484,8 @@ export class App {
439
484
  });
440
485
  // Load root directory
441
486
  this.explorerManager.loadDirectory('');
487
+ // Pre-load file paths for file finder (runs in background)
488
+ this.explorerManager.loadFilePaths();
442
489
  // Update git status after tree is loaded
443
490
  this.updateExplorerGitStatus();
444
491
  }
@@ -985,7 +1032,12 @@ export class App {
985
1032
  }
986
1033
  }
987
1034
  async openFileFinder() {
988
- const allPaths = (await this.explorerManager?.getAllFilePaths()) ?? [];
1035
+ let allPaths = this.explorerManager?.getCachedFilePaths() ?? [];
1036
+ if (allPaths.length === 0) {
1037
+ // First open or cache not yet loaded — wait for it
1038
+ await this.explorerManager?.loadFilePaths();
1039
+ allPaths = this.explorerManager?.getCachedFilePaths() ?? [];
1040
+ }
989
1041
  if (allPaths.length === 0)
990
1042
  return;
991
1043
  this.activeModal = new FileFinder(this.screen, allPaths, async (selectedPath) => {
@@ -1017,6 +1069,66 @@ export class App {
1017
1069
  });
1018
1070
  this.activeModal.focus();
1019
1071
  }
1072
+ openStashListModal() {
1073
+ const entries = this.gitManager?.state.stashList ?? [];
1074
+ this.activeModal = new StashListModal(this.screen, entries, (index) => {
1075
+ this.activeModal = null;
1076
+ this.gitManager?.stashPop(index);
1077
+ }, () => {
1078
+ this.activeModal = null;
1079
+ });
1080
+ this.activeModal.focus();
1081
+ }
1082
+ openBranchPicker() {
1083
+ this.gitManager?.getLocalBranches().then((branches) => {
1084
+ this.activeModal = new BranchPicker(this.screen, branches, (name) => {
1085
+ this.activeModal = null;
1086
+ this.gitManager?.switchBranch(name);
1087
+ }, (name) => {
1088
+ this.activeModal = null;
1089
+ this.gitManager?.createBranch(name);
1090
+ }, () => {
1091
+ this.activeModal = null;
1092
+ });
1093
+ this.activeModal.focus();
1094
+ });
1095
+ }
1096
+ showSoftResetConfirm() {
1097
+ const headCommit = this.gitManager?.historyState.commits[0];
1098
+ if (!headCommit)
1099
+ return;
1100
+ this.activeModal = new SoftResetConfirm(this.screen, headCommit, () => {
1101
+ this.activeModal = null;
1102
+ this.gitManager?.softReset();
1103
+ }, () => {
1104
+ this.activeModal = null;
1105
+ });
1106
+ this.activeModal.focus();
1107
+ }
1108
+ cherryPickSelected() {
1109
+ const commit = this.gitManager?.historyState.selectedCommit;
1110
+ if (!commit)
1111
+ return;
1112
+ this.activeModal = new CommitActionConfirm(this.screen, 'Cherry-pick', commit, () => {
1113
+ this.activeModal = null;
1114
+ this.gitManager?.cherryPick(commit.hash);
1115
+ }, () => {
1116
+ this.activeModal = null;
1117
+ });
1118
+ this.activeModal.focus();
1119
+ }
1120
+ revertSelected() {
1121
+ const commit = this.gitManager?.historyState.selectedCommit;
1122
+ if (!commit)
1123
+ return;
1124
+ this.activeModal = new CommitActionConfirm(this.screen, 'Revert', commit, () => {
1125
+ this.activeModal = null;
1126
+ this.gitManager?.revertCommit(commit.hash);
1127
+ }, () => {
1128
+ this.activeModal = null;
1129
+ });
1130
+ this.activeModal.focus();
1131
+ }
1020
1132
  async commit(message) {
1021
1133
  await this.gitManager?.commit(message);
1022
1134
  }
@@ -1083,7 +1195,7 @@ export class App {
1083
1195
  updateHeader() {
1084
1196
  const gitState = this.gitManager?.state;
1085
1197
  const width = this.screen.width || 80;
1086
- const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width);
1198
+ const content = formatHeader(this.repoPath, gitState?.status?.branch ?? null, gitState?.isLoading ?? false, gitState?.error ?? null, width, this.gitManager?.remoteState ?? null);
1087
1199
  this.layout.headerBox.setContent(content);
1088
1200
  }
1089
1201
  updateTopPane() {
@@ -1108,7 +1220,7 @@ export class App {
1108
1220
  const diffPaneFocused = state.bottomTab === 'diff' && state.currentPane === 'diff';
1109
1221
  const hunkIndex = diffPaneFocused ? state.selectedHunkIndex : undefined;
1110
1222
  const isFileStaged = diffPaneFocused ? this.gitManager?.state.selectedFile?.staged : undefined;
1111
- const { content, totalRows, hunkCount, hunkBoundaries, hunkMapping } = renderBottomPane(state, this.gitManager?.state.diff ?? null, this.gitManager?.historyState, this.gitManager?.compareSelectionState, this.explorerManager?.state?.selectedFile ?? null, this.commitFlowState.state, stagedCount, this.currentTheme, width, this.layout.dimensions.bottomPaneHeight, hunkIndex, isFileStaged, state.flatViewMode ? this.gitManager?.state.combinedFileDiffs : undefined);
1223
+ const { content, totalRows, hunkCount, hunkBoundaries, hunkMapping } = renderBottomPane(state, this.gitManager?.state.diff ?? null, this.gitManager?.historyState, this.gitManager?.compareSelectionState, this.explorerManager?.state?.selectedFile ?? null, this.commitFlowState.state, stagedCount, this.currentTheme, width, this.layout.dimensions.bottomPaneHeight, hunkIndex, isFileStaged, state.flatViewMode ? this.gitManager?.state.combinedFileDiffs : undefined, this.gitManager?.state.status?.branch ?? null, this.gitManager?.remoteState ?? null, this.gitManager?.state.stashList, this.gitManager?.historyState.commits[0] ?? null);
1112
1224
  this.bottomPaneTotalRows = totalRows;
1113
1225
  this.bottomPaneHunkCount = hunkCount;
1114
1226
  this.bottomPaneHunkBoundaries = hunkBoundaries;
@@ -1149,6 +1261,9 @@ export class App {
1149
1261
  if (this.commandServer) {
1150
1262
  this.commandServer.stop();
1151
1263
  }
1264
+ if (this.remoteClearTimer) {
1265
+ clearTimeout(this.remoteClearTimer);
1266
+ }
1152
1267
  // Destroy screen (this will clean up terminal)
1153
1268
  this.screen.destroy();
1154
1269
  }
@@ -130,6 +130,29 @@ export function setupKeyBindings(screen, actions, ctx) {
130
130
  ctx.uiState.toggleAutoTab();
131
131
  }
132
132
  });
133
+ // Ctrl+a: toggle amend on commit tab (works even when input is focused)
134
+ screen.key(['C-a'], () => {
135
+ if (ctx.getBottomTab() === 'commit') {
136
+ ctx.commitFlowState.toggleAmend();
137
+ actions.render();
138
+ }
139
+ });
140
+ // Remote operations (global, any tab)
141
+ screen.key(['S-p'], () => {
142
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
143
+ return;
144
+ actions.push();
145
+ });
146
+ screen.key(['S-f'], () => {
147
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
148
+ return;
149
+ actions.fetchRemote();
150
+ });
151
+ screen.key(['S-r'], () => {
152
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
153
+ return;
154
+ actions.pullRebase();
155
+ });
133
156
  screen.key(['escape'], () => {
134
157
  if (ctx.getBottomTab() === 'commit') {
135
158
  if (ctx.isCommitInputFocused()) {
@@ -163,11 +186,16 @@ export function setupKeyBindings(screen, actions, ctx) {
163
186
  screen.key(['?'], () => ctx.uiState.toggleModal('hotkeys'));
164
187
  // Follow toggle
165
188
  screen.key(['f'], () => actions.toggleFollow());
166
- // Compare view: base branch picker
189
+ // Compare view: base branch picker / Commit tab: branch picker
167
190
  screen.key(['b'], () => {
191
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
192
+ return;
168
193
  if (ctx.getBottomTab() === 'compare') {
169
194
  ctx.uiState.openModal('baseBranch');
170
195
  }
196
+ else if (ctx.getBottomTab() === 'commit') {
197
+ actions.openBranchPicker();
198
+ }
171
199
  });
172
200
  // u: toggle uncommitted in compare view
173
201
  screen.key(['u'], () => {
@@ -225,4 +253,50 @@ export function setupKeyBindings(screen, actions, ctx) {
225
253
  actions.navigatePrevHunk();
226
254
  }
227
255
  });
256
+ // Stash: save (global)
257
+ screen.key(['S-s'], () => {
258
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
259
+ return;
260
+ actions.stash();
261
+ });
262
+ // Stash: pop (commit tab only)
263
+ screen.key(['o'], () => {
264
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
265
+ return;
266
+ if (ctx.getBottomTab() === 'commit') {
267
+ actions.stashPop();
268
+ }
269
+ });
270
+ // Stash: list modal (commit tab only)
271
+ screen.key(['l'], () => {
272
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
273
+ return;
274
+ if (ctx.getBottomTab() === 'commit') {
275
+ actions.openStashListModal();
276
+ }
277
+ });
278
+ // Soft reset HEAD~1 (commit tab only)
279
+ screen.key(['S-x'], () => {
280
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused() || ctx.isRemoteInProgress())
281
+ return;
282
+ if (ctx.getBottomTab() === 'commit') {
283
+ actions.showSoftResetConfirm();
284
+ }
285
+ });
286
+ // Cherry-pick selected commit (history tab only)
287
+ screen.key(['p'], () => {
288
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
289
+ return;
290
+ if (ctx.getBottomTab() === 'history') {
291
+ actions.cherryPickSelected();
292
+ }
293
+ });
294
+ // Revert selected commit (history tab only)
295
+ screen.key(['v'], () => {
296
+ if (ctx.hasActiveModal() || ctx.isCommitInputFocused())
297
+ return;
298
+ if (ctx.getBottomTab() === 'history') {
299
+ actions.revertSelected();
300
+ }
301
+ });
228
302
  }
@@ -29,11 +29,22 @@ export function setupMouseHandlers(layout, actions, ctx) {
29
29
  handleTopPaneClick(clickedRow, mouse.x, actions, ctx);
30
30
  }
31
31
  });
32
- // Click on bottom pane to select hunk (diff tab)
32
+ // Click on bottom pane
33
33
  layout.bottomPane.on('click', (mouse) => {
34
34
  const clickedRow = layout.screenYToBottomPaneRow(mouse.y);
35
35
  if (clickedRow >= 0) {
36
- actions.selectHunkAtRow(clickedRow);
36
+ if (ctx.uiState.state.bottomTab === 'commit') {
37
+ // Row 6 (0-indexed) is the amend checkbox row
38
+ if (clickedRow === 6) {
39
+ actions.toggleAmend();
40
+ }
41
+ else {
42
+ actions.focusCommitInput();
43
+ }
44
+ }
45
+ else {
46
+ actions.selectHunkAtRow(clickedRow);
47
+ }
37
48
  }
38
49
  });
39
50
  // Click on footer for tabs and toggles
@@ -2,6 +2,7 @@ import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import { EventEmitter } from 'node:events';
4
4
  import { getIgnoredFiles } from '../git/ignoreUtils.js';
5
+ import { listAllFiles } from '../git/status.js';
5
6
  const MAX_FILE_SIZE = 1024 * 1024; // 1MB
6
7
  const WARN_FILE_SIZE = 100 * 1024; // 100KB
7
8
  /**
@@ -25,6 +26,7 @@ export class ExplorerStateManager extends EventEmitter {
25
26
  options;
26
27
  expandedPaths = new Set();
27
28
  gitStatusMap = { files: new Map(), directories: new Set() };
29
+ _cachedFilePaths = null;
28
30
  _state = {
29
31
  currentPath: '',
30
32
  tree: null,
@@ -61,9 +63,12 @@ export class ExplorerStateManager extends EventEmitter {
61
63
  }
62
64
  /**
63
65
  * Update git status map and refresh display.
66
+ * Also invalidates the file path cache so the next file finder open gets fresh data.
64
67
  */
65
68
  setGitStatus(statusMap) {
66
69
  this.gitStatusMap = statusMap;
70
+ // Invalidate file path cache — reload in background
71
+ this.loadFilePaths();
67
72
  // Refresh display to show updated status
68
73
  if (this._state.tree) {
69
74
  this.applyGitStatusToTree(this._state.tree);
@@ -538,45 +543,23 @@ export class ExplorerStateManager extends EventEmitter {
538
543
  return search(this._state.tree);
539
544
  }
540
545
  /**
541
- * Get all file paths in the repo (for file finder).
542
- * Scans the filesystem directly to get all files, not just expanded ones.
546
+ * Load all file paths using git ls-files (fast, single git command).
547
+ * Stores result in cache for instant access by FileFinder.
543
548
  */
544
- async getAllFilePaths() {
545
- const paths = [];
546
- const scanDir = async (dirPath) => {
547
- try {
548
- const fullPath = path.join(this.repoPath, dirPath);
549
- const entries = await fs.promises.readdir(fullPath, { withFileTypes: true });
550
- // Build list of paths for gitignore check
551
- const pathsToCheck = entries.map((e) => (dirPath ? path.join(dirPath, e.name) : e.name));
552
- // Get ignored files
553
- const ignoredFiles = this.options.hideGitignored
554
- ? await getIgnoredFiles(this.repoPath, pathsToCheck)
555
- : new Set();
556
- for (const entry of entries) {
557
- // Filter dot-prefixed hidden files
558
- if (this.options.hideHidden && entry.name.startsWith('.')) {
559
- continue;
560
- }
561
- const entryPath = dirPath ? path.join(dirPath, entry.name) : entry.name;
562
- // Filter gitignored files
563
- if (this.options.hideGitignored && ignoredFiles.has(entryPath)) {
564
- continue;
565
- }
566
- if (entry.isDirectory()) {
567
- await scanDir(entryPath);
568
- }
569
- else {
570
- paths.push(entryPath);
571
- }
572
- }
573
- }
574
- catch {
575
- // Ignore errors for individual directories
576
- }
577
- };
578
- await scanDir('');
579
- return paths;
549
+ async loadFilePaths() {
550
+ try {
551
+ this._cachedFilePaths = await listAllFiles(this.repoPath);
552
+ }
553
+ catch {
554
+ this._cachedFilePaths = [];
555
+ }
556
+ }
557
+ /**
558
+ * Get cached file paths (for file finder).
559
+ * Returns empty array if not yet loaded.
560
+ */
561
+ getCachedFilePaths() {
562
+ return this._cachedFilePaths ?? [];
580
563
  }
581
564
  /**
582
565
  * Navigate to a specific file path in the tree.
@@ -5,7 +5,7 @@ import { watch } from 'chokidar';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import ignore from 'ignore';
7
7
  import { getQueueForRepo, removeQueueForRepo } from './GitOperationQueue.js';
8
- import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, stageHunk as gitStageHunk, unstageHunk as gitUnstageHunk, } from '../git/status.js';
8
+ import { getStatus, stageFile, unstageFile, stageAll as gitStageAll, unstageAll as gitUnstageAll, discardChanges as gitDiscardChanges, commit as gitCommit, getHeadMessage, getCommitHistory, stageHunk as gitStageHunk, unstageHunk as gitUnstageHunk, push as gitPush, fetchRemote as gitFetchRemote, pullRebase as gitPullRebase, getStashList as gitGetStashList, stashSave as gitStashSave, stashPop as gitStashPop, getLocalBranches as gitGetLocalBranches, switchBranch as gitSwitchBranch, createBranch as gitCreateBranch, softResetHead as gitSoftResetHead, cherryPick as gitCherryPick, revertCommit as gitRevertCommit, } from '../git/status.js';
9
9
  import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, countHunksPerFile, } from '../git/diff.js';
10
10
  import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
11
11
  /**
@@ -28,6 +28,7 @@ export class GitStateManager extends EventEmitter {
28
28
  isLoading: false,
29
29
  error: null,
30
30
  hunkCounts: null,
31
+ stashList: [],
31
32
  };
32
33
  _compareState = {
33
34
  compareDiff: null,
@@ -46,6 +47,12 @@ export class GitStateManager extends EventEmitter {
46
47
  index: 0,
47
48
  diff: null,
48
49
  };
50
+ _remoteState = {
51
+ operation: null,
52
+ inProgress: false,
53
+ error: null,
54
+ lastResult: null,
55
+ };
49
56
  constructor(repoPath) {
50
57
  super();
51
58
  this.repoPath = repoPath;
@@ -63,6 +70,13 @@ export class GitStateManager extends EventEmitter {
63
70
  get compareSelectionState() {
64
71
  return this._compareSelectionState;
65
72
  }
73
+ get remoteState() {
74
+ return this._remoteState;
75
+ }
76
+ updateRemoteState(partial) {
77
+ this._remoteState = { ...this._remoteState, ...partial };
78
+ this.emit('remote-state-change', this._remoteState);
79
+ }
66
80
  updateState(partial) {
67
81
  this._state = { ...this._state, ...partial };
68
82
  this.emit('state-change', this._state);
@@ -504,6 +518,133 @@ export class GitStateManager extends EventEmitter {
504
518
  });
505
519
  }
506
520
  }
521
+ // Remote operations
522
+ /**
523
+ * Push to remote.
524
+ */
525
+ async push() {
526
+ if (this._remoteState.inProgress)
527
+ return;
528
+ await this.runRemoteOperation('push', () => gitPush(this.repoPath));
529
+ }
530
+ /**
531
+ * Fetch from remote.
532
+ */
533
+ async fetchRemote() {
534
+ if (this._remoteState.inProgress)
535
+ return;
536
+ await this.runRemoteOperation('fetch', () => gitFetchRemote(this.repoPath));
537
+ }
538
+ /**
539
+ * Pull with rebase from remote.
540
+ */
541
+ async pullRebase() {
542
+ if (this._remoteState.inProgress)
543
+ return;
544
+ await this.runRemoteOperation('pull', () => gitPullRebase(this.repoPath));
545
+ }
546
+ async runRemoteOperation(operation, fn) {
547
+ this.updateRemoteState({ operation, inProgress: true, error: null, lastResult: null });
548
+ try {
549
+ const result = await this.queue.enqueue(fn);
550
+ this.updateRemoteState({ inProgress: false, lastResult: result });
551
+ // Refresh status to pick up new ahead/behind counts
552
+ this.scheduleRefresh();
553
+ }
554
+ catch (err) {
555
+ const message = err instanceof Error ? err.message : String(err);
556
+ this.updateRemoteState({ inProgress: false, error: message });
557
+ }
558
+ }
559
+ // Stash operations
560
+ /**
561
+ * Load the stash list.
562
+ */
563
+ async loadStashList() {
564
+ try {
565
+ const stashList = await this.queue.enqueue(() => gitGetStashList(this.repoPath));
566
+ this.updateState({ stashList });
567
+ }
568
+ catch {
569
+ // Silently ignore — stash list is non-critical
570
+ }
571
+ }
572
+ /**
573
+ * Save working changes to stash.
574
+ */
575
+ async stash(message) {
576
+ if (this._remoteState.inProgress)
577
+ return;
578
+ await this.runRemoteOperation('stash', () => gitStashSave(this.repoPath, message));
579
+ await this.loadStashList();
580
+ }
581
+ /**
582
+ * Pop a stash entry.
583
+ */
584
+ async stashPop(index = 0) {
585
+ if (this._remoteState.inProgress)
586
+ return;
587
+ await this.runRemoteOperation('stashPop', () => gitStashPop(this.repoPath, index));
588
+ await this.loadStashList();
589
+ }
590
+ // Branch operations
591
+ /**
592
+ * Get local branches.
593
+ */
594
+ async getLocalBranches() {
595
+ return this.queue.enqueue(() => gitGetLocalBranches(this.repoPath));
596
+ }
597
+ /**
598
+ * Switch to an existing branch.
599
+ */
600
+ async switchBranch(name) {
601
+ if (this._remoteState.inProgress)
602
+ return;
603
+ await this.runRemoteOperation('branchSwitch', () => gitSwitchBranch(this.repoPath, name));
604
+ // Reset compare base branch since it may not exist on the new branch
605
+ this.updateCompareState({ compareBaseBranch: null });
606
+ }
607
+ /**
608
+ * Create and switch to a new branch.
609
+ */
610
+ async createBranch(name) {
611
+ if (this._remoteState.inProgress)
612
+ return;
613
+ await this.runRemoteOperation('branchCreate', () => gitCreateBranch(this.repoPath, name));
614
+ this.updateCompareState({ compareBaseBranch: null });
615
+ }
616
+ // Undo operations
617
+ /**
618
+ * Soft reset HEAD by count commits.
619
+ */
620
+ async softReset(count = 1) {
621
+ if (this._remoteState.inProgress)
622
+ return;
623
+ await this.runRemoteOperation('softReset', () => gitSoftResetHead(this.repoPath, count));
624
+ }
625
+ // History actions
626
+ /**
627
+ * Cherry-pick a commit.
628
+ */
629
+ async cherryPick(hash) {
630
+ if (this._remoteState.inProgress)
631
+ return;
632
+ await this.runRemoteOperation('cherryPick', () => gitCherryPick(this.repoPath, hash));
633
+ }
634
+ /**
635
+ * Revert a commit.
636
+ */
637
+ async revertCommit(hash) {
638
+ if (this._remoteState.inProgress)
639
+ return;
640
+ await this.runRemoteOperation('revert', () => gitRevertCommit(this.repoPath, hash));
641
+ }
642
+ /**
643
+ * Clear the remote state (e.g. after auto-clear timeout).
644
+ */
645
+ clearRemoteState() {
646
+ this.updateRemoteState({ operation: null, error: null, lastResult: null });
647
+ }
507
648
  /**
508
649
  * Get the HEAD commit message.
509
650
  */