diffstalker 0.2.1 → 0.2.2

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.
@@ -1,5 +1,6 @@
1
1
  import { SPLIT_RATIO_STEP } from './ui/Layout.js';
2
2
  import { getFileAtIndex } from './ui/widgets/FileList.js';
3
+ import { getFlatFileAtIndex } from './utils/flatFileList.js';
3
4
  /**
4
5
  * Register all keyboard bindings on the blessed screen.
5
6
  */
@@ -41,10 +42,16 @@ export function setupKeyBindings(screen, actions, ctx) {
41
42
  ctx.uiState.togglePane();
42
43
  });
43
44
  // Staging operations (skip if modal is open)
45
+ // Context-aware: hunk staging when diff pane is focused on diff tab
44
46
  screen.key(['s'], () => {
45
47
  if (ctx.hasActiveModal())
46
48
  return;
47
- actions.stageSelected();
49
+ if (ctx.getBottomTab() === 'diff' && ctx.getCurrentPane() === 'diff') {
50
+ actions.toggleCurrentHunk();
51
+ }
52
+ else {
53
+ actions.stageSelected();
54
+ }
48
55
  });
49
56
  screen.key(['S-u'], () => {
50
57
  if (ctx.hasActiveModal())
@@ -85,7 +92,7 @@ export function setupKeyBindings(screen, actions, ctx) {
85
92
  if (ctx.hasActiveModal())
86
93
  return;
87
94
  if (ctx.getBottomTab() === 'explorer') {
88
- ctx.explorerManager?.toggleShowOnlyChanges();
95
+ ctx.getExplorerManager()?.toggleShowOnlyChanges();
89
96
  }
90
97
  });
91
98
  // Explorer: open file finder
@@ -96,6 +103,12 @@ export function setupKeyBindings(screen, actions, ctx) {
96
103
  actions.openFileFinder();
97
104
  }
98
105
  });
106
+ // Ctrl+P: open file finder from any tab
107
+ screen.key(['C-p'], () => {
108
+ if (ctx.hasActiveModal())
109
+ return;
110
+ actions.openFileFinder();
111
+ });
99
112
  // Commit (skip if modal is open)
100
113
  screen.key(['c'], () => {
101
114
  if (ctx.hasActiveModal())
@@ -156,23 +169,60 @@ export function setupKeyBindings(screen, actions, ctx) {
156
169
  ctx.uiState.openModal('baseBranch');
157
170
  }
158
171
  });
159
- // Compare view: toggle uncommitted
172
+ // u: toggle uncommitted in compare view
160
173
  screen.key(['u'], () => {
174
+ if (ctx.hasActiveModal())
175
+ return;
161
176
  if (ctx.getBottomTab() === 'compare') {
162
177
  ctx.uiState.toggleIncludeUncommitted();
163
178
  const includeUncommitted = ctx.uiState.state.includeUncommitted;
164
- ctx.gitManager?.refreshCompareDiff(includeUncommitted);
179
+ ctx.getGitManager()?.refreshCompareDiff(includeUncommitted);
180
+ }
181
+ });
182
+ // Toggle flat file view (diff/commit tab only)
183
+ screen.key(['h'], () => {
184
+ if (ctx.hasActiveModal())
185
+ return;
186
+ const tab = ctx.getBottomTab();
187
+ if (tab === 'diff' || tab === 'commit') {
188
+ ctx.uiState.toggleFlatViewMode();
165
189
  }
166
190
  });
167
191
  // Discard changes (with confirmation)
168
192
  screen.key(['d'], () => {
169
193
  if (ctx.getBottomTab() === 'diff') {
170
- const files = ctx.getStatusFiles();
171
- const selectedFile = getFileAtIndex(files, ctx.getSelectedIndex());
172
- // Only allow discard for unstaged modified files
173
- if (selectedFile && !selectedFile.staged && selectedFile.status !== 'untracked') {
174
- actions.showDiscardConfirm(selectedFile);
194
+ if (ctx.uiState.state.flatViewMode) {
195
+ const flatEntry = getFlatFileAtIndex(ctx.getCachedFlatFiles(), ctx.getSelectedIndex());
196
+ if (flatEntry?.unstagedEntry) {
197
+ const file = flatEntry.unstagedEntry;
198
+ if (file.status !== 'untracked') {
199
+ actions.showDiscardConfirm(file);
200
+ }
201
+ }
202
+ }
203
+ else {
204
+ const files = ctx.getStatusFiles();
205
+ const selectedFile = getFileAtIndex(files, ctx.getSelectedIndex());
206
+ // Only allow discard for unstaged modified files
207
+ if (selectedFile && !selectedFile.staged && selectedFile.status !== 'untracked') {
208
+ actions.showDiscardConfirm(selectedFile);
209
+ }
175
210
  }
176
211
  }
177
212
  });
213
+ // Hunk navigation (only when diff pane focused on diff tab)
214
+ screen.key(['n'], () => {
215
+ if (ctx.hasActiveModal())
216
+ return;
217
+ if (ctx.getBottomTab() === 'diff' && ctx.getCurrentPane() === 'diff') {
218
+ actions.navigateNextHunk();
219
+ }
220
+ });
221
+ screen.key(['S-n'], () => {
222
+ if (ctx.hasActiveModal())
223
+ return;
224
+ if (ctx.getBottomTab() === 'diff' && ctx.getCurrentPane() === 'diff') {
225
+ actions.navigatePrevHunk();
226
+ }
227
+ });
178
228
  }
@@ -1,4 +1,5 @@
1
1
  import { getFileListTotalRows, getFileIndexFromRow } from './ui/widgets/FileList.js';
2
+ import { getFlatFileListTotalRows } from './ui/widgets/FlatFileList.js';
2
3
  import { getCompareListTotalRows, getCompareSelectionFromRow, } from './ui/widgets/CompareListView.js';
3
4
  import { getExplorerTotalRows } from './ui/widgets/ExplorerView.js';
4
5
  import { getExplorerContentTotalRows } from './ui/widgets/ExplorerContent.js';
@@ -28,11 +29,49 @@ export function setupMouseHandlers(layout, actions, ctx) {
28
29
  handleTopPaneClick(clickedRow, mouse.x, actions, ctx);
29
30
  }
30
31
  });
32
+ // Click on bottom pane to select hunk (diff tab)
33
+ layout.bottomPane.on('click', (mouse) => {
34
+ const clickedRow = layout.screenYToBottomPaneRow(mouse.y);
35
+ if (clickedRow >= 0) {
36
+ actions.selectHunkAtRow(clickedRow);
37
+ }
38
+ });
31
39
  // Click on footer for tabs and toggles
32
40
  layout.footerBox.on('click', (mouse) => {
33
41
  handleFooterClick(mouse.x, actions, ctx);
34
42
  });
35
43
  }
44
+ function handleFileListClick(row, x, actions, ctx) {
45
+ const state = ctx.uiState.state;
46
+ if (state.flatViewMode) {
47
+ // Flat mode: row 0 is header, files start at row 1
48
+ const absoluteRow = row + state.fileListScrollOffset;
49
+ const fileIndex = absoluteRow - 1; // subtract header row
50
+ const flatFiles = ctx.getCachedFlatFiles();
51
+ if (fileIndex < 0 || fileIndex >= flatFiles.length)
52
+ return;
53
+ if (x !== undefined && x >= 2 && x <= 4) {
54
+ actions.toggleFileByIndex(fileIndex);
55
+ }
56
+ else {
57
+ ctx.uiState.setSelectedIndex(fileIndex);
58
+ actions.selectFileByIndex(fileIndex);
59
+ }
60
+ }
61
+ else {
62
+ const files = ctx.getStatusFiles();
63
+ const fileIndex = getFileIndexFromRow(row + state.fileListScrollOffset, files);
64
+ if (fileIndex === null || fileIndex < 0)
65
+ return;
66
+ if (x !== undefined && x >= 2 && x <= 4) {
67
+ actions.toggleFileByIndex(fileIndex);
68
+ }
69
+ else {
70
+ ctx.uiState.setSelectedIndex(fileIndex);
71
+ actions.selectFileByIndex(fileIndex);
72
+ }
73
+ }
74
+ }
36
75
  function handleTopPaneClick(row, x, actions, ctx) {
37
76
  const state = ctx.uiState.state;
38
77
  if (state.bottomTab === 'history') {
@@ -50,23 +89,19 @@ function handleTopPaneClick(row, x, actions, ctx) {
50
89
  }
51
90
  else if (state.bottomTab === 'explorer') {
52
91
  const index = state.explorerScrollOffset + row;
53
- ctx.explorerManager?.selectIndex(index);
54
- ctx.uiState.setExplorerSelectedIndex(index);
92
+ const explorerManager = ctx.getExplorerManager();
93
+ const isAlreadySelected = explorerManager?.state.selectedIndex === index;
94
+ const displayRow = explorerManager?.state.displayRows[index];
95
+ if (isAlreadySelected && displayRow?.node.isDirectory) {
96
+ actions.enterExplorerDirectory();
97
+ }
98
+ else {
99
+ explorerManager?.selectIndex(index);
100
+ ctx.uiState.setExplorerSelectedIndex(index);
101
+ }
55
102
  }
56
103
  else {
57
- // Diff tab - select file
58
- const files = ctx.getStatusFiles();
59
- const fileIndex = getFileIndexFromRow(row + state.fileListScrollOffset, files);
60
- if (fileIndex !== null && fileIndex >= 0) {
61
- // Check if click is on the action button [+] or [-] (columns 2-4)
62
- if (x !== undefined && x >= 2 && x <= 4) {
63
- actions.toggleFileByIndex(fileIndex);
64
- }
65
- else {
66
- ctx.uiState.setSelectedIndex(fileIndex);
67
- actions.selectFileByIndex(fileIndex);
68
- }
69
- }
104
+ handleFileListClick(row, x, actions, ctx);
70
105
  }
71
106
  }
72
107
  function handleFooterClick(x, actions, ctx) {
@@ -102,7 +137,7 @@ function handleFooterClick(x, actions, ctx) {
102
137
  actions.toggleFollow();
103
138
  }
104
139
  else if (x >= 34 && x <= 43 && ctx.uiState.state.bottomTab === 'explorer') {
105
- ctx.explorerManager?.toggleShowOnlyChanges();
140
+ ctx.getExplorerManager()?.toggleShowOnlyChanges();
106
141
  }
107
142
  else if (x === 0) {
108
143
  ctx.uiState.openModal('hotkeys');
@@ -124,14 +159,15 @@ function handleTopPaneScroll(delta, layout, ctx) {
124
159
  ctx.uiState.setCompareScrollOffset(newOffset);
125
160
  }
126
161
  else if (state.bottomTab === 'explorer') {
127
- const totalRows = getExplorerTotalRows(ctx.explorerManager?.state.displayRows ?? []);
162
+ const totalRows = getExplorerTotalRows(ctx.getExplorerManager()?.state.displayRows ?? []);
128
163
  const maxOffset = Math.max(0, totalRows - visibleHeight);
129
164
  const newOffset = Math.min(maxOffset, Math.max(0, state.explorerScrollOffset + delta));
130
165
  ctx.uiState.setExplorerScrollOffset(newOffset);
131
166
  }
132
167
  else {
133
- const files = ctx.getStatusFiles();
134
- const totalRows = getFileListTotalRows(files);
168
+ const totalRows = state.flatViewMode
169
+ ? getFlatFileListTotalRows(ctx.getCachedFlatFiles())
170
+ : getFileListTotalRows(ctx.getStatusFiles());
135
171
  const maxOffset = Math.max(0, totalRows - visibleHeight);
136
172
  const newOffset = Math.min(maxOffset, Math.max(0, state.fileListScrollOffset + delta));
137
173
  ctx.uiState.setFileListScrollOffset(newOffset);
@@ -142,7 +178,7 @@ function handleBottomPaneScroll(delta, layout, ctx) {
142
178
  const visibleHeight = layout.dimensions.bottomPaneHeight;
143
179
  const width = ctx.getScreenWidth();
144
180
  if (state.bottomTab === 'explorer') {
145
- const selectedFile = ctx.explorerManager?.state.selectedFile;
181
+ const selectedFile = ctx.getExplorerManager()?.state.selectedFile;
146
182
  const totalRows = getExplorerContentTotalRows(selectedFile?.content ?? null, selectedFile?.path ?? null, selectedFile?.truncated ?? false, width, state.wrapMode);
147
183
  const maxOffset = Math.max(0, totalRows - visibleHeight);
148
184
  const newOffset = Math.min(maxOffset, Math.max(0, state.explorerFileScrollOffset + delta));
@@ -123,7 +123,7 @@ export class ExplorerStateManager extends EventEmitter {
123
123
  /**
124
124
  * Build a tree node for a directory path.
125
125
  */
126
- async buildTreeNode(relativePath, depth) {
126
+ async buildTreeNode(relativePath, _depth) {
127
127
  try {
128
128
  const fullPath = path.join(this.repoPath, relativePath);
129
129
  const stats = await fs.promises.stat(fullPath);
@@ -153,7 +153,7 @@ export class ExplorerStateManager extends EventEmitter {
153
153
  }
154
154
  return node;
155
155
  }
156
- catch (err) {
156
+ catch {
157
157
  return null;
158
158
  }
159
159
  }
@@ -211,7 +211,7 @@ export class ExplorerStateManager extends EventEmitter {
211
211
  this.collapseNode(node, children);
212
212
  node.childrenLoaded = true;
213
213
  }
214
- catch (err) {
214
+ catch {
215
215
  node.childrenLoaded = true;
216
216
  node.children = [];
217
217
  }
@@ -262,43 +262,21 @@ export class ExplorerStateManager extends EventEmitter {
262
262
  /**
263
263
  * Flatten tree into display rows.
264
264
  */
265
+ shouldIncludeNode(node) {
266
+ if (!this.options.showOnlyChanges)
267
+ return true;
268
+ if (node.isDirectory)
269
+ return !!node.hasChangedChildren;
270
+ return !!node.gitStatus;
271
+ }
265
272
  flattenTree(root) {
266
273
  const rows = [];
267
- const traverse = (node, depth, parentIsLast) => {
268
- // Skip root node in display (but process its children)
269
- if (depth === 0) {
270
- for (let i = 0; i < node.children.length; i++) {
271
- const child = node.children[i];
272
- const isLast = i === node.children.length - 1;
273
- // Apply filter if showOnlyChanges is enabled
274
- if (this.options.showOnlyChanges) {
275
- if (child.isDirectory && !child.hasChangedChildren)
276
- continue;
277
- if (!child.isDirectory && !child.gitStatus)
278
- continue;
279
- }
280
- rows.push({
281
- node: child,
282
- depth: 0,
283
- isLast,
284
- parentIsLast: [],
285
- });
286
- if (child.isDirectory && child.expanded) {
287
- traverse(child, 1, [isLast]);
288
- }
289
- }
290
- return;
291
- }
274
+ const traverseChildren = (node, depth, parentIsLast) => {
292
275
  for (let i = 0; i < node.children.length; i++) {
293
276
  const child = node.children[i];
294
277
  const isLast = i === node.children.length - 1;
295
- // Apply filter if showOnlyChanges is enabled
296
- if (this.options.showOnlyChanges) {
297
- if (child.isDirectory && !child.hasChangedChildren)
298
- continue;
299
- if (!child.isDirectory && !child.gitStatus)
300
- continue;
301
- }
278
+ if (!this.shouldIncludeNode(child))
279
+ continue;
302
280
  rows.push({
303
281
  node: child,
304
282
  depth,
@@ -306,11 +284,12 @@ export class ExplorerStateManager extends EventEmitter {
306
284
  parentIsLast: [...parentIsLast],
307
285
  });
308
286
  if (child.isDirectory && child.expanded) {
309
- traverse(child, depth + 1, [...parentIsLast, isLast]);
287
+ traverseChildren(child, depth + 1, [...parentIsLast, isLast]);
310
288
  }
311
289
  }
312
290
  };
313
- traverse(root, 0, []);
291
+ // Start from root's children at depth 0 (root itself is not displayed)
292
+ traverseChildren(root, 0, []);
314
293
  return rows;
315
294
  }
316
295
  /**
@@ -592,7 +571,7 @@ export class ExplorerStateManager extends EventEmitter {
592
571
  }
593
572
  }
594
573
  }
595
- catch (err) {
574
+ catch {
596
575
  // Ignore errors for individual directories
597
576
  }
598
577
  };
@@ -5,8 +5,8 @@ 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, } from '../git/status.js';
9
- import { getDiff, getDiffForUntracked, getStagedDiff, getDefaultBaseBranch, getCandidateBaseBranches, getDiffBetweenRefs, getCompareDiffWithUncommitted, getCommitDiff, } from '../git/diff.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';
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
  /**
12
12
  * GitStateManager manages git state independent of React.
@@ -23,9 +23,11 @@ export class GitStateManager extends EventEmitter {
23
23
  _state = {
24
24
  status: null,
25
25
  diff: null,
26
+ combinedFileDiffs: null,
26
27
  selectedFile: null,
27
28
  isLoading: false,
28
29
  error: null,
30
+ hunkCounts: null,
29
31
  };
30
32
  _compareState = {
31
33
  compareDiff: null,
@@ -256,34 +258,24 @@ export class GitStateManager extends EventEmitter {
256
258
  });
257
259
  return;
258
260
  }
259
- // Emit status immediately so the file list updates after a single git spawn
260
- this.updateState({ status: newStatus });
261
- // Fetch unstaged diff (updates diff view once complete)
262
- const allUnstagedDiff = await getDiff(this.repoPath, undefined, false);
261
+ // Fetch unstaged and staged diffs in parallel
262
+ const [allUnstagedDiff, allStagedDiff] = await Promise.all([
263
+ getDiff(this.repoPath, undefined, false),
264
+ getDiff(this.repoPath, undefined, true),
265
+ ]);
266
+ // Count hunks per file for the file list display
267
+ const hunkCounts = {
268
+ unstaged: countHunksPerFile(allUnstagedDiff.raw),
269
+ staged: countHunksPerFile(allStagedDiff.raw),
270
+ };
263
271
  // Determine display diff based on selected file
264
- let displayDiff;
265
- const currentSelectedFile = this._state.selectedFile;
266
- if (currentSelectedFile) {
267
- const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged);
268
- if (currentFile) {
269
- if (currentFile.status === 'untracked') {
270
- displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
271
- }
272
- else {
273
- displayDiff = await getDiff(this.repoPath, currentFile.path, currentFile.staged);
274
- }
275
- }
276
- else {
277
- // File no longer exists - clear selection, show unstaged diff
278
- displayDiff = allUnstagedDiff;
279
- this.updateState({ selectedFile: null });
280
- }
281
- }
282
- else {
283
- displayDiff = allUnstagedDiff;
284
- }
272
+ const { displayDiff, combinedFileDiffs } = await this.resolveFileDiffs(newStatus, allUnstagedDiff);
273
+ // Batch status + diffs into a single update to avoid flicker
285
274
  this.updateState({
275
+ status: newStatus,
286
276
  diff: displayDiff,
277
+ combinedFileDiffs,
278
+ hunkCounts,
287
279
  isLoading: false,
288
280
  });
289
281
  }
@@ -294,6 +286,37 @@ export class GitStateManager extends EventEmitter {
294
286
  });
295
287
  }
296
288
  }
289
+ /**
290
+ * Resolve display diff and combined diffs for the currently selected file.
291
+ */
292
+ async resolveFileDiffs(newStatus, fallbackDiff) {
293
+ const currentSelectedFile = this._state.selectedFile;
294
+ if (!currentSelectedFile) {
295
+ return { displayDiff: fallbackDiff, combinedFileDiffs: null };
296
+ }
297
+ // Match by path + staged, falling back to path-only (handles staging state changes)
298
+ const currentFile = newStatus.files.find((f) => f.path === currentSelectedFile.path && f.staged === currentSelectedFile.staged) ?? newStatus.files.find((f) => f.path === currentSelectedFile.path);
299
+ if (!currentFile) {
300
+ this.updateState({ selectedFile: null });
301
+ return { displayDiff: fallbackDiff, combinedFileDiffs: null };
302
+ }
303
+ if (currentFile.status === 'untracked') {
304
+ const displayDiff = await getDiffForUntracked(this.repoPath, currentFile.path);
305
+ return {
306
+ displayDiff,
307
+ combinedFileDiffs: { unstaged: displayDiff, staged: { raw: '', lines: [] } },
308
+ };
309
+ }
310
+ const [unstagedFileDiff, stagedFileDiff] = await Promise.all([
311
+ getDiff(this.repoPath, currentFile.path, false),
312
+ getDiff(this.repoPath, currentFile.path, true),
313
+ ]);
314
+ const displayDiff = currentFile.staged ? stagedFileDiff : unstagedFileDiff;
315
+ return {
316
+ displayDiff,
317
+ combinedFileDiffs: { unstaged: unstagedFileDiff, staged: stagedFileDiff },
318
+ };
319
+ }
297
320
  /**
298
321
  * Select a file and update the diff display.
299
322
  * The selection highlight updates immediately; the diff fetch is debounced
@@ -323,27 +346,9 @@ export class GitStateManager extends EventEmitter {
323
346
  const file = this._state.selectedFile;
324
347
  this.queue
325
348
  .enqueue(async () => {
326
- // Selection changed while queued — skip stale fetch
327
349
  if (file !== this._state.selectedFile)
328
350
  return;
329
- if (file) {
330
- let fileDiff;
331
- if (file.status === 'untracked') {
332
- fileDiff = await getDiffForUntracked(this.repoPath, file.path);
333
- }
334
- else {
335
- fileDiff = await getDiff(this.repoPath, file.path, file.staged);
336
- }
337
- if (file === this._state.selectedFile) {
338
- this.updateState({ diff: fileDiff });
339
- }
340
- }
341
- else {
342
- const allDiff = await getStagedDiff(this.repoPath);
343
- if (this._state.selectedFile === null) {
344
- this.updateState({ diff: allDiff });
345
- }
346
- }
351
+ await this.doFetchDiffForFile(file);
347
352
  })
348
353
  .catch((err) => {
349
354
  this.updateState({
@@ -351,6 +356,36 @@ export class GitStateManager extends EventEmitter {
351
356
  });
352
357
  });
353
358
  }
359
+ async doFetchDiffForFile(file) {
360
+ if (!file) {
361
+ const allDiff = await getStagedDiff(this.repoPath);
362
+ if (this._state.selectedFile === null) {
363
+ this.updateState({ diff: allDiff, combinedFileDiffs: null });
364
+ }
365
+ return;
366
+ }
367
+ if (file.status === 'untracked') {
368
+ const fileDiff = await getDiffForUntracked(this.repoPath, file.path);
369
+ if (file === this._state.selectedFile) {
370
+ this.updateState({
371
+ diff: fileDiff,
372
+ combinedFileDiffs: { unstaged: fileDiff, staged: { raw: '', lines: [] } },
373
+ });
374
+ }
375
+ return;
376
+ }
377
+ const [unstagedDiff, stagedDiff] = await Promise.all([
378
+ getDiff(this.repoPath, file.path, false),
379
+ getDiff(this.repoPath, file.path, true),
380
+ ]);
381
+ if (file === this._state.selectedFile) {
382
+ const displayDiff = file.staged ? stagedDiff : unstagedDiff;
383
+ this.updateState({
384
+ diff: displayDiff,
385
+ combinedFileDiffs: { unstaged: unstagedDiff, staged: stagedDiff },
386
+ });
387
+ }
388
+ }
354
389
  /**
355
390
  * Stage a file.
356
391
  */
@@ -381,6 +416,36 @@ export class GitStateManager extends EventEmitter {
381
416
  });
382
417
  }
383
418
  }
419
+ /**
420
+ * Stage a single hunk via patch.
421
+ */
422
+ async stageHunk(patch) {
423
+ try {
424
+ await this.queue.enqueueMutation(async () => gitStageHunk(this.repoPath, patch));
425
+ this.scheduleRefresh();
426
+ }
427
+ catch (err) {
428
+ await this.refresh();
429
+ this.updateState({
430
+ error: `Failed to stage hunk: ${err instanceof Error ? err.message : String(err)}`,
431
+ });
432
+ }
433
+ }
434
+ /**
435
+ * Unstage a single hunk via patch.
436
+ */
437
+ async unstageHunk(patch) {
438
+ try {
439
+ await this.queue.enqueueMutation(async () => gitUnstageHunk(this.repoPath, patch));
440
+ this.scheduleRefresh();
441
+ }
442
+ catch (err) {
443
+ await this.refresh();
444
+ this.updateState({
445
+ error: `Failed to unstage hunk: ${err instanceof Error ? err.message : String(err)}`,
446
+ });
447
+ }
448
+ }
384
449
  /**
385
450
  * Discard changes to a file.
386
451
  */