mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-25 20:46:31 +00:00
chore(webui): remove dead monaco-terminal component and monaco deps
monaco-terminal.tsx had no importers; drop it together with the now-unused monaco-editor and @monaco-editor/react dependencies (~3 MB). The component is preserved on branch feature/frontend-monaco-terminal for later work. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5a7bdca4ac
commit
82b82c5f49
@@ -20,7 +20,6 @@
|
||||
"dependencies": {
|
||||
"@apollo/client": "^4.1.9",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
@@ -71,7 +70,6 @@
|
||||
"lru-cache": "^11.3.6",
|
||||
"lucide-react": "^1.16.0",
|
||||
"marked": "^18.0.3",
|
||||
"monaco-editor": "^0.55.1",
|
||||
"motion": "^12.39.0",
|
||||
"react": "^19.2.6",
|
||||
"react-day-picker": "^10.0.1",
|
||||
|
||||
Generated
-60
@@ -14,9 +14,6 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.76.0(react@19.2.6))
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@radix-ui/react-accordion':
|
||||
specifier: ^1.2.12
|
||||
version: 1.2.12(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -167,9 +164,6 @@ importers:
|
||||
marked:
|
||||
specifier: ^18.0.3
|
||||
version: 18.0.3
|
||||
monaco-editor:
|
||||
specifier: ^0.55.1
|
||||
version: 0.55.1
|
||||
motion:
|
||||
specifier: ^12.39.0
|
||||
version: 12.39.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
@@ -1344,16 +1338,6 @@ packages:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==}
|
||||
|
||||
'@monaco-editor/react@4.7.0':
|
||||
resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==}
|
||||
peerDependencies:
|
||||
monaco-editor: '>= 0.25.0 < 1'
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4':
|
||||
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
|
||||
peerDependencies:
|
||||
@@ -2607,9 +2591,6 @@ packages:
|
||||
'@types/react@19.2.14':
|
||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@2.0.11':
|
||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||
|
||||
@@ -3391,9 +3372,6 @@ packages:
|
||||
resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
domutils@2.8.0:
|
||||
resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==}
|
||||
|
||||
@@ -4476,11 +4454,6 @@ packages:
|
||||
markdown-table@3.0.4:
|
||||
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
|
||||
|
||||
marked@14.0.0:
|
||||
resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==}
|
||||
engines: {node: '>= 18'}
|
||||
hasBin: true
|
||||
|
||||
marked@18.0.3:
|
||||
resolution: {integrity: sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==}
|
||||
engines: {node: '>= 20'}
|
||||
@@ -4679,9 +4652,6 @@ packages:
|
||||
resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
|
||||
|
||||
motion-dom@12.39.0:
|
||||
resolution: {integrity: sha512-Xn7aAcGDhco/JZTXOub64UmaYn73C6J1Po7Fk+8EvkJsNGTqfhon6UJY53vJKXW5v5Zl8HrYsVxv6oPXeGoGLQ==}
|
||||
|
||||
@@ -5409,9 +5379,6 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
state-local@1.0.7:
|
||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
@@ -7172,17 +7139,6 @@ snapshots:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@monaco-editor/loader@1.7.0':
|
||||
dependencies:
|
||||
state-local: 1.0.7
|
||||
|
||||
'@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@monaco-editor/loader': 1.7.0
|
||||
monaco-editor: 0.55.1
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
|
||||
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.10.0
|
||||
@@ -8415,9 +8371,6 @@ snapshots:
|
||||
dependencies:
|
||||
csstype: 3.2.3
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@2.0.11': {}
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
@@ -9259,10 +9212,6 @@ snapshots:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@2.8.0:
|
||||
dependencies:
|
||||
dom-serializer: 1.4.1
|
||||
@@ -10513,8 +10462,6 @@ snapshots:
|
||||
|
||||
markdown-table@3.0.4: {}
|
||||
|
||||
marked@14.0.0: {}
|
||||
|
||||
marked@18.0.3: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
@@ -10906,11 +10853,6 @@ snapshots:
|
||||
dependencies:
|
||||
brace-expansion: 2.1.0
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
dependencies:
|
||||
dompurify: 3.2.7
|
||||
marked: 14.0.0
|
||||
|
||||
motion-dom@12.39.0:
|
||||
dependencies:
|
||||
motion-utils: 12.39.0
|
||||
@@ -11706,8 +11648,6 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
state-local@1.0.7: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
stop-iteration-iterator@1.1.0:
|
||||
|
||||
@@ -1,549 +0,0 @@
|
||||
/**
|
||||
* Monaco Terminal Component
|
||||
*
|
||||
* High-performance terminal log viewer based on Monaco Editor with ANSI color support.
|
||||
* Uses the `anser` library for robust ANSI escape sequence parsing.
|
||||
*
|
||||
* Supported ANSI features (via anser):
|
||||
* - SGR codes: Reset, Bold, Dim, Italic, Underline, Blink, Reverse, Hidden, Strikethrough
|
||||
* - Standard and bright foreground/background colors (30-37, 40-47, 90-97, 100-107)
|
||||
* - 256-color palette (38;5;N, 48;5;N)
|
||||
* - True color / 24-bit RGB (38;2;R;G;B, 48;2;R;G;B)
|
||||
* - Reverse video (proper fg/bg color swap handled by anser)
|
||||
*
|
||||
* Key optimizations:
|
||||
* - Dynamic CSS class injection with deduplication for color support
|
||||
* - IEditorDecorationsCollection for efficient decoration management
|
||||
* - Module-level WeakMap cache for parsed logs (avoids re-parsing same logs array)
|
||||
* - Memoized content extraction for better React performance
|
||||
* - Incremental decoration updates (only decorates new lines)
|
||||
* - Stable useCallback for mount handler
|
||||
*
|
||||
* @see https://microsoft.github.io/monaco-editor/docs.html for Monaco API reference
|
||||
* @see https://github.com/IonicaBizau/anser for ANSI parser documentation
|
||||
*/
|
||||
|
||||
import type * as monaco from 'monaco-editor';
|
||||
|
||||
import Editor, { type Monaco, type OnMount } from '@monaco-editor/react';
|
||||
import Anser from 'anser';
|
||||
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { Log } from '@/lib/log';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MonacoTerminalProps {
|
||||
className?: string;
|
||||
logs: string[];
|
||||
searchValue?: string;
|
||||
}
|
||||
|
||||
interface MonacoTerminalRef {
|
||||
findNext: () => void;
|
||||
findPrevious: () => void;
|
||||
}
|
||||
|
||||
interface ParsedLine {
|
||||
lineNumber: number;
|
||||
segments: ParsedSegment[];
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface ParsedSegment {
|
||||
className: string;
|
||||
endColumn: number;
|
||||
startColumn: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set of already injected CSS class names to prevent duplicate style injection.
|
||||
*/
|
||||
const injectedColorClasses = new Set<string>();
|
||||
|
||||
/**
|
||||
* Converts an RGB string like "187, 0, 0" to a hex string "bb0000".
|
||||
*/
|
||||
const rgbStringToHex = (rgb: string): string =>
|
||||
rgb
|
||||
.split(',')
|
||||
.map((part) =>
|
||||
Math.min(255, Math.max(0, parseInt(part.trim(), 10)))
|
||||
.toString(16)
|
||||
.padStart(2, '0'),
|
||||
)
|
||||
.join('');
|
||||
|
||||
/**
|
||||
* Returns the shared style element for dynamic color injection, creating it if needed.
|
||||
*/
|
||||
const getDynamicStyleElement = (): HTMLStyleElement => {
|
||||
const styleId = 'monaco-terminal-dynamic-colors';
|
||||
let element = document.getElementById(styleId) as HTMLStyleElement | null;
|
||||
|
||||
if (!element) {
|
||||
element = document.createElement('style');
|
||||
element.id = styleId;
|
||||
document.head.appendChild(element);
|
||||
}
|
||||
|
||||
return element;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures a CSS class for the given RGB color exists, injecting it if needed.
|
||||
* Returns the class name.
|
||||
*/
|
||||
const ensureColorClass = (prefix: string, rgb: string, cssProperty: string): string => {
|
||||
const hex = rgbStringToHex(rgb);
|
||||
const className = `${prefix}-${hex}`;
|
||||
|
||||
if (!injectedColorClasses.has(className)) {
|
||||
getDynamicStyleElement().textContent += `.${className} { ${cssProperty}: rgb(${rgb}) !important; }\n`;
|
||||
injectedColorClasses.add(className);
|
||||
}
|
||||
|
||||
return className;
|
||||
};
|
||||
|
||||
/**
|
||||
* Maps anser decoration names to CSS class names.
|
||||
*/
|
||||
const DECORATION_CLASS_MAP: Record<string, string> = {
|
||||
blink: 'ansi-blink',
|
||||
bold: 'ansi-bold',
|
||||
dim: 'ansi-dim',
|
||||
hidden: 'ansi-hidden',
|
||||
italic: 'ansi-italic',
|
||||
strikethrough: 'ansi-strikethrough',
|
||||
underline: 'ansi-underline',
|
||||
};
|
||||
|
||||
/**
|
||||
* Static CSS for text decoration and font style classes.
|
||||
*/
|
||||
const DECORATION_STYLES = [
|
||||
'.ansi-bold { font-weight: bold !important; }',
|
||||
'.ansi-dim { opacity: 0.7 !important; }',
|
||||
'.ansi-italic { font-style: italic !important; }',
|
||||
'.ansi-underline { text-decoration: underline !important; }',
|
||||
'.ansi-strikethrough { text-decoration: line-through !important; }',
|
||||
'.ansi-underline.ansi-strikethrough { text-decoration: underline line-through !important; }',
|
||||
'.ansi-hidden { visibility: hidden !important; }',
|
||||
'.ansi-blink { animation: ansi-blink 1s step-end infinite !important; }',
|
||||
'@keyframes ansi-blink { 50% { opacity: 0; } }',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* Regex matching ANSI sequences and control characters not handled by anser.
|
||||
* Anser only processes CSI SGR (ESC[...m). This regex matches everything else:
|
||||
* - OSC sequences: ESC ] ... (BEL | ESC \)
|
||||
* - DCS sequences: ESC P ... (ESC \)
|
||||
* - Character set designations: ESC ( X, ESC ) X
|
||||
* - DEC private sequences: ESC # N
|
||||
* - Single-character escape codes: ESC followed by 7,8,D,M,E,H,c,N,O,Z,=,>,<
|
||||
* - Lone ESC not followed by [ (catch-all for any remaining non-CSI escape)
|
||||
* - Non-printable control characters (except \t and \n)
|
||||
*/
|
||||
const UNSUPPORTED_SEQUENCES_REGEX = new RegExp(
|
||||
[
|
||||
'\\x1b\\][\\s\\S]*?(?:\\x07|\\x1b\\\\)', // OSC: ESC ] ... (BEL | ST)
|
||||
'\\x1bP[\\s\\S]*?\\x1b\\\\', // DCS: ESC P ... ST
|
||||
'\\x1b[()][A-Z0-9]', // Character set: ESC ( X or ESC ) X
|
||||
'\\x1b#[0-9]', // DEC screen alignment: ESC # N
|
||||
'\\x1b[78DMEHcNOZ=>]', // Single-char escape sequences
|
||||
'\\x1b(?!\\[)', // Lone ESC not starting a CSI sequence
|
||||
'[\\x00-\\x08\\x0b\\x0c\\x0e-\\x1a\\x1c-\\x1f\\x7f]', // Control chars (keep \t=0x09, \n=0x0a, \r=0x0d; skip ESC=0x1b)
|
||||
].join('|'),
|
||||
'g',
|
||||
);
|
||||
|
||||
/**
|
||||
* Sanitizes a single line before passing to anser:
|
||||
* 1. Handles carriage returns (\r) — simulates terminal overwrite behavior
|
||||
* by keeping only content after the last \r on the line
|
||||
* 2. Strips non-SGR escape sequences and control characters
|
||||
*/
|
||||
const sanitizeTerminalLine = (line: string): string => {
|
||||
// Handle carriage returns: keep only content after the last lone \r
|
||||
// This simulates terminal overwrite (e.g. progress bars)
|
||||
const lastCarriageReturn = line.lastIndexOf('\r');
|
||||
|
||||
const withoutCarriageReturns = lastCarriageReturn !== -1 ? line.substring(lastCarriageReturn + 1) : line;
|
||||
|
||||
return withoutCarriageReturns.replace(UNSUPPORTED_SEQUENCES_REGEX, '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses a single line using anser and returns structured data for Monaco decorations.
|
||||
* Anser handles all ANSI SGR codes including 256-color, true color, and reverse video.
|
||||
* Non-SGR sequences and control characters are stripped before parsing.
|
||||
*/
|
||||
const parseAnsiLine = (line: string, lineNumber: number): ParsedLine => {
|
||||
if (!line) {
|
||||
return { lineNumber, segments: [], text: '' };
|
||||
}
|
||||
|
||||
const sanitizedLine = sanitizeTerminalLine(line);
|
||||
|
||||
if (!sanitizedLine) {
|
||||
return { lineNumber, segments: [], text: '' };
|
||||
}
|
||||
|
||||
const entries = Anser.ansiToJson(sanitizedLine, { remove_empty: true });
|
||||
const segments: ParsedSegment[] = [];
|
||||
let column = 1;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const startColumn = column;
|
||||
const endColumn = column + entry.content.length;
|
||||
|
||||
column = endColumn;
|
||||
|
||||
const classNames: string[] = [];
|
||||
|
||||
if (entry.fg) {
|
||||
classNames.push(ensureColorClass('ansi-fg', entry.fg, 'color'));
|
||||
}
|
||||
|
||||
if (entry.bg) {
|
||||
classNames.push(ensureColorClass('ansi-bg', entry.bg, 'background-color'));
|
||||
}
|
||||
|
||||
for (const decoration of entry.decorations) {
|
||||
const decorationClass = DECORATION_CLASS_MAP[decoration];
|
||||
|
||||
if (decorationClass) {
|
||||
classNames.push(decorationClass);
|
||||
}
|
||||
}
|
||||
|
||||
if (classNames.length) {
|
||||
segments.push({
|
||||
className: classNames.join(' '),
|
||||
endColumn,
|
||||
startColumn,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const text = entries.map((entry) => entry.content).join('');
|
||||
|
||||
return { lineNumber, segments, text };
|
||||
};
|
||||
|
||||
/**
|
||||
* Module-level cache for parsed lines.
|
||||
* Uses WeakMap to avoid memory leaks - when logs array is garbage collected, cache is cleaned.
|
||||
*/
|
||||
const parsedLogsCache = new WeakMap<readonly string[], ParsedLine[]>();
|
||||
|
||||
/**
|
||||
* Parse logs array with caching.
|
||||
* Returns cached result if the same logs array reference is passed.
|
||||
*/
|
||||
const parseLogsWithCache = (logs: string[]): ParsedLine[] => {
|
||||
const cached = parsedLogsCache.get(logs);
|
||||
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const allLogsText = logs.join('\n');
|
||||
const lines = allLogsText.split('\n');
|
||||
const parsedLines = lines.map((line, index) => parseAnsiLine(line, index + 1));
|
||||
|
||||
parsedLogsCache.set(logs, parsedLines);
|
||||
|
||||
return parsedLines;
|
||||
};
|
||||
|
||||
/**
|
||||
* Terminal component based on Monaco Editor with ANSI color support.
|
||||
* Provides a read-only code viewer with search functionality, theme support, and ANSI color rendering.
|
||||
* Compatible with the existing Terminal component API.
|
||||
*/
|
||||
function MonacoTerminal({
|
||||
className,
|
||||
logs,
|
||||
ref,
|
||||
searchValue,
|
||||
}: MonacoTerminalProps & { ref?: React.RefObject<MonacoTerminalRef | null> }) {
|
||||
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(null);
|
||||
const monacoRef = useRef<Monaco | null>(null);
|
||||
const { theme } = useTheme();
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const prevLogsLengthRef = useRef<number>(0);
|
||||
const searchWidgetRef = useRef<null | { findNext: () => void; findPrevious: () => void }>(null);
|
||||
const decorationsCollectionRef = useRef<monaco.editor.IEditorDecorationsCollection | null>(null);
|
||||
|
||||
const parsedContent = useMemo(() => parseLogsWithCache(logs), [logs]);
|
||||
|
||||
const content = useMemo(() => parsedContent.map((line) => line.text).join('\n'), [parsedContent]);
|
||||
|
||||
const handleEditorDidMount: OnMount = useCallback((editor, monacoInstance) => {
|
||||
editorRef.current = editor;
|
||||
monacoRef.current = monacoInstance;
|
||||
setIsEditorReady(true);
|
||||
|
||||
editor.updateOptions({
|
||||
automaticLayout: true,
|
||||
folding: false,
|
||||
glyphMargin: false,
|
||||
lineDecorationsWidth: 0,
|
||||
lineNumbers: 'on',
|
||||
lineNumbersMinChars: 5,
|
||||
minimap: { enabled: false },
|
||||
overviewRulerLanes: 0, // Disable overview ruler for better performance
|
||||
padding: { top: 4 },
|
||||
readOnly: true,
|
||||
renderLineHighlight: 'none',
|
||||
renderWhitespace: 'none',
|
||||
scrollbar: {
|
||||
alwaysConsumeMouseWheel: false,
|
||||
horizontal: 'hidden', // Hide horizontal scrollbar completely
|
||||
useShadows: false,
|
||||
vertical: 'visible',
|
||||
verticalScrollbarSize: 10,
|
||||
},
|
||||
scrollBeyondLastLine: false,
|
||||
selectOnLineNumbers: false,
|
||||
wordWrap: 'on', // Enable word wrap (simple 'on' works better than 'bounded')
|
||||
wrappingIndent: 'same',
|
||||
wrappingStrategy: 'simple', // Use simple strategy for better performance
|
||||
});
|
||||
|
||||
const ansiStyleId = 'monaco-terminal-ansi-styles';
|
||||
|
||||
if (!document.getElementById(ansiStyleId)) {
|
||||
const ansiStyleElement = document.createElement('style');
|
||||
|
||||
ansiStyleElement.id = ansiStyleId;
|
||||
ansiStyleElement.textContent = DECORATION_STYLES;
|
||||
document.head.appendChild(ansiStyleElement);
|
||||
}
|
||||
|
||||
const terminalStyleId = 'monaco-terminal-custom-styles';
|
||||
|
||||
if (!document.getElementById(terminalStyleId)) {
|
||||
const terminalStyleElement = document.createElement('style');
|
||||
|
||||
terminalStyleElement.id = terminalStyleId;
|
||||
terminalStyleElement.textContent = `
|
||||
.monaco-editor .line-numbers {
|
||||
padding-right: 12px !important;
|
||||
}
|
||||
.monaco-editor,
|
||||
.monaco-editor .monaco-editor-background,
|
||||
.monaco-editor .margin {
|
||||
background-color: var(--background) !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(terminalStyleElement);
|
||||
}
|
||||
|
||||
searchWidgetRef.current = {
|
||||
findNext: () => {
|
||||
try {
|
||||
editor.trigger('monaco-terminal', 'actions.find', {});
|
||||
editor.trigger('monaco-terminal', 'editor.action.nextMatchFindAction', {});
|
||||
} catch (error: unknown) {
|
||||
Log.error('Monaco findNext failed:', error);
|
||||
}
|
||||
},
|
||||
findPrevious: () => {
|
||||
try {
|
||||
editor.trigger('monaco-terminal', 'actions.find', {});
|
||||
editor.trigger('monaco-terminal', 'editor.action.previousMatchFindAction', {});
|
||||
} catch (error: unknown) {
|
||||
Log.error('Monaco findPrevious failed:', error);
|
||||
}
|
||||
},
|
||||
};
|
||||
}, []);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
findNext: () => {
|
||||
if (searchWidgetRef.current && editorRef.current) {
|
||||
searchWidgetRef.current.findNext();
|
||||
}
|
||||
},
|
||||
findPrevious: () => {
|
||||
if (searchWidgetRef.current && editorRef.current) {
|
||||
searchWidgetRef.current.findPrevious();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const decoratedLinesCountRef = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady || !editorRef.current || !monacoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = editorRef.current;
|
||||
const monacoInstance = monacoRef.current;
|
||||
|
||||
try {
|
||||
const decoratedCount = decoratedLinesCountRef.current;
|
||||
|
||||
if (parsedContent.length < decoratedCount) {
|
||||
if (decorationsCollectionRef.current) {
|
||||
decorationsCollectionRef.current.clear();
|
||||
}
|
||||
|
||||
decoratedLinesCountRef.current = 0;
|
||||
}
|
||||
|
||||
if (parsedContent.length <= decoratedLinesCountRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Incremental: only decorate the tail of `parsedContent` past `decoratedLinesCountRef`.
|
||||
// Re-applying the full set on every log append turns large terminals into a stutter
|
||||
// because Monaco re-runs the decoration delta over every line.
|
||||
const newDecorations: monaco.editor.IModelDeltaDecoration[] = [];
|
||||
|
||||
for (let index = decoratedLinesCountRef.current; index < parsedContent.length; index++) {
|
||||
const line = parsedContent[index];
|
||||
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const segment of line.segments) {
|
||||
newDecorations.push({
|
||||
options: {
|
||||
inlineClassName: segment.className,
|
||||
},
|
||||
range: new monacoInstance.Range(
|
||||
line.lineNumber,
|
||||
segment.startColumn,
|
||||
line.lineNumber,
|
||||
segment.endColumn,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newDecorations.length) {
|
||||
if (decorationsCollectionRef.current) {
|
||||
decorationsCollectionRef.current.append(newDecorations);
|
||||
} else {
|
||||
decorationsCollectionRef.current = editor.createDecorationsCollection(newDecorations);
|
||||
}
|
||||
}
|
||||
|
||||
decoratedLinesCountRef.current = parsedContent.length;
|
||||
} catch (error: unknown) {
|
||||
Log.error('Monaco apply decorations failed:', error);
|
||||
}
|
||||
}, [parsedContent, isEditorReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady || !editorRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = editorRef.current;
|
||||
|
||||
// Auto-scroll only on appends — not on the initial mount (`prev === 0`) and not when logs
|
||||
// shrink (cleared), so we don't fight a user who scrolled up before more output arrives.
|
||||
if (logs.length > prevLogsLengthRef.current && prevLogsLengthRef.current > 0) {
|
||||
try {
|
||||
const lineCount = editor.getModel()?.getLineCount() ?? 0;
|
||||
|
||||
if (lineCount > 0) {
|
||||
editor.revealLine(lineCount, 1);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
Log.error('Monaco scroll failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
prevLogsLengthRef.current = logs.length;
|
||||
}, [logs, isEditorReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady || !editorRef.current || !monacoRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editor = editorRef.current;
|
||||
|
||||
if (searchValue?.trim()) {
|
||||
try {
|
||||
const searchText = searchValue.trim();
|
||||
|
||||
const findOptions = {
|
||||
isRegex: false,
|
||||
matchCase: false,
|
||||
preserveCase: false,
|
||||
searchString: searchText,
|
||||
wholeWord: false,
|
||||
};
|
||||
|
||||
editor.trigger('monaco-terminal', 'actions.find', findOptions);
|
||||
|
||||
// rAF (instead of setTimeout) lets Monaco finish mounting the find widget before
|
||||
// we issue the jump-to-first-match — fewer flaky races at log-bursty moments.
|
||||
requestAnimationFrame(() => {
|
||||
editor.trigger('monaco-terminal', 'editor.action.nextMatchFindAction', {});
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
Log.error('Monaco search failed:', error);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
editor.trigger('monaco-terminal', 'closeFindWidget', {});
|
||||
} catch (error: unknown) {
|
||||
Log.error('Monaco close find widget failed:', error);
|
||||
}
|
||||
}
|
||||
}, [searchValue, isEditorReady]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (decorationsCollectionRef.current) {
|
||||
decorationsCollectionRef.current.clear();
|
||||
decorationsCollectionRef.current = null;
|
||||
}
|
||||
|
||||
decoratedLinesCountRef.current = 0;
|
||||
|
||||
// Shared <style> tags are deliberately left attached — multiple instances share them
|
||||
// and the WeakMap-backed parseLogsWithCache cleans itself up.
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cn('relative size-full overflow-hidden', className)}>
|
||||
<Editor
|
||||
defaultLanguage="plaintext"
|
||||
height="100%"
|
||||
language="plaintext"
|
||||
onMount={handleEditorDidMount}
|
||||
options={{
|
||||
domReadOnly: true,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
}}
|
||||
theme={theme === 'dark' ? 'vs-dark' : 'vs-light'}
|
||||
value={content}
|
||||
width="100%"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MonacoTerminal;
|
||||
Reference in New Issue
Block a user