Skip to content

Commit 056d45f

Browse files
authored
Merge pull request #190 from wgqqqqq/fix/issue-188-large-json-freeze
fix(editor): prevent freeze on large JSON long-line expansion (fix #188)
2 parents f8a9bdf + 5c37444 commit 056d45f

3 files changed

Lines changed: 179 additions & 30 deletions

File tree

‎src/web-ui/src/tools/editor/components/CodeEditor.scss‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,16 @@ $_editor-padding-desktop: 32px;
170170
z-index: 50;
171171
animation: code-editor-fade-in 0.2s ease-out;
172172
}
173+
174+
&.is-large-file-mode {
175+
:global {
176+
.monaco-editor .view-line .mtkoverflow {
177+
pointer-events: none !important;
178+
cursor: default !important;
179+
text-decoration: none !important;
180+
}
181+
}
182+
}
173183

174184
// Ctrl+Hover Underline Styles
175185
:global {

‎src/web-ui/src/tools/editor/components/CodeEditor.tsx‎

Lines changed: 157 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,28 @@ export interface CodeEditorProps {
7070
jumpToRange?: import('@/component-library/components/Markdown').LineRange;
7171
}
7272

73+
const LARGE_FILE_SIZE_THRESHOLD_BYTES = 1 * 1024 * 1024; // 1MB
74+
const LARGE_FILE_MAX_LINE_LENGTH = 20000;
75+
const LARGE_FILE_RENDER_LINE_LIMIT = 10000;
76+
const LARGE_FILE_MAX_TOKENIZATION_LINE_LENGTH = 2000;
77+
const LARGE_FILE_EXPANSION_LABELS = ['show more', '显示更多', '展开更多'];
78+
79+
function hasVeryLongLine(content: string, maxLineLength: number): boolean {
80+
let currentLineLength = 0;
81+
for (let i = 0; i < content.length; i++) {
82+
const code = content.charCodeAt(i);
83+
if (code === 10 || code === 13) {
84+
currentLineLength = 0;
85+
continue;
86+
}
87+
currentLineLength++;
88+
if (currentLineLength >= maxLineLength) {
89+
return true;
90+
}
91+
}
92+
return false;
93+
}
94+
7395
const CodeEditor: React.FC<CodeEditorProps> = ({
7496
filePath: rawFilePath,
7597
workspacePath,
@@ -157,6 +179,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
157179
const [statusBarPopover, setStatusBarPopover] = useState<null | 'position' | 'indent' | 'encoding' | 'language'>(null);
158180
const [statusBarAnchorRect, setStatusBarAnchorRect] = useState<AnchorRect | null>(null);
159181
const [encoding, setEncoding] = useState<string>('UTF-8');
182+
const [largeFileMode, setLargeFileMode] = useState(false);
160183
const containerRef = useRef<HTMLDivElement>(null);
161184
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
162185
const modelRef = useRef<monaco.editor.ITextModel | null>(null);
@@ -177,6 +200,53 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
177200
const delayedFontApplyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
178201
const userLanguageOverrideRef = useRef(false);
179202
const userIndentRef = useRef<{ tab_size: number; insert_spaces: boolean } | null>(null);
203+
const largeFileModeRef = useRef(false);
204+
const largeFileExpansionBlockedLogRef = useRef(false);
205+
206+
const detectLargeFileMode = useCallback((nextContent: string, fileSizeBytes?: number): boolean => {
207+
const size = typeof fileSizeBytes === 'number' && fileSizeBytes >= 0
208+
? fileSizeBytes
209+
: new Blob([nextContent]).size;
210+
if (size >= LARGE_FILE_SIZE_THRESHOLD_BYTES) {
211+
return true;
212+
}
213+
return hasVeryLongLine(nextContent, LARGE_FILE_MAX_LINE_LENGTH);
214+
}, []);
215+
216+
const updateLargeFileMode = useCallback((nextContent: string, fileSizeBytes?: number) => {
217+
const nextMode = detectLargeFileMode(nextContent, fileSizeBytes);
218+
if (largeFileModeRef.current !== nextMode) {
219+
largeFileModeRef.current = nextMode;
220+
setLargeFileMode(nextMode);
221+
log.info('Editor performance mode changed', {
222+
filePath,
223+
largeFileMode: nextMode,
224+
fileSizeBytes: typeof fileSizeBytes === 'number' ? fileSizeBytes : undefined
225+
});
226+
}
227+
}, [detectLargeFileMode, filePath]);
228+
229+
const shouldBlockLargeFileExpansionClick = useCallback((target: EventTarget | null): boolean => {
230+
if (!(target instanceof HTMLElement)) {
231+
return false;
232+
}
233+
234+
if (!target.closest('.monaco-editor')) {
235+
return false;
236+
}
237+
238+
const clickable = target.closest('a,button,[role="button"],.monaco-button') as HTMLElement | null;
239+
const text = (clickable?.textContent ?? target.textContent ?? '')
240+
.toLowerCase()
241+
.replace(/\s+/g, ' ')
242+
.trim();
243+
244+
if (!text) {
245+
return false;
246+
}
247+
248+
return LARGE_FILE_EXPANSION_LABELS.some((label) => text.includes(label));
249+
}, []);
180250

181251
useEffect(() => {
182252
filePathRef.current = filePath;
@@ -239,18 +309,31 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
239309
wordWrap: (config.word_wrap as any) || 'off',
240310
lineNumbers: config.line_numbers as any || 'on',
241311
minimap: {
242-
enabled: showMinimap,
312+
enabled: showMinimap && !largeFileMode,
243313
side: (config.minimap?.side as any) || 'right',
244314
size: (config.minimap?.size as any) || 'proportional'
245315
},
246316
cursorStyle: config.cursor_style as any || 'line',
247317
cursorBlinking: config.cursor_blinking as any || 'blink',
248-
smoothScrolling: config.smooth_scrolling ?? true,
318+
smoothScrolling: largeFileMode ? false : (config.smooth_scrolling ?? true),
249319
renderWhitespace: config.render_whitespace as any || 'none',
250320
renderLineHighlight: config.render_line_highlight as any || 'line',
251-
bracketPairColorization: { enabled: config.bracket_pair_colorization ?? true },
321+
bracketPairColorization: { enabled: largeFileMode ? false : (config.bracket_pair_colorization ?? true) },
252322
formatOnPaste: config.format_on_paste ?? false,
253323
trimAutoWhitespace: config.trim_auto_whitespace ?? true,
324+
inlayHints: { enabled: largeFileMode ? 'off' : 'on' },
325+
quickSuggestions: largeFileMode
326+
? { other: false, comments: false, strings: false }
327+
: { other: true, comments: false, strings: false },
328+
'semanticHighlighting.enabled': !largeFileMode,
329+
renderValidationDecorations: largeFileMode ? 'off' : 'on',
330+
largeFileOptimizations: true,
331+
maxTokenizationLineLength: largeFileMode ? LARGE_FILE_MAX_TOKENIZATION_LINE_LENGTH : LARGE_FILE_MAX_LINE_LENGTH,
332+
occurrencesHighlight: largeFileMode ? 'off' : 'singleFile',
333+
selectionHighlight: !largeFileMode,
334+
matchBrackets: largeFileMode ? 'never' : 'always',
335+
disableMonospaceOptimizations: !largeFileMode,
336+
stopRenderingLineAfter: largeFileMode ? LARGE_FILE_RENDER_LINE_LIMIT : -1,
254337
});
255338
}
256339
};
@@ -279,13 +362,42 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
279362
return () => {
280363
globalEventBus.off('editor:config:changed', handleConfigChange);
281364
};
282-
}, [showMinimap]);
365+
}, [showMinimap, largeFileMode]);
366+
367+
useEffect(() => {
368+
const container = containerRef.current;
369+
if (!container || !largeFileMode) {
370+
return;
371+
}
372+
373+
const blockLargeFileExpansion = (event: MouseEvent) => {
374+
if (!shouldBlockLargeFileExpansionClick(event.target)) {
375+
return;
376+
}
377+
378+
event.preventDefault();
379+
event.stopPropagation();
380+
event.stopImmediatePropagation();
381+
382+
if (!largeFileExpansionBlockedLogRef.current) {
383+
largeFileExpansionBlockedLogRef.current = true;
384+
log.info('Blocked long-line expansion in large file mode', { filePath });
385+
}
386+
};
387+
388+
container.addEventListener('mousedown', blockLargeFileExpansion, true);
389+
container.addEventListener('click', blockLargeFileExpansion, true);
390+
return () => {
391+
container.removeEventListener('mousedown', blockLargeFileExpansion, true);
392+
container.removeEventListener('click', blockLargeFileExpansion, true);
393+
};
394+
}, [filePath, largeFileMode, shouldBlockLargeFileExpansionClick]);
283395

284396
useMonacoLsp(
285397
editorInstance,
286398
detectedLanguage,
287399
filePath,
288-
enableLsp && lspReady && monacoReady,
400+
enableLsp && lspReady && monacoReady && !largeFileMode,
289401
workspacePath
290402
);
291403

@@ -331,6 +443,9 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
331443

332444
modelRef.current = model;
333445
const modelContent = model.getValue();
446+
const initialLargeFileMode = detectLargeFileMode(modelContent);
447+
largeFileModeRef.current = initialLargeFileMode;
448+
setLargeFileMode(initialLargeFileMode);
334449

335450
const modelMetadata = monacoModelManager.getModelMetadata(filePath);
336451
if (modelMetadata) {
@@ -376,7 +491,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
376491
readOnly: readOnly,
377492
lineNumbers: showLineNumbers ? 'on' : (editorConfig.line_numbers as any) || 'on',
378493
minimap: {
379-
enabled: showMinimap,
494+
enabled: showMinimap && !initialLargeFileMode,
380495
side: (editorConfig.minimap?.side as any) || 'right',
381496
size: (editorConfig.minimap?.size as any) || 'proportional'
382497
},
@@ -400,7 +515,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
400515
multiCursorModifier: 'alt',
401516
definitionLinkOpensInPeek: false,
402517
inlayHints: {
403-
enabled: 'on',
518+
enabled: initialLargeFileMode ? 'off' : 'on',
404519
fontSize: 12,
405520
fontFamily: "'Fira Code', Consolas, 'Courier New', monospace",
406521
padding: false
@@ -422,7 +537,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
422537
})(),
423538

424539
quickSuggestions: {
425-
other: true,
540+
other: !initialLargeFileMode,
426541
comments: false,
427542
strings: false
428543
},
@@ -431,7 +546,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
431546
showSnippets: true
432547
},
433548

434-
'semanticHighlighting.enabled': true,
549+
'semanticHighlighting.enabled': !initialLargeFileMode,
435550
guides: {
436551
indentation: true,
437552
bracketPairs: true,
@@ -442,12 +557,17 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
442557

443558
renderLineHighlight: 'line',
444559
renderControlCharacters: false,
445-
renderValidationDecorations: 'on',
446-
smoothScrolling: true,
560+
renderValidationDecorations: initialLargeFileMode ? 'off' : 'on',
561+
largeFileOptimizations: true,
562+
maxTokenizationLineLength: initialLargeFileMode ? LARGE_FILE_MAX_TOKENIZATION_LINE_LENGTH : LARGE_FILE_MAX_LINE_LENGTH,
563+
occurrencesHighlight: initialLargeFileMode ? 'off' : 'singleFile',
564+
selectionHighlight: !initialLargeFileMode,
565+
matchBrackets: initialLargeFileMode ? 'never' : 'always',
566+
smoothScrolling: !initialLargeFileMode,
447567
roundedSelection: false,
448-
disableMonospaceOptimizations: true,
568+
disableMonospaceOptimizations: !initialLargeFileMode,
449569
fontLigatures: false,
450-
stopRenderingLineAfter: -1,
570+
stopRenderingLineAfter: initialLargeFileMode ? LARGE_FILE_RENDER_LINE_LIMIT : -1,
451571
scrollbar: {
452572
vertical: 'auto',
453573
horizontal: 'auto',
@@ -713,7 +833,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
713833
log.error('Failed to cleanup EditorReadyManager', err);
714834
});
715835
};
716-
}, [filePath, detectedLanguage]);
836+
}, [filePath, detectedLanguage, detectLargeFileMode]);
717837

718838
useEffect(() => {
719839
if (modelRef.current && monacoReady && !loading) {
@@ -906,6 +1026,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
9061026
try {
9071027
const { workspaceAPI } = await import('@/infrastructure/api');
9081028
const content = await workspaceAPI.readFileContent(filePath, newEncoding);
1029+
updateLargeFileMode(content);
9091030
setContent(content);
9101031
originalContentRef.current = content;
9111032
if (modelRef.current) {
@@ -914,7 +1035,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
9141035
} catch (err) {
9151036
log.warn('Failed to reload file with new encoding', err);
9161037
}
917-
}, [filePath]);
1038+
}, [filePath, updateLargeFileMode]);
9181039

9191040
const handleLanguageConfirm = useCallback((languageId: string) => {
9201041
userLanguageOverrideRef.current = true;
@@ -944,8 +1065,23 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
9441065
try {
9451066
const { workspaceAPI } = await import('@/infrastructure/api');
9461067
const { invoke } = await import('@tauri-apps/api/core');
1068+
let fileSizeBytes: number | undefined;
1069+
try {
1070+
const fileInfo: any = await invoke('get_file_metadata', {
1071+
request: { path: filePath }
1072+
});
1073+
if (typeof fileInfo?.modified === 'number') {
1074+
lastModifiedTimeRef.current = fileInfo.modified;
1075+
}
1076+
if (typeof fileInfo?.size === 'number') {
1077+
fileSizeBytes = fileInfo.size;
1078+
}
1079+
} catch (err) {
1080+
log.warn('Failed to get file metadata', err);
1081+
}
9471082

9481083
const fileContent = await workspaceAPI.readFileContent(filePath);
1084+
updateLargeFileMode(fileContent, fileSizeBytes);
9491085

9501086
setContent(fileContent);
9511087
originalContentRef.current = fileContent;
@@ -965,15 +1101,6 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
9651101
}
9661102
});
9671103

968-
// Get file's initial modification time
969-
try {
970-
const fileInfo: any = await invoke('get_file_metadata', {
971-
request: { path: filePath }
972-
});
973-
lastModifiedTimeRef.current = fileInfo.modified;
974-
} catch (err) {
975-
log.warn('Failed to get file metadata', err);
976-
}
9771104
} catch (err) {
9781105
// Simplify error message, show only core reason
9791106
const errStr = String(err);
@@ -993,7 +1120,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
9931120
isLoadingContentRef.current = false;
9941121
});
9951122
}
996-
}, [filePath, detectedLanguage, t]);
1123+
}, [filePath, detectedLanguage, t, updateLargeFileMode]);
9971124

9981125
// Save file content
9991126
const saveFileContent = useCallback(async () => {
@@ -1101,6 +1228,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
11011228

11021229
const { workspaceAPI } = await import('@/infrastructure/api');
11031230
const fileContent = await workspaceAPI.readFileContent(filePath);
1231+
updateLargeFileMode(fileContent);
11041232

11051233
if (!isUnmountedRef.current) {
11061234
isLoadingContentRef.current = true;
@@ -1128,7 +1256,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
11281256
} finally {
11291257
isCheckingFileRef.current = false;
11301258
}
1131-
}, [filePath, hasChanges, monacoReady]);
1259+
}, [filePath, hasChanges, monacoReady, updateLargeFileMode]);
11321260

11331261
// Initial file load - only run once when filePath changes
11341262
const loadFileContentCalledRef = useRef(false);
@@ -1334,6 +1462,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
13341462
try {
13351463
const { workspaceAPI } = await import('@/infrastructure/api');
13361464
const content = await workspaceAPI.readFile(filePath);
1465+
updateLargeFileMode(content);
13371466

13381467
const currentPosition = editor?.getPosition();
13391468

@@ -1365,7 +1494,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
13651494
return () => {
13661495
unsubscribers.forEach(unsub => unsub());
13671496
};
1368-
}, [monacoReady, filePath]);
1497+
}, [monacoReady, filePath, updateLargeFileMode]);
13691498

13701499
useEffect(() => {
13711500
userLanguageOverrideRef.current = false;
@@ -1420,7 +1549,7 @@ const CodeEditor: React.FC<CodeEditorProps> = ({
14201549

14211550
return (
14221551
<div
1423-
className={`code-editor-tool ${className} ${loading && showLoadingOverlay ? 'is-loading' : ''} ${error ? 'is-error' : ''}`}
1552+
className={`code-editor-tool ${className} ${loading && showLoadingOverlay ? 'is-loading' : ''} ${error ? 'is-error' : ''} ${largeFileMode ? 'is-large-file-mode' : ''}`}
14241553
data-monaco-editor="true"
14251554
data-editor-id={`editor-${filePath.replace(/[^a-zA-Z0-9]/g, '-')}`}
14261555
data-file-path={filePath}

‎src/web-ui/src/tools/lsp/hooks/useMonacoLsp.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,18 @@ export function useMonacoLsp(
3939
return;
4040
}
4141

42-
const monacoBuiltinLanguages = ['typescript', 'javascript', 'typescriptreact', 'javascriptreact'];
43-
if (monacoBuiltinLanguages.includes(language)) {
42+
const monacoBuiltinLanguages = [
43+
'typescript',
44+
'javascript',
45+
'typescriptreact',
46+
'javascriptreact',
47+
'json',
48+
'html',
49+
'css',
50+
'scss',
51+
'less',
52+
];
53+
if (monacoBuiltinLanguages.includes(language.toLowerCase())) {
4454
return;
4555
}
4656

0 commit comments

Comments
 (0)