vzcode 2.15.0 → 2.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{index-BpUpNto4.js → index-C2BLPyQP.js} +138 -125
- package/dist/assets/{index-DLm5FQ0E.css → index-DVR90LwF.css} +1 -1
- package/dist/index.html +2 -2
- package/dist/server/aiChatHandler/chatOperations.js +168 -35
- package/dist/server/aiChatHandler/index.js +11 -30
- package/dist/server/aiChatHandler/llmStreaming.js +105 -118
- package/package.json +11 -11
- package/src/client/AIAssist/AIAssistWidget/index.tsx +12 -1
- package/src/client/App/useShareDB.ts +1 -1
- package/src/client/CodeEditor/index.tsx +9 -10
- package/src/client/VZCodeContext/types.ts +3 -1
- package/src/client/VZCodeContext/useVZCodeState.ts +7 -5
- package/src/client/VZRight.tsx +10 -2
- package/src/client/VZSidebar/AIChat/ChatInput.tsx +1 -7
- package/src/client/VZSidebar/AIChat/DiffView.scss +1 -0
- package/src/client/VZSidebar/AIChat/DiffView.tsx +72 -29
- package/src/client/VZSidebar/AIChat/FileEditingIndicator.tsx +81 -0
- package/src/client/VZSidebar/AIChat/IndividualFileDiff.tsx +86 -0
- package/src/client/VZSidebar/AIChat/JumpToLatestButton.tsx +64 -0
- package/src/client/VZSidebar/AIChat/Message.tsx +68 -53
- package/src/client/VZSidebar/AIChat/MessageList.tsx +139 -118
- package/src/client/VZSidebar/AIChat/StreamingMessage.tsx +63 -21
- package/src/client/VZSidebar/AIChat/ThinkingScratchpad.tsx +4 -118
- package/src/client/VZSidebar/AIChat/index.tsx +62 -19
- package/src/client/VZSidebar/AIChat/styles.scss +159 -16
- package/src/client/VZSidebar/Item.tsx +2 -2
- package/src/client/VZSidebar/Search.tsx +13 -6
- package/src/client/VZSidebar/VisualEditor/VisualEditor.tsx +39 -23
- package/src/client/VZSidebar/VisualEditor/utils.ts +1 -1
- package/src/client/VZSidebar/index.tsx +12 -10
- package/src/client/VZSidebar/useDragAndDrop.tsx +225 -214
- package/src/client/featureFlags.ts +3 -0
- package/src/client/hooks/useAutoScroll.ts +329 -0
- package/src/client/tabsSearchParameters.ts +1 -17
- package/src/client/useFileCRUD.ts +5 -2
- package/src/client/useKeyboardShortcuts.ts +7 -0
- package/src/client/useOpenDirectories.ts +2 -1
- package/src/client/usePrettier/index.ts +1 -1
- package/src/client/useURLSync.ts +1 -0
- package/src/client/utils/scrollUtils.ts +170 -0
- package/src/client/vzReducer/searchReducer.ts +6 -3
- package/src/server/aiChatHandler/chatOperations.ts +224 -43
- package/src/server/aiChatHandler/index.ts +8 -48
- package/src/server/aiChatHandler/llmStreaming.ts +149 -170
- package/src/types.ts +81 -1
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useRef,
|
|
3
|
+
useCallback,
|
|
4
|
+
useState,
|
|
5
|
+
useEffect,
|
|
6
|
+
} from 'react';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Auto-scroll state machine with simplified states
|
|
10
|
+
*/
|
|
11
|
+
type AutoScrollState = 'AUTO_SCROLL_ON' | 'AUTO_SCROLL_OFF';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if the container is at the bottom with optional slack
|
|
15
|
+
*/
|
|
16
|
+
function isAtBottom(el: HTMLElement, slack = 2): boolean {
|
|
17
|
+
return (
|
|
18
|
+
el.scrollTop + el.clientHeight >=
|
|
19
|
+
el.scrollHeight - slack
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wait for scroll position to settle after programmatic scrolling
|
|
25
|
+
*/
|
|
26
|
+
function waitForScrollSettle(
|
|
27
|
+
el: HTMLElement,
|
|
28
|
+
{
|
|
29
|
+
epsilon = 1, // px change to consider "no movement"
|
|
30
|
+
stableFrames = 3, // frames in a row with no movement
|
|
31
|
+
maxWaitMs = 1000, // safety timeout
|
|
32
|
+
} = {},
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
return new Promise<void>((resolve) => {
|
|
35
|
+
let lastY = el.scrollTop;
|
|
36
|
+
let stable = 0;
|
|
37
|
+
let rafId = 0;
|
|
38
|
+
const start = performance.now();
|
|
39
|
+
|
|
40
|
+
const tick = () => {
|
|
41
|
+
const nowY = el.scrollTop;
|
|
42
|
+
const moved = Math.abs(nowY - lastY) > epsilon;
|
|
43
|
+
if (!moved) stable += 1;
|
|
44
|
+
else stable = 0;
|
|
45
|
+
lastY = nowY;
|
|
46
|
+
|
|
47
|
+
const timedOut =
|
|
48
|
+
performance.now() - start > maxWaitMs;
|
|
49
|
+
if (
|
|
50
|
+
(stable >= stableFrames && isAtBottom(el)) ||
|
|
51
|
+
timedOut
|
|
52
|
+
) {
|
|
53
|
+
cancelAnimationFrame(rafId);
|
|
54
|
+
resolve();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
rafId = requestAnimationFrame(tick);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
rafId = requestAnimationFrame(tick);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Hook options for customizing behavior
|
|
66
|
+
*/
|
|
67
|
+
interface UseAutoScrollOptions {
|
|
68
|
+
/** Threshold for "at bottom" detection in pixels (default: 24) */
|
|
69
|
+
threshold?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Hook return type with methods and state
|
|
74
|
+
*/
|
|
75
|
+
interface UseAutoScrollReturn {
|
|
76
|
+
/** Ref to attach to the scrollable container */
|
|
77
|
+
containerRef: React.RefObject<HTMLDivElement>;
|
|
78
|
+
/** Current auto-scroll state */
|
|
79
|
+
autoScrollState: AutoScrollState;
|
|
80
|
+
/** Whether to show the "jump to latest" button */
|
|
81
|
+
showJumpButton: boolean;
|
|
82
|
+
/** Function to handle new content/events */
|
|
83
|
+
onNewEvent: (targetElement?: HTMLElement) => void;
|
|
84
|
+
/** Function to handle user clicking jump to latest button */
|
|
85
|
+
onJumpToLatest: (targetElement?: HTMLElement) => void;
|
|
86
|
+
/** Function to call before rendering new content (for anchoring) */
|
|
87
|
+
beforeRender: () => number;
|
|
88
|
+
/** Function to call after rendering new content (for anchoring) */
|
|
89
|
+
afterRender: (prevScrollHeight: number) => void;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Simplified auto-scroll hook implementing the state machine from the issue
|
|
94
|
+
*
|
|
95
|
+
* States: AUTO_SCROLL_ON, AUTO_SCROLL_OFF
|
|
96
|
+
* Transitions:
|
|
97
|
+
* - onNewEvent: if AUTO_SCROLL_ON → scrollToBottom(), if AUTO_SCROLL_OFF → no scroll
|
|
98
|
+
* - onUserScroll: if isAtBottom(el) → AUTO_SCROLL_ON; hide button, else → AUTO_SCROLL_OFF; show button
|
|
99
|
+
* - onJumpToLatestClick → AUTO_SCROLL_ON + scrollToBottom(); hide button
|
|
100
|
+
*/
|
|
101
|
+
export const useAutoScroll = (
|
|
102
|
+
options: UseAutoScrollOptions = {},
|
|
103
|
+
): UseAutoScrollReturn => {
|
|
104
|
+
const { threshold = 24 } = options;
|
|
105
|
+
|
|
106
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
107
|
+
const [autoScrollState, setAutoScrollState] =
|
|
108
|
+
useState<AutoScrollState>('AUTO_SCROLL_ON');
|
|
109
|
+
const [showJumpButton, setShowJumpButton] =
|
|
110
|
+
useState(false);
|
|
111
|
+
const rafIdRef = useRef<number>();
|
|
112
|
+
const isProgrammaticScrollRef = useRef(false);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check if the container is at the bottom
|
|
116
|
+
*/
|
|
117
|
+
const isAtBottom = useCallback(
|
|
118
|
+
(el: HTMLElement): boolean => {
|
|
119
|
+
return (
|
|
120
|
+
el.scrollHeight - el.clientHeight - el.scrollTop <=
|
|
121
|
+
threshold
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
[threshold],
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Update jump button visibility
|
|
129
|
+
*/
|
|
130
|
+
const updateJumpButtonVisibility = useCallback(() => {
|
|
131
|
+
const container = containerRef.current;
|
|
132
|
+
if (!container) {
|
|
133
|
+
setShowJumpButton(false);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Only show when AUTO_SCROLL_OFF AND not at bottom
|
|
138
|
+
const shouldShow =
|
|
139
|
+
autoScrollState === 'AUTO_SCROLL_OFF' &&
|
|
140
|
+
!isAtBottom(container);
|
|
141
|
+
setShowJumpButton(shouldShow);
|
|
142
|
+
}, [autoScrollState, isAtBottom]);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Handle scroll events to detect user manual scrolling
|
|
146
|
+
*/
|
|
147
|
+
const handleScroll = useCallback(() => {
|
|
148
|
+
// Ignore scroll events during programmatic scrolling
|
|
149
|
+
if (isProgrammaticScrollRef.current) return;
|
|
150
|
+
|
|
151
|
+
console.log('handleScroll called');
|
|
152
|
+
const container = containerRef.current;
|
|
153
|
+
if (!container) return;
|
|
154
|
+
|
|
155
|
+
if (isAtBottom(container)) {
|
|
156
|
+
// User scrolled back to bottom - enable auto-scroll and hide button
|
|
157
|
+
setAutoScrollState('AUTO_SCROLL_ON');
|
|
158
|
+
} else {
|
|
159
|
+
// User scrolled up - disable auto-scroll and show button
|
|
160
|
+
setAutoScrollState('AUTO_SCROLL_OFF');
|
|
161
|
+
}
|
|
162
|
+
}, [isAtBottom]);
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Attach scroll listener to container
|
|
166
|
+
*/
|
|
167
|
+
useEffect(() => {
|
|
168
|
+
const container = containerRef.current;
|
|
169
|
+
if (!container) return;
|
|
170
|
+
|
|
171
|
+
container.addEventListener('scroll', handleScroll, {
|
|
172
|
+
passive: true,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
return () => {
|
|
176
|
+
container.removeEventListener('scroll', handleScroll);
|
|
177
|
+
};
|
|
178
|
+
}, [handleScroll]);
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Update jump button visibility when state changes
|
|
182
|
+
*/
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
updateJumpButtonVisibility();
|
|
185
|
+
}, [updateJumpButtonVisibility]);
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Handle new event/message render
|
|
189
|
+
*/
|
|
190
|
+
const onNewEvent = useCallback(
|
|
191
|
+
(targetElement?: HTMLElement) => {
|
|
192
|
+
if (autoScrollState === 'AUTO_SCROLL_ON') {
|
|
193
|
+
// Use requestAnimationFrame for batched updates
|
|
194
|
+
if (rafIdRef.current) {
|
|
195
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
rafIdRef.current = requestAnimationFrame(
|
|
199
|
+
async () => {
|
|
200
|
+
const container = containerRef.current;
|
|
201
|
+
if (container) {
|
|
202
|
+
// Set flag to ignore scroll events during programmatic scroll
|
|
203
|
+
isProgrammaticScrollRef.current = true;
|
|
204
|
+
|
|
205
|
+
// Perform smooth scroll
|
|
206
|
+
if (targetElement) {
|
|
207
|
+
// Scroll to specific element
|
|
208
|
+
const containerRect =
|
|
209
|
+
container.getBoundingClientRect();
|
|
210
|
+
const targetRect =
|
|
211
|
+
targetElement.getBoundingClientRect();
|
|
212
|
+
const scrollTop =
|
|
213
|
+
targetRect.top -
|
|
214
|
+
containerRect.top +
|
|
215
|
+
container.scrollTop;
|
|
216
|
+
container.scrollTo({
|
|
217
|
+
top: scrollTop,
|
|
218
|
+
behavior: 'smooth',
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
// Scroll to bottom
|
|
222
|
+
container.scrollTo({
|
|
223
|
+
top: container.scrollHeight,
|
|
224
|
+
behavior: 'smooth',
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Wait for scroll to settle before re-enabling scroll listener
|
|
229
|
+
await waitForScrollSettle(container);
|
|
230
|
+
isProgrammaticScrollRef.current = false;
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
// If AUTO_SCROLL_OFF, do nothing (no scroll)
|
|
236
|
+
},
|
|
237
|
+
[autoScrollState],
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Handle jump to latest button click
|
|
242
|
+
*/
|
|
243
|
+
const onJumpToLatest = useCallback(
|
|
244
|
+
async (targetElement?: HTMLElement) => {
|
|
245
|
+
const container = containerRef.current;
|
|
246
|
+
if (!container) return;
|
|
247
|
+
|
|
248
|
+
// Set flag to ignore scroll events during programmatic scroll
|
|
249
|
+
isProgrammaticScrollRef.current = true;
|
|
250
|
+
|
|
251
|
+
// Set state to AUTO_SCROLL_ON
|
|
252
|
+
setAutoScrollState('AUTO_SCROLL_ON');
|
|
253
|
+
|
|
254
|
+
// Perform smooth scroll
|
|
255
|
+
if (targetElement) {
|
|
256
|
+
// Scroll to specific element
|
|
257
|
+
const containerRect =
|
|
258
|
+
container.getBoundingClientRect();
|
|
259
|
+
const targetRect =
|
|
260
|
+
targetElement.getBoundingClientRect();
|
|
261
|
+
const scrollTop =
|
|
262
|
+
targetRect.top -
|
|
263
|
+
containerRect.top +
|
|
264
|
+
container.scrollTop;
|
|
265
|
+
container.scrollTo({
|
|
266
|
+
top: scrollTop,
|
|
267
|
+
behavior: 'smooth',
|
|
268
|
+
});
|
|
269
|
+
} else {
|
|
270
|
+
// Scroll to bottom
|
|
271
|
+
container.scrollTo({
|
|
272
|
+
top: container.scrollHeight,
|
|
273
|
+
behavior: 'smooth',
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Wait for scroll to settle before re-enabling scroll listener
|
|
278
|
+
await waitForScrollSettle(container);
|
|
279
|
+
isProgrammaticScrollRef.current = false;
|
|
280
|
+
},
|
|
281
|
+
[],
|
|
282
|
+
);
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Get scroll height before rendering (for anchoring)
|
|
286
|
+
*/
|
|
287
|
+
const beforeRender = useCallback((): number => {
|
|
288
|
+
const container = containerRef.current;
|
|
289
|
+
return container ? container.scrollHeight : 0;
|
|
290
|
+
}, []);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Adjust scroll position after rendering (for anchoring when OFF)
|
|
294
|
+
*/
|
|
295
|
+
const afterRender = useCallback(
|
|
296
|
+
(prevScrollHeight: number) => {
|
|
297
|
+
if (autoScrollState === 'AUTO_SCROLL_OFF') {
|
|
298
|
+
const container = containerRef.current;
|
|
299
|
+
if (container) {
|
|
300
|
+
const delta =
|
|
301
|
+
container.scrollHeight - prevScrollHeight;
|
|
302
|
+
container.scrollTop += delta; // anchor view
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
[autoScrollState],
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Clean up animation frame on unmount
|
|
311
|
+
*/
|
|
312
|
+
useEffect(() => {
|
|
313
|
+
return () => {
|
|
314
|
+
if (rafIdRef.current) {
|
|
315
|
+
cancelAnimationFrame(rafIdRef.current);
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
}, []);
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
containerRef,
|
|
322
|
+
autoScrollState,
|
|
323
|
+
showJumpButton,
|
|
324
|
+
onNewEvent,
|
|
325
|
+
onJumpToLatest,
|
|
326
|
+
beforeRender,
|
|
327
|
+
afterRender,
|
|
328
|
+
};
|
|
329
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TabState } from '../types';
|
|
2
2
|
import { VizFileId, VizContent } from '@vizhub/viz-types';
|
|
3
|
+
import { getFileId } from '@vizhub/viz-utils';
|
|
3
4
|
|
|
4
5
|
// The delimiter used to separate file names in the `tabs` parameter.
|
|
5
6
|
// We need a character that is both URL-safe (does not get escaped in URLs)
|
|
@@ -28,23 +29,6 @@ import { VizFileId, VizContent } from '@vizhub/viz-types';
|
|
|
28
29
|
// in file names for JavaScript and CSS files, making it a suitable choice for a delimiter.
|
|
29
30
|
export const delimiter = '~';
|
|
30
31
|
|
|
31
|
-
// Gets the file id of a file with the given name.
|
|
32
|
-
// Returns null if not found.
|
|
33
|
-
const getFileId = (
|
|
34
|
-
content: VizContent,
|
|
35
|
-
fileName: string,
|
|
36
|
-
): string | null => {
|
|
37
|
-
if (content && content.files) {
|
|
38
|
-
for (const fileId of Object.keys(content.files)) {
|
|
39
|
-
const file = content.files[fileId];
|
|
40
|
-
if (file.name === fileName) {
|
|
41
|
-
return fileId;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return null;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
32
|
// Gets the file name of a file with the given id,
|
|
49
33
|
// guard against failure cases.
|
|
50
34
|
// Returns null if not found.
|
|
@@ -46,7 +46,7 @@ export const useFileCRUD = ({
|
|
|
46
46
|
openTab({ fileId, isTransient: false });
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
|
-
[submitOperation],
|
|
49
|
+
[submitOperation, openTab],
|
|
50
50
|
);
|
|
51
51
|
|
|
52
52
|
const createDirectory = useCallback(
|
|
@@ -86,6 +86,9 @@ export const useFileCRUD = ({
|
|
|
86
86
|
},
|
|
87
87
|
}));
|
|
88
88
|
|
|
89
|
+
if (!content) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
89
92
|
const oldName = content.files[fileId].name;
|
|
90
93
|
const oldExtension = getFileExtension(oldName);
|
|
91
94
|
|
|
@@ -125,7 +128,7 @@ export const useFileCRUD = ({
|
|
|
125
128
|
}
|
|
126
129
|
}
|
|
127
130
|
},
|
|
128
|
-
[submitOperation],
|
|
131
|
+
[submitOperation, content, editorCache],
|
|
129
132
|
);
|
|
130
133
|
|
|
131
134
|
// Renames a directory
|
|
@@ -451,5 +451,12 @@ export const useKeyboardShortcuts = ({
|
|
|
451
451
|
activeFileId,
|
|
452
452
|
setActiveFileLeft,
|
|
453
453
|
setActiveFileRight,
|
|
454
|
+
activePaneId,
|
|
455
|
+
codeEditorRef,
|
|
456
|
+
editorCache,
|
|
457
|
+
runCodeRef,
|
|
458
|
+
runPrettierRef,
|
|
459
|
+
sidebarRef,
|
|
460
|
+
toggleSearchFocused,
|
|
454
461
|
]);
|
|
455
462
|
};
|
|
@@ -45,6 +45,7 @@ export const useOpenDirectories = ({
|
|
|
45
45
|
if (
|
|
46
46
|
activePane.type === 'leafPane' &&
|
|
47
47
|
activePane?.activeFileId &&
|
|
48
|
+
content.files &&
|
|
48
49
|
content.files[activePane.activeFileId]
|
|
49
50
|
) {
|
|
50
51
|
const activeFileId = activePane.activeFileId;
|
|
@@ -58,7 +59,7 @@ export const useOpenDirectories = ({
|
|
|
58
59
|
return updated;
|
|
59
60
|
});
|
|
60
61
|
}
|
|
61
|
-
}, [activePane]);
|
|
62
|
+
}, [activePane, content?.files]);
|
|
62
63
|
|
|
63
64
|
// Whether a directory is open.
|
|
64
65
|
const isDirectoryOpen: (path: VZPath) => boolean =
|
|
@@ -170,7 +170,7 @@ export const usePrettier = ({
|
|
|
170
170
|
|
|
171
171
|
shareDBDoc.removeListener('op batch', handleOpBatch);
|
|
172
172
|
};
|
|
173
|
-
}, [shareDBDoc]);
|
|
173
|
+
}, [shareDBDoc, prettierWorker, submitOperation]);
|
|
174
174
|
|
|
175
175
|
// Return the errors and run prettier function ref
|
|
176
176
|
// for use elsewhere.
|
package/src/client/useURLSync.ts
CHANGED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions for scrolling behavior in the AI editing interface
|
|
3
|
+
* Part of Phase 2: Diff-First Review Experience
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Scrolls to the first diff hunk in a diff container
|
|
8
|
+
* Accounts for fixed headers and navigation
|
|
9
|
+
* @param diffContainer - The container element with diff content
|
|
10
|
+
* @param headerOffset - Offset for fixed headers (default: 60px)
|
|
11
|
+
*/
|
|
12
|
+
export const scrollToFirstDiff = (
|
|
13
|
+
diffContainer: HTMLElement,
|
|
14
|
+
headerOffset: number = 60,
|
|
15
|
+
): void => {
|
|
16
|
+
// Look for the first diff hunk row
|
|
17
|
+
const firstHunk = diffContainer.querySelector(
|
|
18
|
+
'.d2h-diff-tbody tr',
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
if (firstHunk) {
|
|
22
|
+
// Scroll to the first hunk with header offset
|
|
23
|
+
firstHunk.scrollIntoView({
|
|
24
|
+
behavior: 'smooth',
|
|
25
|
+
block: 'start',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// Adjust for fixed headers
|
|
29
|
+
window.scrollBy(0, -headerOffset);
|
|
30
|
+
|
|
31
|
+
// Set focus for keyboard navigation accessibility
|
|
32
|
+
if (diffContainer.setAttribute) {
|
|
33
|
+
diffContainer.tabIndex = -1;
|
|
34
|
+
diffContainer.focus();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Gets the first diff anchor element for scroll targeting
|
|
41
|
+
* @param diffContainer - The container element with diff content
|
|
42
|
+
* @returns The first diff element or null if not found
|
|
43
|
+
*/
|
|
44
|
+
export const getFirstDiffAnchor = (
|
|
45
|
+
diffContainer: HTMLElement,
|
|
46
|
+
): HTMLElement | null => {
|
|
47
|
+
// Try different selectors for various diff formats
|
|
48
|
+
const selectors = [
|
|
49
|
+
'.d2h-diff-tbody tr:first-child', // diff2html format
|
|
50
|
+
'.diff-line:first-child', // alternative format
|
|
51
|
+
'.hunk:first-child', // git diff format
|
|
52
|
+
'.d2h-file-wrapper:first-child', // file-level anchor
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
for (const selector of selectors) {
|
|
56
|
+
const element = diffContainer.querySelector(selector);
|
|
57
|
+
if (element) {
|
|
58
|
+
return element as HTMLElement;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return null;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Scrolls to a specific element with header-aware offset
|
|
67
|
+
* @param element - The element to scroll to
|
|
68
|
+
* @param headerOffset - Offset for fixed headers (default: 60px)
|
|
69
|
+
* @param behavior - Scroll behavior ('smooth' or 'auto')
|
|
70
|
+
*/
|
|
71
|
+
export const scrollToElementWithOffset = (
|
|
72
|
+
element: HTMLElement,
|
|
73
|
+
headerOffset: number = 60,
|
|
74
|
+
behavior: ScrollBehavior = 'smooth',
|
|
75
|
+
): void => {
|
|
76
|
+
element.scrollIntoView({
|
|
77
|
+
behavior,
|
|
78
|
+
block: 'start',
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Adjust for fixed headers
|
|
82
|
+
window.scrollBy(0, -headerOffset);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Gets the appropriate header offset by measuring actual header height
|
|
87
|
+
* Falls back to default if header elements not found
|
|
88
|
+
* @param defaultOffset - Default offset if header not found (default: 60px)
|
|
89
|
+
* @returns The calculated header offset
|
|
90
|
+
*/
|
|
91
|
+
export const getHeaderOffset = (
|
|
92
|
+
defaultOffset: number = 60,
|
|
93
|
+
): number => {
|
|
94
|
+
// Try to find the actual header element
|
|
95
|
+
const header =
|
|
96
|
+
document.querySelector('#appHeader') ||
|
|
97
|
+
document.querySelector('.app-header') ||
|
|
98
|
+
document.querySelector('header');
|
|
99
|
+
|
|
100
|
+
if (header) {
|
|
101
|
+
return header.getBoundingClientRect().height;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return defaultOffset;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Handles edge cases for diff scrolling
|
|
109
|
+
* @param diffContainer - The container element with diff content
|
|
110
|
+
* @returns Object describing the diff state
|
|
111
|
+
*/
|
|
112
|
+
export const analyzeDiffContent = (
|
|
113
|
+
diffContainer: HTMLElement,
|
|
114
|
+
) => {
|
|
115
|
+
const fileWrappers = diffContainer.querySelectorAll(
|
|
116
|
+
'.d2h-file-wrapper',
|
|
117
|
+
);
|
|
118
|
+
const diffLines = diffContainer.querySelectorAll(
|
|
119
|
+
'.d2h-diff-tbody tr',
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
hasFiles: fileWrappers.length > 0,
|
|
124
|
+
fileCount: fileWrappers.length,
|
|
125
|
+
hasDiffLines: diffLines.length > 0,
|
|
126
|
+
diffLineCount: diffLines.length,
|
|
127
|
+
isEmpty:
|
|
128
|
+
fileWrappers.length === 0 && diffLines.length === 0,
|
|
129
|
+
isMultiFile: fileWrappers.length > 1,
|
|
130
|
+
};
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Announces diff summary for screen readers
|
|
135
|
+
* @param diffContainer - The container element with diff content
|
|
136
|
+
*/
|
|
137
|
+
export const announceDiffSummary = (
|
|
138
|
+
diffContainer: HTMLElement,
|
|
139
|
+
): void => {
|
|
140
|
+
const analysis = analyzeDiffContent(diffContainer);
|
|
141
|
+
|
|
142
|
+
let announcement = '';
|
|
143
|
+
|
|
144
|
+
if (analysis.isEmpty) {
|
|
145
|
+
announcement = 'No changes to review';
|
|
146
|
+
} else if (analysis.isMultiFile) {
|
|
147
|
+
announcement = `${analysis.fileCount} files changed. Review diff content.`;
|
|
148
|
+
} else {
|
|
149
|
+
announcement = `1 file changed. Review diff content.`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Create or update ARIA live region for announcements
|
|
153
|
+
let liveRegion = document.getElementById(
|
|
154
|
+
'diff-announcements',
|
|
155
|
+
);
|
|
156
|
+
if (!liveRegion) {
|
|
157
|
+
liveRegion = document.createElement('div');
|
|
158
|
+
liveRegion.id = 'diff-announcements';
|
|
159
|
+
liveRegion.setAttribute('aria-live', 'polite');
|
|
160
|
+
liveRegion.setAttribute('aria-atomic', 'true');
|
|
161
|
+
liveRegion.style.position = 'absolute';
|
|
162
|
+
liveRegion.style.left = '-10000px';
|
|
163
|
+
liveRegion.style.width = '1px';
|
|
164
|
+
liveRegion.style.height = '1px';
|
|
165
|
+
liveRegion.style.overflow = 'hidden';
|
|
166
|
+
document.body.appendChild(liveRegion);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
liveRegion.textContent = announcement;
|
|
170
|
+
};
|
|
@@ -11,14 +11,17 @@ function searchPattern(
|
|
|
11
11
|
shareDBDoc: ShareDBDoc<VizContent>,
|
|
12
12
|
pattern: string,
|
|
13
13
|
): SearchResult {
|
|
14
|
-
const files = shareDBDoc
|
|
15
|
-
|
|
14
|
+
const files = shareDBDoc?.data?.files;
|
|
15
|
+
if (!files) {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
const fileIds = Object.keys(files);
|
|
16
19
|
const results: { [id: string]: SearchFile } = {};
|
|
17
20
|
|
|
18
21
|
for (let i = 0; i < fileIds.length; i++) {
|
|
19
22
|
const file = files[fileIds[i]];
|
|
20
23
|
|
|
21
|
-
if (
|
|
24
|
+
if (file.text) {
|
|
22
25
|
const fileName = file.name;
|
|
23
26
|
const lines = file.text.split('\n');
|
|
24
27
|
const matches = [];
|