diffstalker 0.2.2 → 0.2.4

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 (47) hide show
  1. package/.dependency-cruiser.cjs +2 -2
  2. package/dist/App.js +299 -664
  3. package/dist/KeyBindings.js +125 -39
  4. package/dist/ModalController.js +166 -0
  5. package/dist/MouseHandlers.js +43 -25
  6. package/dist/NavigationController.js +290 -0
  7. package/dist/StagingOperations.js +199 -0
  8. package/dist/config.js +39 -0
  9. package/dist/core/CompareManager.js +134 -0
  10. package/dist/core/ExplorerStateManager.js +27 -40
  11. package/dist/core/GitStateManager.js +28 -630
  12. package/dist/core/HistoryManager.js +72 -0
  13. package/dist/core/RemoteOperationManager.js +109 -0
  14. package/dist/core/WorkingTreeManager.js +412 -0
  15. package/dist/git/status.js +95 -0
  16. package/dist/index.js +59 -54
  17. package/dist/state/FocusRing.js +40 -0
  18. package/dist/state/UIState.js +82 -48
  19. package/dist/types/remote.js +5 -0
  20. package/dist/ui/PaneRenderers.js +11 -4
  21. package/dist/ui/modals/BaseBranchPicker.js +4 -7
  22. package/dist/ui/modals/CommitActionConfirm.js +66 -0
  23. package/dist/ui/modals/DiscardConfirm.js +4 -7
  24. package/dist/ui/modals/FileFinder.js +33 -27
  25. package/dist/ui/modals/HotkeysModal.js +32 -13
  26. package/dist/ui/modals/Modal.js +1 -0
  27. package/dist/ui/modals/RepoPicker.js +109 -0
  28. package/dist/ui/modals/ThemePicker.js +4 -7
  29. package/dist/ui/widgets/CommitPanel.js +52 -14
  30. package/dist/ui/widgets/CompareListView.js +1 -11
  31. package/dist/ui/widgets/DiffView.js +2 -27
  32. package/dist/ui/widgets/ExplorerContent.js +1 -4
  33. package/dist/ui/widgets/ExplorerView.js +1 -11
  34. package/dist/ui/widgets/FileList.js +2 -8
  35. package/dist/ui/widgets/Footer.js +1 -0
  36. package/dist/ui/widgets/Header.js +37 -3
  37. package/dist/utils/ansi.js +38 -0
  38. package/dist/utils/ansiTruncate.js +1 -5
  39. package/dist/utils/displayRows.js +72 -59
  40. package/dist/utils/fileCategories.js +7 -0
  41. package/dist/utils/fileResolution.js +23 -0
  42. package/dist/utils/languageDetection.js +3 -2
  43. package/dist/utils/logger.js +32 -0
  44. package/metrics/v0.2.3.json +243 -0
  45. package/metrics/v0.2.4.json +236 -0
  46. package/package.json +5 -2
  47. package/dist/utils/layoutCalculations.js +0 -100
@@ -0,0 +1,290 @@
1
+ import { getNextCompareSelection, getRowFromCompareSelection, } from './ui/widgets/CompareListView.js';
2
+ import { getRowFromFileIndex } from './ui/widgets/FileList.js';
3
+ import { getCommitAtIndex } from './ui/widgets/HistoryView.js';
4
+ /**
5
+ * Handles all list and pane navigation: file list, history, compare, explorer, hunks.
6
+ * Owns the compareSelection state.
7
+ */
8
+ export class NavigationController {
9
+ ctx;
10
+ compareSelection = null;
11
+ constructor(ctx) {
12
+ this.ctx = ctx;
13
+ }
14
+ scrollActiveDiffPane(delta) {
15
+ const state = this.ctx.uiState.state;
16
+ if (state.bottomTab === 'explorer') {
17
+ const newOffset = Math.max(0, state.explorerFileScrollOffset + delta);
18
+ this.ctx.uiState.setExplorerFileScrollOffset(newOffset);
19
+ }
20
+ else {
21
+ const newOffset = Math.max(0, state.diffScrollOffset + delta);
22
+ this.ctx.uiState.setDiffScrollOffset(newOffset);
23
+ }
24
+ }
25
+ navigateFileList(direction) {
26
+ const state = this.ctx.uiState.state;
27
+ const files = this.ctx.getGitManager()?.workingTree.state.status?.files ?? [];
28
+ const maxIndex = this.ctx.getFileListMaxIndex();
29
+ if (maxIndex < 0)
30
+ return;
31
+ const newIndex = direction === -1
32
+ ? Math.max(0, state.selectedIndex - 1)
33
+ : Math.min(maxIndex, state.selectedIndex + 1);
34
+ this.ctx.uiState.setSelectedIndex(newIndex);
35
+ this.selectFileByIndex(newIndex);
36
+ const row = state.flatViewMode ? newIndex + 1 : getRowFromFileIndex(newIndex, files);
37
+ this.scrollToKeepRowVisible(row, direction, state.fileListScrollOffset);
38
+ }
39
+ scrollToKeepRowVisible(row, direction, currentOffset) {
40
+ if (direction === -1 && row < currentOffset) {
41
+ this.ctx.uiState.setFileListScrollOffset(row);
42
+ }
43
+ else if (direction === 1) {
44
+ const visibleEnd = currentOffset + this.ctx.getTopPaneHeight() - 1;
45
+ if (row >= visibleEnd) {
46
+ this.ctx.uiState.setFileListScrollOffset(currentOffset + (row - visibleEnd + 1));
47
+ }
48
+ }
49
+ }
50
+ navigateActiveList(direction) {
51
+ const tab = this.ctx.uiState.state.bottomTab;
52
+ if (tab === 'history') {
53
+ if (direction === -1)
54
+ this.navigateHistoryUp();
55
+ else
56
+ this.navigateHistoryDown();
57
+ }
58
+ else if (tab === 'compare') {
59
+ if (direction === -1)
60
+ this.navigateCompareUp();
61
+ else
62
+ this.navigateCompareDown();
63
+ }
64
+ else if (tab === 'explorer') {
65
+ if (direction === -1)
66
+ this.navigateExplorerUp();
67
+ else
68
+ this.navigateExplorerDown();
69
+ }
70
+ else {
71
+ this.navigateFileList(direction);
72
+ }
73
+ }
74
+ navigateUp() {
75
+ const state = this.ctx.uiState.state;
76
+ const isListPane = state.currentPane !== 'diff';
77
+ if (isListPane) {
78
+ this.navigateActiveList(-1);
79
+ }
80
+ else {
81
+ this.scrollActiveDiffPane(-3);
82
+ }
83
+ }
84
+ navigateDown() {
85
+ const state = this.ctx.uiState.state;
86
+ const isListPane = state.currentPane !== 'diff';
87
+ if (isListPane) {
88
+ this.navigateActiveList(1);
89
+ }
90
+ else {
91
+ this.scrollActiveDiffPane(3);
92
+ }
93
+ }
94
+ navigateHistoryUp() {
95
+ const state = this.ctx.uiState.state;
96
+ const newIndex = Math.max(0, state.historySelectedIndex - 1);
97
+ if (newIndex !== state.historySelectedIndex) {
98
+ this.ctx.uiState.setHistorySelectedIndex(newIndex);
99
+ if (newIndex < state.historyScrollOffset) {
100
+ this.ctx.uiState.setHistoryScrollOffset(newIndex);
101
+ }
102
+ this.selectHistoryCommitByIndex(newIndex);
103
+ }
104
+ }
105
+ navigateHistoryDown() {
106
+ const state = this.ctx.uiState.state;
107
+ const commits = this.ctx.getGitManager()?.history.historyState.commits ?? [];
108
+ const newIndex = Math.min(commits.length - 1, state.historySelectedIndex + 1);
109
+ if (newIndex !== state.historySelectedIndex) {
110
+ this.ctx.uiState.setHistorySelectedIndex(newIndex);
111
+ const visibleEnd = state.historyScrollOffset + this.ctx.getTopPaneHeight() - 1;
112
+ if (newIndex >= visibleEnd) {
113
+ this.ctx.uiState.setHistoryScrollOffset(state.historyScrollOffset + 1);
114
+ }
115
+ this.selectHistoryCommitByIndex(newIndex);
116
+ }
117
+ }
118
+ selectHistoryCommitByIndex(index) {
119
+ const gm = this.ctx.getGitManager();
120
+ const commits = gm?.history.historyState.commits ?? [];
121
+ const commit = getCommitAtIndex(commits, index);
122
+ if (commit) {
123
+ this.ctx.uiState.setDiffScrollOffset(0);
124
+ gm?.history.selectHistoryCommit(commit).catch((err) => {
125
+ this.ctx.onError(`Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`);
126
+ });
127
+ }
128
+ }
129
+ navigateCompareUp() {
130
+ const compareState = this.ctx.getGitManager()?.compare.compareState;
131
+ const commits = compareState?.compareDiff?.commits ?? [];
132
+ const files = compareState?.compareDiff?.files ?? [];
133
+ if (commits.length === 0 && files.length === 0)
134
+ return;
135
+ const next = getNextCompareSelection(this.compareSelection, commits, files, 'up');
136
+ if (next &&
137
+ (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
138
+ this.selectCompareItem(next);
139
+ const state = this.ctx.uiState.state;
140
+ const row = getRowFromCompareSelection(next, commits, files);
141
+ if (row < state.compareScrollOffset) {
142
+ this.ctx.uiState.setCompareScrollOffset(row);
143
+ }
144
+ }
145
+ }
146
+ navigateCompareDown() {
147
+ const compareState = this.ctx.getGitManager()?.compare.compareState;
148
+ const commits = compareState?.compareDiff?.commits ?? [];
149
+ const files = compareState?.compareDiff?.files ?? [];
150
+ if (commits.length === 0 && files.length === 0)
151
+ return;
152
+ if (!this.compareSelection) {
153
+ if (commits.length > 0) {
154
+ this.selectCompareItem({ type: 'commit', index: 0 });
155
+ }
156
+ else if (files.length > 0) {
157
+ this.selectCompareItem({ type: 'file', index: 0 });
158
+ }
159
+ return;
160
+ }
161
+ const next = getNextCompareSelection(this.compareSelection, commits, files, 'down');
162
+ if (next &&
163
+ (next.type !== this.compareSelection?.type || next.index !== this.compareSelection?.index)) {
164
+ this.selectCompareItem(next);
165
+ const state = this.ctx.uiState.state;
166
+ const row = getRowFromCompareSelection(next, commits, files);
167
+ const visibleEnd = state.compareScrollOffset + this.ctx.getTopPaneHeight() - 1;
168
+ if (row >= visibleEnd) {
169
+ this.ctx.uiState.setCompareScrollOffset(state.compareScrollOffset + (row - visibleEnd + 1));
170
+ }
171
+ }
172
+ }
173
+ selectCompareItem(selection) {
174
+ this.compareSelection = selection;
175
+ this.ctx.uiState.setDiffScrollOffset(0);
176
+ const gm = this.ctx.getGitManager();
177
+ if (selection.type === 'commit') {
178
+ gm?.compare.selectCompareCommit(selection.index).catch((err) => {
179
+ this.ctx.onError(`Failed to load commit diff: ${err instanceof Error ? err.message : String(err)}`);
180
+ });
181
+ }
182
+ else {
183
+ gm?.compare.selectCompareFile(selection.index);
184
+ }
185
+ }
186
+ navigateExplorerUp() {
187
+ const state = this.ctx.uiState.state;
188
+ const explorer = this.ctx.getExplorerManager();
189
+ const rows = explorer?.state.displayRows ?? [];
190
+ if (rows.length === 0)
191
+ return;
192
+ const newScrollOffset = explorer?.navigateUp(state.explorerScrollOffset);
193
+ if (newScrollOffset !== null && newScrollOffset !== undefined) {
194
+ this.ctx.uiState.setExplorerScrollOffset(newScrollOffset);
195
+ }
196
+ this.ctx.uiState.setExplorerSelectedIndex(explorer?.state.selectedIndex ?? 0);
197
+ }
198
+ navigateExplorerDown() {
199
+ const state = this.ctx.uiState.state;
200
+ const explorer = this.ctx.getExplorerManager();
201
+ const rows = explorer?.state.displayRows ?? [];
202
+ if (rows.length === 0)
203
+ return;
204
+ const visibleHeight = this.ctx.getTopPaneHeight();
205
+ const newScrollOffset = explorer?.navigateDown(state.explorerScrollOffset, visibleHeight);
206
+ if (newScrollOffset !== null && newScrollOffset !== undefined) {
207
+ this.ctx.uiState.setExplorerScrollOffset(newScrollOffset);
208
+ }
209
+ this.ctx.uiState.setExplorerSelectedIndex(explorer?.state.selectedIndex ?? 0);
210
+ }
211
+ async enterExplorerDirectory() {
212
+ const explorer = this.ctx.getExplorerManager();
213
+ await explorer?.enterDirectory();
214
+ this.ctx.uiState.setExplorerFileScrollOffset(0);
215
+ this.ctx.uiState.setExplorerSelectedIndex(explorer?.state.selectedIndex ?? 0);
216
+ }
217
+ async goExplorerUp() {
218
+ const explorer = this.ctx.getExplorerManager();
219
+ await explorer?.goUp();
220
+ this.ctx.uiState.setExplorerFileScrollOffset(0);
221
+ this.ctx.uiState.setExplorerSelectedIndex(explorer?.state.selectedIndex ?? 0);
222
+ }
223
+ selectFileByIndex(index) {
224
+ const file = this.ctx.resolveFileAtIndex(index);
225
+ if (file) {
226
+ this.ctx.uiState.setDiffScrollOffset(0);
227
+ this.ctx.uiState.setSelectedHunkIndex(0);
228
+ this.ctx.getGitManager()?.workingTree.selectFile(file);
229
+ }
230
+ }
231
+ navigateToFile(absolutePath) {
232
+ const repoPath = this.ctx.getRepoPath();
233
+ if (!absolutePath || !repoPath)
234
+ return;
235
+ const repoPrefix = repoPath.endsWith('/') ? repoPath : repoPath + '/';
236
+ if (!absolutePath.startsWith(repoPrefix))
237
+ return;
238
+ const relativePath = absolutePath.slice(repoPrefix.length);
239
+ if (!relativePath)
240
+ return;
241
+ const files = this.ctx.getGitManager()?.workingTree.state.status?.files ?? [];
242
+ const fileIndex = files.findIndex((f) => f.path === relativePath);
243
+ if (fileIndex >= 0) {
244
+ this.ctx.uiState.setSelectedIndex(fileIndex);
245
+ this.selectFileByIndex(fileIndex);
246
+ }
247
+ }
248
+ // Hunk navigation
249
+ navigateNextHunk() {
250
+ const current = this.ctx.uiState.state.selectedHunkIndex;
251
+ const hunkCount = this.ctx.getHunkCount();
252
+ if (hunkCount > 0 && current < hunkCount - 1) {
253
+ this.ctx.uiState.setSelectedHunkIndex(current + 1);
254
+ this.scrollHunkIntoView(current + 1);
255
+ }
256
+ }
257
+ navigatePrevHunk() {
258
+ const current = this.ctx.uiState.state.selectedHunkIndex;
259
+ if (current > 0) {
260
+ this.ctx.uiState.setSelectedHunkIndex(current - 1);
261
+ this.scrollHunkIntoView(current - 1);
262
+ }
263
+ }
264
+ scrollHunkIntoView(hunkIndex) {
265
+ const boundary = this.ctx.getHunkBoundaries()[hunkIndex];
266
+ if (!boundary)
267
+ return;
268
+ const scrollOffset = this.ctx.uiState.state.diffScrollOffset;
269
+ const visibleHeight = this.ctx.getBottomPaneHeight();
270
+ if (boundary.startRow < scrollOffset || boundary.startRow >= scrollOffset + visibleHeight) {
271
+ this.ctx.uiState.setDiffScrollOffset(boundary.startRow);
272
+ }
273
+ }
274
+ selectHunkAtRow(visualRow) {
275
+ if (this.ctx.uiState.state.bottomTab !== 'diff')
276
+ return;
277
+ const boundaries = this.ctx.getHunkBoundaries();
278
+ if (boundaries.length === 0)
279
+ return;
280
+ this.ctx.uiState.setPane('diff');
281
+ const absoluteRow = this.ctx.uiState.state.diffScrollOffset + visualRow;
282
+ for (let i = 0; i < boundaries.length; i++) {
283
+ const b = boundaries[i];
284
+ if (absoluteRow >= b.startRow && absoluteRow < b.endRow) {
285
+ this.ctx.uiState.setSelectedHunkIndex(i);
286
+ return;
287
+ }
288
+ }
289
+ }
290
+ }
@@ -0,0 +1,199 @@
1
+ import { getFlatFileAtIndex } from './utils/flatFileList.js';
2
+ import { getCategoryForIndex } from './utils/fileCategories.js';
3
+ import { extractHunkPatch } from './git/diff.js';
4
+ /**
5
+ * Handles all file and hunk staging/unstaging operations.
6
+ * Owns selection anchoring state used for reconciliation after git state changes.
7
+ */
8
+ export class StagingOperations {
9
+ ctx;
10
+ pendingSelectionAnchor = null;
11
+ pendingFlatSelectionPath = null;
12
+ pendingHunkIndex = null;
13
+ constructor(ctx) {
14
+ this.ctx = ctx;
15
+ }
16
+ consumePendingSelectionAnchor() {
17
+ const value = this.pendingSelectionAnchor;
18
+ this.pendingSelectionAnchor = null;
19
+ return value;
20
+ }
21
+ consumePendingFlatSelectionPath() {
22
+ const value = this.pendingFlatSelectionPath;
23
+ this.pendingFlatSelectionPath = null;
24
+ return value;
25
+ }
26
+ consumePendingHunkIndex() {
27
+ const value = this.pendingHunkIndex;
28
+ this.pendingHunkIndex = null;
29
+ return value;
30
+ }
31
+ async stageSelected() {
32
+ const gm = this.ctx.getGitManager();
33
+ const wt = gm?.workingTree;
34
+ const files = wt?.state.status?.files ?? [];
35
+ const index = this.ctx.uiState.state.selectedIndex;
36
+ if (this.ctx.uiState.state.flatViewMode) {
37
+ const flatEntry = getFlatFileAtIndex(this.ctx.getCachedFlatFiles(), index);
38
+ if (!flatEntry)
39
+ return;
40
+ const file = flatEntry.unstagedEntry;
41
+ if (file) {
42
+ this.pendingFlatSelectionPath = flatEntry.path;
43
+ await wt?.stage(file);
44
+ }
45
+ }
46
+ else {
47
+ const selectedFile = this.ctx.resolveFileAtIndex(index);
48
+ if (selectedFile && !selectedFile.staged) {
49
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
50
+ await wt?.stage(selectedFile);
51
+ }
52
+ }
53
+ }
54
+ async unstageSelected() {
55
+ const gm = this.ctx.getGitManager();
56
+ const wt = gm?.workingTree;
57
+ const files = wt?.state.status?.files ?? [];
58
+ const index = this.ctx.uiState.state.selectedIndex;
59
+ if (this.ctx.uiState.state.flatViewMode) {
60
+ const flatEntry = getFlatFileAtIndex(this.ctx.getCachedFlatFiles(), index);
61
+ if (!flatEntry)
62
+ return;
63
+ const file = flatEntry.stagedEntry;
64
+ if (file) {
65
+ this.pendingFlatSelectionPath = flatEntry.path;
66
+ await wt?.unstage(file);
67
+ }
68
+ }
69
+ else {
70
+ const selectedFile = this.ctx.resolveFileAtIndex(index);
71
+ if (selectedFile?.staged) {
72
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
73
+ await wt?.unstage(selectedFile);
74
+ }
75
+ }
76
+ }
77
+ async toggleSelected() {
78
+ const index = this.ctx.uiState.state.selectedIndex;
79
+ if (this.ctx.uiState.state.flatViewMode) {
80
+ const flatEntry = getFlatFileAtIndex(this.ctx.getCachedFlatFiles(), index);
81
+ if (flatEntry)
82
+ await this.toggleFlatEntry(flatEntry);
83
+ }
84
+ else {
85
+ const gm = this.ctx.getGitManager();
86
+ const wt = gm?.workingTree;
87
+ const files = wt?.state.status?.files ?? [];
88
+ const selectedFile = this.ctx.resolveFileAtIndex(index);
89
+ if (selectedFile) {
90
+ this.pendingSelectionAnchor = getCategoryForIndex(files, index);
91
+ if (selectedFile.staged) {
92
+ await wt?.unstage(selectedFile);
93
+ }
94
+ else {
95
+ await wt?.stage(selectedFile);
96
+ }
97
+ }
98
+ }
99
+ }
100
+ async stageAll() {
101
+ await this.ctx.getGitManager()?.workingTree.stageAll();
102
+ }
103
+ async unstageAll() {
104
+ await this.ctx.getGitManager()?.workingTree.unstageAll();
105
+ }
106
+ async toggleFlatEntry(entry) {
107
+ const wt = this.ctx.getGitManager()?.workingTree;
108
+ this.pendingFlatSelectionPath = entry.path;
109
+ if (entry.stagingState === 'staged') {
110
+ if (entry.stagedEntry)
111
+ await wt?.unstage(entry.stagedEntry);
112
+ }
113
+ else {
114
+ if (entry.unstagedEntry)
115
+ await wt?.stage(entry.unstagedEntry);
116
+ }
117
+ }
118
+ async toggleFileByIndex(index) {
119
+ if (this.ctx.uiState.state.flatViewMode) {
120
+ const flatEntry = getFlatFileAtIndex(this.ctx.getCachedFlatFiles(), index);
121
+ if (flatEntry)
122
+ await this.toggleFlatEntry(flatEntry);
123
+ }
124
+ else {
125
+ const gm = this.ctx.getGitManager();
126
+ const wt = gm?.workingTree;
127
+ const files = wt?.state.status?.files ?? [];
128
+ const file = this.ctx.resolveFileAtIndex(index);
129
+ if (file) {
130
+ this.pendingSelectionAnchor = getCategoryForIndex(files, this.ctx.uiState.state.selectedIndex);
131
+ if (file.staged) {
132
+ await wt?.unstage(file);
133
+ }
134
+ else {
135
+ await wt?.stage(file);
136
+ }
137
+ }
138
+ }
139
+ }
140
+ // Hunk staging
141
+ async toggleCurrentHunk() {
142
+ const selectedFile = this.ctx.getGitManager()?.workingTree.state.selectedFile;
143
+ if (!selectedFile)
144
+ return;
145
+ if (selectedFile.status === 'untracked') {
146
+ // Hunk staging not available for untracked files; stage the whole file
147
+ // but preserve selection state like a normal hunk toggle would
148
+ const wt = this.ctx.getGitManager()?.workingTree;
149
+ const files = wt?.state.status?.files ?? [];
150
+ this.pendingSelectionAnchor = getCategoryForIndex(files, this.ctx.uiState.state.selectedIndex);
151
+ this.pendingHunkIndex = this.ctx.uiState.state.selectedHunkIndex;
152
+ await wt?.stage(selectedFile);
153
+ return;
154
+ }
155
+ if (this.ctx.uiState.state.flatViewMode) {
156
+ await this.toggleCurrentHunkFlat();
157
+ }
158
+ else {
159
+ await this.toggleCurrentHunkCategorized(selectedFile);
160
+ }
161
+ }
162
+ async toggleCurrentHunkFlat() {
163
+ const mapping = this.ctx.getCombinedHunkMapping()[this.ctx.uiState.state.selectedHunkIndex];
164
+ if (!mapping)
165
+ return;
166
+ const wt = this.ctx.getGitManager()?.workingTree;
167
+ const combined = wt?.state.combinedFileDiffs;
168
+ if (!combined)
169
+ return;
170
+ const rawDiff = mapping.source === 'unstaged' ? combined.unstaged.raw : combined.staged.raw;
171
+ const patch = extractHunkPatch(rawDiff, mapping.hunkIndex);
172
+ if (!patch)
173
+ return;
174
+ this.pendingHunkIndex = this.ctx.uiState.state.selectedHunkIndex;
175
+ if (mapping.source === 'staged') {
176
+ await wt?.unstageHunk(patch);
177
+ }
178
+ else {
179
+ await wt?.stageHunk(patch);
180
+ }
181
+ }
182
+ async toggleCurrentHunkCategorized(selectedFile) {
183
+ const wt = this.ctx.getGitManager()?.workingTree;
184
+ const rawDiff = wt?.state.diff?.raw;
185
+ if (!rawDiff)
186
+ return;
187
+ const patch = extractHunkPatch(rawDiff, this.ctx.uiState.state.selectedHunkIndex);
188
+ if (!patch)
189
+ return;
190
+ const files = wt?.state.status?.files ?? [];
191
+ this.pendingSelectionAnchor = getCategoryForIndex(files, this.ctx.uiState.state.selectedIndex);
192
+ if (selectedFile.staged) {
193
+ await wt?.unstageHunk(patch);
194
+ }
195
+ else {
196
+ await wt?.stageHunk(patch);
197
+ }
198
+ }
199
+ }
package/dist/config.js CHANGED
@@ -40,6 +40,24 @@ export function loadConfig() {
40
40
  fileConfig.splitRatio <= 0.85) {
41
41
  config.splitRatio = fileConfig.splitRatio;
42
42
  }
43
+ if (typeof fileConfig.autoTabEnabled === 'boolean') {
44
+ config.autoTabEnabled = fileConfig.autoTabEnabled;
45
+ }
46
+ if (typeof fileConfig.wrapMode === 'boolean') {
47
+ config.wrapMode = fileConfig.wrapMode;
48
+ }
49
+ if (typeof fileConfig.mouseEnabled === 'boolean') {
50
+ config.mouseEnabled = fileConfig.mouseEnabled;
51
+ }
52
+ if (Array.isArray(fileConfig.recentRepos) &&
53
+ fileConfig.recentRepos.every((r) => typeof r === 'string')) {
54
+ config.recentRepos = fileConfig.recentRepos;
55
+ }
56
+ if (typeof fileConfig.maxRecentRepos === 'number' &&
57
+ fileConfig.maxRecentRepos >= 1 &&
58
+ fileConfig.maxRecentRepos <= 50) {
59
+ config.maxRecentRepos = fileConfig.maxRecentRepos;
60
+ }
43
61
  }
44
62
  catch {
45
63
  // Ignore config file errors
@@ -81,3 +99,24 @@ export function abbreviateHomePath(fullPath) {
81
99
  }
82
100
  return fullPath;
83
101
  }
102
+ function normalizeRepoPath(p) {
103
+ return p.length > 1 && p.endsWith('/') ? p.slice(0, -1) : p;
104
+ }
105
+ export function addRecentRepo(repoPath, maxRecentRepos = 10) {
106
+ const normalized = normalizeRepoPath(repoPath);
107
+ let existing = [];
108
+ if (fs.existsSync(CONFIG_PATH)) {
109
+ try {
110
+ const fileConfig = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
111
+ if (Array.isArray(fileConfig.recentRepos))
112
+ existing = fileConfig.recentRepos;
113
+ }
114
+ catch {
115
+ // start fresh
116
+ }
117
+ }
118
+ const filtered = existing.filter((r) => normalizeRepoPath(r) !== normalized);
119
+ saveConfig({
120
+ recentRepos: [normalized, ...filtered.map(normalizeRepoPath)].slice(0, maxRecentRepos),
121
+ });
122
+ }
@@ -0,0 +1,134 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import { getDiffBetweenRefs, getCompareDiffWithUncommitted, getDefaultBaseBranch, getCandidateBaseBranches as gitGetCandidateBaseBranches, getCommitDiff, } from '../git/diff.js';
3
+ import { getCachedBaseBranch, setCachedBaseBranch } from '../utils/baseBranchCache.js';
4
+ /**
5
+ * Manages branch comparison state: base branch, diff, and selection.
6
+ */
7
+ export class CompareManager extends EventEmitter {
8
+ repoPath;
9
+ queue;
10
+ _compareState = {
11
+ compareDiff: null,
12
+ compareBaseBranch: null,
13
+ compareLoading: false,
14
+ compareError: null,
15
+ };
16
+ _compareSelectionState = {
17
+ type: null,
18
+ index: 0,
19
+ diff: null,
20
+ };
21
+ constructor(repoPath, queue) {
22
+ super();
23
+ this.repoPath = repoPath;
24
+ this.queue = queue;
25
+ }
26
+ get compareState() {
27
+ return this._compareState;
28
+ }
29
+ get compareSelectionState() {
30
+ return this._compareSelectionState;
31
+ }
32
+ updateCompareState(partial) {
33
+ this._compareState = { ...this._compareState, ...partial };
34
+ this.emit('compare-state-change', this._compareState);
35
+ }
36
+ updateCompareSelectionState(partial) {
37
+ this._compareSelectionState = { ...this._compareSelectionState, ...partial };
38
+ this.emit('compare-selection-change', this._compareSelectionState);
39
+ }
40
+ /**
41
+ * Refresh compare diff if it was previously loaded (has a base branch set).
42
+ * Called by the cascade refresh after file changes.
43
+ */
44
+ async refreshIfLoaded() {
45
+ if (this._compareState.compareBaseBranch) {
46
+ await this.doRefreshCompareDiff(false);
47
+ }
48
+ }
49
+ /**
50
+ * Reset the base branch (e.g. after switching branches).
51
+ */
52
+ resetBaseBranch() {
53
+ this.updateCompareState({ compareBaseBranch: null });
54
+ }
55
+ /**
56
+ * Refresh compare diff.
57
+ */
58
+ async refreshCompareDiff(includeUncommitted = false) {
59
+ this.updateCompareState({ compareLoading: true, compareError: null });
60
+ try {
61
+ await this.queue.enqueue(() => this.doRefreshCompareDiff(includeUncommitted));
62
+ }
63
+ catch (err) {
64
+ this.updateCompareState({
65
+ compareLoading: false,
66
+ compareError: `Failed to load compare diff: ${err instanceof Error ? err.message : String(err)}`,
67
+ });
68
+ }
69
+ }
70
+ async doRefreshCompareDiff(includeUncommitted) {
71
+ let base = this._compareState.compareBaseBranch;
72
+ if (!base) {
73
+ // Try cached value first, then fall back to default detection
74
+ base = getCachedBaseBranch(this.repoPath) ?? (await getDefaultBaseBranch(this.repoPath));
75
+ this.updateCompareState({ compareBaseBranch: base });
76
+ }
77
+ if (base) {
78
+ const diff = includeUncommitted
79
+ ? await getCompareDiffWithUncommitted(this.repoPath, base)
80
+ : await getDiffBetweenRefs(this.repoPath, base);
81
+ this.updateCompareState({ compareDiff: diff, compareLoading: false });
82
+ }
83
+ else {
84
+ this.updateCompareState({
85
+ compareDiff: null,
86
+ compareLoading: false,
87
+ compareError: 'No base branch found',
88
+ });
89
+ }
90
+ }
91
+ /**
92
+ * Get candidate base branches for branch comparison.
93
+ */
94
+ async getCandidateBaseBranches() {
95
+ return gitGetCandidateBaseBranches(this.repoPath);
96
+ }
97
+ /**
98
+ * Set the base branch for branch comparison and refresh.
99
+ * Also saves the selection to the cache for future sessions.
100
+ */
101
+ async setCompareBaseBranch(branch, includeUncommitted = false) {
102
+ this.updateCompareState({ compareBaseBranch: branch });
103
+ setCachedBaseBranch(this.repoPath, branch);
104
+ await this.refreshCompareDiff(includeUncommitted);
105
+ }
106
+ /**
107
+ * Select a commit in compare view and load its diff.
108
+ */
109
+ async selectCompareCommit(index) {
110
+ const compareDiff = this._compareState.compareDiff;
111
+ if (!compareDiff || index < 0 || index >= compareDiff.commits.length) {
112
+ this.updateCompareSelectionState({ type: null, index: 0, diff: null });
113
+ return;
114
+ }
115
+ const commit = compareDiff.commits[index];
116
+ this.updateCompareSelectionState({ type: 'commit', index, diff: null });
117
+ await this.queue.enqueue(async () => {
118
+ const diff = await getCommitDiff(this.repoPath, commit.hash);
119
+ this.updateCompareSelectionState({ diff });
120
+ });
121
+ }
122
+ /**
123
+ * Select a file in compare view and show its diff.
124
+ */
125
+ selectCompareFile(index) {
126
+ const compareDiff = this._compareState.compareDiff;
127
+ if (!compareDiff || index < 0 || index >= compareDiff.files.length) {
128
+ this.updateCompareSelectionState({ type: null, index: 0, diff: null });
129
+ return;
130
+ }
131
+ const file = compareDiff.files[index];
132
+ this.updateCompareSelectionState({ type: 'file', index, diff: file.diff });
133
+ }
134
+ }