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.
Files changed (45) hide show
  1. package/dist/assets/{index-BpUpNto4.js → index-C2BLPyQP.js} +138 -125
  2. package/dist/assets/{index-DLm5FQ0E.css → index-DVR90LwF.css} +1 -1
  3. package/dist/index.html +2 -2
  4. package/dist/server/aiChatHandler/chatOperations.js +168 -35
  5. package/dist/server/aiChatHandler/index.js +11 -30
  6. package/dist/server/aiChatHandler/llmStreaming.js +105 -118
  7. package/package.json +11 -11
  8. package/src/client/AIAssist/AIAssistWidget/index.tsx +12 -1
  9. package/src/client/App/useShareDB.ts +1 -1
  10. package/src/client/CodeEditor/index.tsx +9 -10
  11. package/src/client/VZCodeContext/types.ts +3 -1
  12. package/src/client/VZCodeContext/useVZCodeState.ts +7 -5
  13. package/src/client/VZRight.tsx +10 -2
  14. package/src/client/VZSidebar/AIChat/ChatInput.tsx +1 -7
  15. package/src/client/VZSidebar/AIChat/DiffView.scss +1 -0
  16. package/src/client/VZSidebar/AIChat/DiffView.tsx +72 -29
  17. package/src/client/VZSidebar/AIChat/FileEditingIndicator.tsx +81 -0
  18. package/src/client/VZSidebar/AIChat/IndividualFileDiff.tsx +86 -0
  19. package/src/client/VZSidebar/AIChat/JumpToLatestButton.tsx +64 -0
  20. package/src/client/VZSidebar/AIChat/Message.tsx +68 -53
  21. package/src/client/VZSidebar/AIChat/MessageList.tsx +139 -118
  22. package/src/client/VZSidebar/AIChat/StreamingMessage.tsx +63 -21
  23. package/src/client/VZSidebar/AIChat/ThinkingScratchpad.tsx +4 -118
  24. package/src/client/VZSidebar/AIChat/index.tsx +62 -19
  25. package/src/client/VZSidebar/AIChat/styles.scss +159 -16
  26. package/src/client/VZSidebar/Item.tsx +2 -2
  27. package/src/client/VZSidebar/Search.tsx +13 -6
  28. package/src/client/VZSidebar/VisualEditor/VisualEditor.tsx +39 -23
  29. package/src/client/VZSidebar/VisualEditor/utils.ts +1 -1
  30. package/src/client/VZSidebar/index.tsx +12 -10
  31. package/src/client/VZSidebar/useDragAndDrop.tsx +225 -214
  32. package/src/client/featureFlags.ts +3 -0
  33. package/src/client/hooks/useAutoScroll.ts +329 -0
  34. package/src/client/tabsSearchParameters.ts +1 -17
  35. package/src/client/useFileCRUD.ts +5 -2
  36. package/src/client/useKeyboardShortcuts.ts +7 -0
  37. package/src/client/useOpenDirectories.ts +2 -1
  38. package/src/client/usePrettier/index.ts +1 -1
  39. package/src/client/useURLSync.ts +1 -0
  40. package/src/client/utils/scrollUtils.ts +170 -0
  41. package/src/client/vzReducer/searchReducer.ts +6 -3
  42. package/src/server/aiChatHandler/chatOperations.ts +224 -43
  43. package/src/server/aiChatHandler/index.ts +8 -48
  44. package/src/server/aiChatHandler/llmStreaming.ts +149 -170
  45. 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.
@@ -68,6 +68,7 @@ export const useURLSync = ({
68
68
  setActiveFileId,
69
69
  openTab,
70
70
  isInitialized,
71
+ tabStateParams,
71
72
  ]);
72
73
 
73
74
  // Track content in a ref so that we can use it in the
@@ -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.data.files;
15
- const fileIds = Object.keys(shareDBDoc.data.files);
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 (files[fileIds[i]].text) {
24
+ if (file.text) {
22
25
  const fileName = file.name;
23
26
  const lines = file.text.split('\n');
24
27
  const matches = [];