diffstalker 0.2.0 → 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.
Files changed (46) hide show
  1. package/.dependency-cruiser.cjs +67 -0
  2. package/.githooks/pre-commit +2 -0
  3. package/.githooks/pre-push +15 -0
  4. package/.github/workflows/release.yml +8 -0
  5. package/README.md +43 -35
  6. package/bun.lock +82 -3
  7. package/dist/App.js +555 -552
  8. package/dist/FollowMode.js +85 -0
  9. package/dist/KeyBindings.js +228 -0
  10. package/dist/MouseHandlers.js +192 -0
  11. package/dist/core/ExplorerStateManager.js +423 -78
  12. package/dist/core/GitStateManager.js +260 -119
  13. package/dist/git/diff.js +102 -17
  14. package/dist/git/status.js +16 -54
  15. package/dist/git/test-helpers.js +67 -0
  16. package/dist/index.js +60 -53
  17. package/dist/ipc/CommandClient.js +6 -7
  18. package/dist/state/UIState.js +39 -4
  19. package/dist/ui/PaneRenderers.js +76 -0
  20. package/dist/ui/modals/FileFinder.js +193 -0
  21. package/dist/ui/modals/HotkeysModal.js +12 -3
  22. package/dist/ui/modals/ThemePicker.js +1 -2
  23. package/dist/ui/widgets/CommitPanel.js +1 -1
  24. package/dist/ui/widgets/CompareListView.js +123 -80
  25. package/dist/ui/widgets/DiffView.js +228 -180
  26. package/dist/ui/widgets/ExplorerContent.js +15 -28
  27. package/dist/ui/widgets/ExplorerView.js +148 -43
  28. package/dist/ui/widgets/FileList.js +62 -95
  29. package/dist/ui/widgets/FlatFileList.js +65 -0
  30. package/dist/ui/widgets/Footer.js +25 -11
  31. package/dist/ui/widgets/Header.js +17 -52
  32. package/dist/ui/widgets/fileRowFormatters.js +73 -0
  33. package/dist/utils/ansiTruncate.js +0 -1
  34. package/dist/utils/displayRows.js +101 -21
  35. package/dist/utils/fileCategories.js +37 -0
  36. package/dist/utils/fileTree.js +148 -0
  37. package/dist/utils/flatFileList.js +67 -0
  38. package/dist/utils/layoutCalculations.js +5 -3
  39. package/eslint.metrics.js +15 -0
  40. package/metrics/.gitkeep +0 -0
  41. package/metrics/v0.2.1.json +268 -0
  42. package/metrics/v0.2.2.json +229 -0
  43. package/package.json +9 -2
  44. package/dist/utils/ansiToBlessed.js +0 -125
  45. package/dist/utils/mouseCoordinates.js +0 -165
  46. package/dist/utils/rowCalculations.js +0 -246
@@ -0,0 +1,85 @@
1
+ import { FilePathWatcher } from './core/FilePathWatcher.js';
2
+ /**
3
+ * Manages the file-watching follow mode.
4
+ * Watches a target file for repository path changes and file navigation.
5
+ */
6
+ export class FollowMode {
7
+ targetFile;
8
+ getCurrentRepoPath;
9
+ callbacks;
10
+ watcher = null;
11
+ _watcherState = { enabled: false };
12
+ constructor(targetFile, getCurrentRepoPath, callbacks) {
13
+ this.targetFile = targetFile;
14
+ this.getCurrentRepoPath = getCurrentRepoPath;
15
+ this.callbacks = callbacks;
16
+ }
17
+ get watcherState() {
18
+ return this._watcherState;
19
+ }
20
+ get isEnabled() {
21
+ return this.watcher !== null;
22
+ }
23
+ /**
24
+ * Start watching the target file.
25
+ */
26
+ start() {
27
+ this.watcher = new FilePathWatcher(this.targetFile);
28
+ this.watcher.on('path-change', (state) => {
29
+ if (state.path && state.path !== this.getCurrentRepoPath()) {
30
+ this._watcherState = {
31
+ enabled: true,
32
+ sourceFile: state.sourceFile ?? this.targetFile,
33
+ rawContent: state.rawContent ?? undefined,
34
+ lastUpdate: state.lastUpdate ?? undefined,
35
+ };
36
+ this.callbacks.onRepoChange(state.path, this._watcherState);
37
+ }
38
+ // Navigate to the followed file if it's within the repo
39
+ if (state.rawContent) {
40
+ this.callbacks.onFileNavigate(state.rawContent);
41
+ }
42
+ });
43
+ this._watcherState = {
44
+ enabled: true,
45
+ sourceFile: this.targetFile,
46
+ };
47
+ this.watcher.start();
48
+ // Switch to the repo described in the target file
49
+ const initialState = this.watcher.state;
50
+ if (initialState.path && initialState.path !== this.getCurrentRepoPath()) {
51
+ this._watcherState = {
52
+ enabled: true,
53
+ sourceFile: initialState.sourceFile ?? this.targetFile,
54
+ rawContent: initialState.rawContent ?? undefined,
55
+ lastUpdate: initialState.lastUpdate ?? undefined,
56
+ };
57
+ this.callbacks.onRepoChange(initialState.path, this._watcherState);
58
+ }
59
+ else if (initialState.rawContent) {
60
+ this._watcherState.rawContent = initialState.rawContent;
61
+ this.callbacks.onFileNavigate(initialState.rawContent);
62
+ }
63
+ }
64
+ /**
65
+ * Toggle follow mode on/off.
66
+ */
67
+ toggle() {
68
+ if (this.watcher) {
69
+ this.stop();
70
+ }
71
+ else {
72
+ this.start();
73
+ }
74
+ }
75
+ /**
76
+ * Stop watching.
77
+ */
78
+ stop() {
79
+ if (this.watcher) {
80
+ this.watcher.stop();
81
+ this.watcher = null;
82
+ this._watcherState = { enabled: false };
83
+ }
84
+ }
85
+ }
@@ -0,0 +1,228 @@
1
+ import { SPLIT_RATIO_STEP } from './ui/Layout.js';
2
+ import { getFileAtIndex } from './ui/widgets/FileList.js';
3
+ import { getFlatFileAtIndex } from './utils/flatFileList.js';
4
+ /**
5
+ * Register all keyboard bindings on the blessed screen.
6
+ */
7
+ export function setupKeyBindings(screen, actions, ctx) {
8
+ // Quit
9
+ screen.key(['q', 'C-c'], () => {
10
+ actions.exit();
11
+ });
12
+ // Navigation (skip if modal is open)
13
+ screen.key(['j', 'down'], () => {
14
+ if (ctx.hasActiveModal())
15
+ return;
16
+ actions.navigateDown();
17
+ });
18
+ screen.key(['k', 'up'], () => {
19
+ if (ctx.hasActiveModal())
20
+ return;
21
+ actions.navigateUp();
22
+ });
23
+ // Tab switching (skip if modal is open)
24
+ const tabs = [
25
+ ['1', 'diff'],
26
+ ['2', 'commit'],
27
+ ['3', 'history'],
28
+ ['4', 'compare'],
29
+ ['5', 'explorer'],
30
+ ];
31
+ for (const [key, tab] of tabs) {
32
+ screen.key([key], () => {
33
+ if (ctx.hasActiveModal())
34
+ return;
35
+ ctx.uiState.setTab(tab);
36
+ });
37
+ }
38
+ // Pane toggle (skip if modal is open)
39
+ screen.key(['tab'], () => {
40
+ if (ctx.hasActiveModal())
41
+ return;
42
+ ctx.uiState.togglePane();
43
+ });
44
+ // Staging operations (skip if modal is open)
45
+ // Context-aware: hunk staging when diff pane is focused on diff tab
46
+ screen.key(['s'], () => {
47
+ if (ctx.hasActiveModal())
48
+ return;
49
+ if (ctx.getBottomTab() === 'diff' && ctx.getCurrentPane() === 'diff') {
50
+ actions.toggleCurrentHunk();
51
+ }
52
+ else {
53
+ actions.stageSelected();
54
+ }
55
+ });
56
+ screen.key(['S-u'], () => {
57
+ if (ctx.hasActiveModal())
58
+ return;
59
+ actions.unstageSelected();
60
+ });
61
+ screen.key(['S-a'], () => {
62
+ if (ctx.hasActiveModal())
63
+ return;
64
+ actions.stageAll();
65
+ });
66
+ screen.key(['S-z'], () => {
67
+ if (ctx.hasActiveModal())
68
+ return;
69
+ actions.unstageAll();
70
+ });
71
+ // Select/toggle (skip if modal is open)
72
+ screen.key(['enter', 'space'], () => {
73
+ if (ctx.hasActiveModal())
74
+ return;
75
+ if (ctx.getBottomTab() === 'explorer' && ctx.getCurrentPane() === 'explorer') {
76
+ actions.enterExplorerDirectory();
77
+ }
78
+ else {
79
+ actions.toggleSelected();
80
+ }
81
+ });
82
+ // Explorer: go up directory (skip if modal is open)
83
+ screen.key(['backspace'], () => {
84
+ if (ctx.hasActiveModal())
85
+ return;
86
+ if (ctx.getBottomTab() === 'explorer' && ctx.getCurrentPane() === 'explorer') {
87
+ actions.goExplorerUp();
88
+ }
89
+ });
90
+ // Explorer: toggle show only changes filter
91
+ screen.key(['g'], () => {
92
+ if (ctx.hasActiveModal())
93
+ return;
94
+ if (ctx.getBottomTab() === 'explorer') {
95
+ ctx.getExplorerManager()?.toggleShowOnlyChanges();
96
+ }
97
+ });
98
+ // Explorer: open file finder
99
+ screen.key(['/'], () => {
100
+ if (ctx.hasActiveModal())
101
+ return;
102
+ if (ctx.getBottomTab() === 'explorer') {
103
+ actions.openFileFinder();
104
+ }
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
+ });
112
+ // Commit (skip if modal is open)
113
+ screen.key(['c'], () => {
114
+ if (ctx.hasActiveModal())
115
+ return;
116
+ ctx.uiState.setTab('commit');
117
+ });
118
+ // Commit panel specific keys (only when on commit tab)
119
+ screen.key(['i'], () => {
120
+ if (ctx.getBottomTab() === 'commit' && !ctx.isCommitInputFocused()) {
121
+ actions.focusCommitInput();
122
+ }
123
+ });
124
+ screen.key(['a'], () => {
125
+ if (ctx.getBottomTab() === 'commit' && !ctx.isCommitInputFocused()) {
126
+ ctx.commitFlowState.toggleAmend();
127
+ actions.render();
128
+ }
129
+ else {
130
+ ctx.uiState.toggleAutoTab();
131
+ }
132
+ });
133
+ screen.key(['escape'], () => {
134
+ if (ctx.getBottomTab() === 'commit') {
135
+ if (ctx.isCommitInputFocused()) {
136
+ actions.unfocusCommitInput();
137
+ }
138
+ else {
139
+ ctx.uiState.setTab('diff');
140
+ }
141
+ }
142
+ });
143
+ // Refresh
144
+ screen.key(['r'], () => actions.refresh());
145
+ // Display toggles
146
+ screen.key(['w'], () => ctx.uiState.toggleWrapMode());
147
+ screen.key(['m'], () => actions.toggleMouseMode());
148
+ screen.key(['S-t'], () => ctx.uiState.toggleAutoTab());
149
+ // Split ratio adjustments
150
+ screen.key(['-', '_', '['], () => {
151
+ ctx.uiState.adjustSplitRatio(-SPLIT_RATIO_STEP);
152
+ ctx.layout.setSplitRatio(ctx.uiState.state.splitRatio);
153
+ actions.render();
154
+ });
155
+ screen.key(['=', '+', ']'], () => {
156
+ ctx.uiState.adjustSplitRatio(SPLIT_RATIO_STEP);
157
+ ctx.layout.setSplitRatio(ctx.uiState.state.splitRatio);
158
+ actions.render();
159
+ });
160
+ // Theme picker
161
+ screen.key(['t'], () => ctx.uiState.openModal('theme'));
162
+ // Hotkeys modal
163
+ screen.key(['?'], () => ctx.uiState.toggleModal('hotkeys'));
164
+ // Follow toggle
165
+ screen.key(['f'], () => actions.toggleFollow());
166
+ // Compare view: base branch picker
167
+ screen.key(['b'], () => {
168
+ if (ctx.getBottomTab() === 'compare') {
169
+ ctx.uiState.openModal('baseBranch');
170
+ }
171
+ });
172
+ // u: toggle uncommitted in compare view
173
+ screen.key(['u'], () => {
174
+ if (ctx.hasActiveModal())
175
+ return;
176
+ if (ctx.getBottomTab() === 'compare') {
177
+ ctx.uiState.toggleIncludeUncommitted();
178
+ const includeUncommitted = ctx.uiState.state.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();
189
+ }
190
+ });
191
+ // Discard changes (with confirmation)
192
+ screen.key(['d'], () => {
193
+ if (ctx.getBottomTab() === 'diff') {
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
+ }
210
+ }
211
+ }
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
+ });
228
+ }
@@ -0,0 +1,192 @@
1
+ import { getFileListTotalRows, getFileIndexFromRow } from './ui/widgets/FileList.js';
2
+ import { getFlatFileListTotalRows } from './ui/widgets/FlatFileList.js';
3
+ import { getCompareListTotalRows, getCompareSelectionFromRow, } from './ui/widgets/CompareListView.js';
4
+ import { getExplorerTotalRows } from './ui/widgets/ExplorerView.js';
5
+ import { getExplorerContentTotalRows } from './ui/widgets/ExplorerContent.js';
6
+ const SCROLL_AMOUNT = 3;
7
+ /**
8
+ * Register all mouse event handlers on the layout.
9
+ */
10
+ export function setupMouseHandlers(layout, actions, ctx) {
11
+ // Mouse wheel on top pane
12
+ layout.topPane.on('wheeldown', () => {
13
+ handleTopPaneScroll(SCROLL_AMOUNT, layout, ctx);
14
+ });
15
+ layout.topPane.on('wheelup', () => {
16
+ handleTopPaneScroll(-SCROLL_AMOUNT, layout, ctx);
17
+ });
18
+ // Mouse wheel on bottom pane
19
+ layout.bottomPane.on('wheeldown', () => {
20
+ handleBottomPaneScroll(SCROLL_AMOUNT, layout, ctx);
21
+ });
22
+ layout.bottomPane.on('wheelup', () => {
23
+ handleBottomPaneScroll(-SCROLL_AMOUNT, layout, ctx);
24
+ });
25
+ // Click on top pane to select item
26
+ layout.topPane.on('click', (mouse) => {
27
+ const clickedRow = layout.screenYToTopPaneRow(mouse.y);
28
+ if (clickedRow >= 0) {
29
+ handleTopPaneClick(clickedRow, mouse.x, actions, ctx);
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
+ });
39
+ // Click on footer for tabs and toggles
40
+ layout.footerBox.on('click', (mouse) => {
41
+ handleFooterClick(mouse.x, actions, ctx);
42
+ });
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
+ }
75
+ function handleTopPaneClick(row, x, actions, ctx) {
76
+ const state = ctx.uiState.state;
77
+ if (state.bottomTab === 'history') {
78
+ const index = state.historyScrollOffset + row;
79
+ ctx.uiState.setHistorySelectedIndex(index);
80
+ actions.selectHistoryCommitByIndex(index);
81
+ }
82
+ else if (state.bottomTab === 'compare') {
83
+ const commits = ctx.getCompareCommits();
84
+ const files = ctx.getCompareFiles();
85
+ const selection = getCompareSelectionFromRow(state.compareScrollOffset + row, commits, files);
86
+ if (selection) {
87
+ actions.selectCompareItem(selection);
88
+ }
89
+ }
90
+ else if (state.bottomTab === 'explorer') {
91
+ const index = state.explorerScrollOffset + row;
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
+ }
102
+ }
103
+ else {
104
+ handleFileListClick(row, x, actions, ctx);
105
+ }
106
+ }
107
+ function handleFooterClick(x, actions, ctx) {
108
+ const width = ctx.getScreenWidth();
109
+ // Tabs are right-aligned
110
+ const tabPositions = [
111
+ { tab: 'explorer', width: 11 },
112
+ { tab: 'compare', width: 10 },
113
+ { tab: 'history', width: 10 },
114
+ { tab: 'commit', width: 9 },
115
+ { tab: 'diff', width: 7 },
116
+ ];
117
+ let rightEdge = width;
118
+ for (const { tab, width: tabWidth } of tabPositions) {
119
+ const leftEdge = rightEdge - tabWidth - 1;
120
+ if (x >= leftEdge && x < rightEdge) {
121
+ ctx.uiState.setTab(tab);
122
+ return;
123
+ }
124
+ rightEdge = leftEdge;
125
+ }
126
+ // Left side toggles (approximate positions)
127
+ if (x >= 2 && x <= 9) {
128
+ actions.toggleMouseMode();
129
+ }
130
+ else if (x >= 11 && x <= 16) {
131
+ ctx.uiState.toggleAutoTab();
132
+ }
133
+ else if (x >= 18 && x <= 23) {
134
+ ctx.uiState.toggleWrapMode();
135
+ }
136
+ else if (x >= 25 && x <= 32) {
137
+ actions.toggleFollow();
138
+ }
139
+ else if (x >= 34 && x <= 43 && ctx.uiState.state.bottomTab === 'explorer') {
140
+ ctx.getExplorerManager()?.toggleShowOnlyChanges();
141
+ }
142
+ else if (x === 0) {
143
+ ctx.uiState.openModal('hotkeys');
144
+ }
145
+ }
146
+ function handleTopPaneScroll(delta, layout, ctx) {
147
+ const state = ctx.uiState.state;
148
+ const visibleHeight = layout.dimensions.topPaneHeight;
149
+ if (state.bottomTab === 'history') {
150
+ const totalRows = ctx.getHistoryCommitCount();
151
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
152
+ const newOffset = Math.min(maxOffset, Math.max(0, state.historyScrollOffset + delta));
153
+ ctx.uiState.setHistoryScrollOffset(newOffset);
154
+ }
155
+ else if (state.bottomTab === 'compare') {
156
+ const totalRows = getCompareListTotalRows(ctx.getCompareCommits(), ctx.getCompareFiles());
157
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
158
+ const newOffset = Math.min(maxOffset, Math.max(0, state.compareScrollOffset + delta));
159
+ ctx.uiState.setCompareScrollOffset(newOffset);
160
+ }
161
+ else if (state.bottomTab === 'explorer') {
162
+ const totalRows = getExplorerTotalRows(ctx.getExplorerManager()?.state.displayRows ?? []);
163
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
164
+ const newOffset = Math.min(maxOffset, Math.max(0, state.explorerScrollOffset + delta));
165
+ ctx.uiState.setExplorerScrollOffset(newOffset);
166
+ }
167
+ else {
168
+ const totalRows = state.flatViewMode
169
+ ? getFlatFileListTotalRows(ctx.getCachedFlatFiles())
170
+ : getFileListTotalRows(ctx.getStatusFiles());
171
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
172
+ const newOffset = Math.min(maxOffset, Math.max(0, state.fileListScrollOffset + delta));
173
+ ctx.uiState.setFileListScrollOffset(newOffset);
174
+ }
175
+ }
176
+ function handleBottomPaneScroll(delta, layout, ctx) {
177
+ const state = ctx.uiState.state;
178
+ const visibleHeight = layout.dimensions.bottomPaneHeight;
179
+ const width = ctx.getScreenWidth();
180
+ if (state.bottomTab === 'explorer') {
181
+ const selectedFile = ctx.getExplorerManager()?.state.selectedFile;
182
+ const totalRows = getExplorerContentTotalRows(selectedFile?.content ?? null, selectedFile?.path ?? null, selectedFile?.truncated ?? false, width, state.wrapMode);
183
+ const maxOffset = Math.max(0, totalRows - visibleHeight);
184
+ const newOffset = Math.min(maxOffset, Math.max(0, state.explorerFileScrollOffset + delta));
185
+ ctx.uiState.setExplorerFileScrollOffset(newOffset);
186
+ }
187
+ else {
188
+ const maxOffset = Math.max(0, ctx.getBottomPaneTotalRows() - visibleHeight);
189
+ const newOffset = Math.min(maxOffset, Math.max(0, state.diffScrollOffset + delta));
190
+ ctx.uiState.setDiffScrollOffset(newOffset);
191
+ }
192
+ }