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.
- package/.dependency-cruiser.cjs +67 -0
- package/.githooks/pre-commit +2 -0
- package/.githooks/pre-push +15 -0
- package/README.md +43 -35
- package/bun.lock +60 -4
- package/dist/App.js +378 -129
- package/dist/KeyBindings.js +59 -9
- package/dist/MouseHandlers.js +56 -20
- package/dist/core/ExplorerStateManager.js +17 -38
- package/dist/core/GitStateManager.js +111 -46
- package/dist/git/diff.js +99 -18
- package/dist/git/status.js +16 -54
- package/dist/git/test-helpers.js +67 -0
- package/dist/index.js +53 -47
- package/dist/ipc/CommandClient.js +6 -7
- package/dist/state/UIState.js +22 -0
- package/dist/ui/PaneRenderers.js +33 -13
- package/dist/ui/modals/FileFinder.js +26 -65
- package/dist/ui/modals/HotkeysModal.js +12 -3
- package/dist/ui/modals/ThemePicker.js +1 -2
- package/dist/ui/widgets/CommitPanel.js +1 -1
- package/dist/ui/widgets/CompareListView.js +44 -23
- package/dist/ui/widgets/DiffView.js +216 -170
- package/dist/ui/widgets/ExplorerView.js +50 -54
- package/dist/ui/widgets/FileList.js +62 -95
- package/dist/ui/widgets/FlatFileList.js +65 -0
- package/dist/ui/widgets/Footer.js +25 -15
- package/dist/ui/widgets/Header.js +14 -6
- package/dist/ui/widgets/fileRowFormatters.js +73 -0
- package/dist/utils/ansiTruncate.js +0 -1
- package/dist/utils/displayRows.js +101 -21
- package/dist/utils/flatFileList.js +67 -0
- package/dist/utils/layoutCalculations.js +5 -3
- package/eslint.metrics.js +0 -1
- package/metrics/v0.2.2.json +229 -0
- package/package.json +6 -2
package/dist/KeyBindings.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
}
|
package/dist/MouseHandlers.js
CHANGED
|
@@ -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.
|
|
54
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
134
|
-
|
|
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.
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
287
|
+
traverseChildren(child, depth + 1, [...parentIsLast, isLast]);
|
|
310
288
|
}
|
|
311
289
|
}
|
|
312
290
|
};
|
|
313
|
-
|
|
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
|
|
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
|
-
//
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
*/
|