AUDIT / src /components /CodeMirrorEditor.tsx
Arypulka98's picture
feat(audit): deploy full backend cluster node
aea470f verified
Raw
History Blame Contribute Delete
28.3 kB
/**
* CodeMirrorEditor.tsx — Gap 4: CodeMirror 6 via esm.sh CDN
*
* Zero bundle impact: caricato dinamicamente al primo mount.
* Fallback graceful a textarea mentre il CDN carica (o in caso di errore).
* Safari/iPhone-safe: no backdrop-filter, no SharedArrayBuffer, distrugge view on unmount.
*
* Lingue supportate: Python, JS, JSX, TS, TSX, HTML, CSS, JSON, Markdown.
* Funzionalità: line numbers, syntax highlight, auto-indentazione, bracket matching,
* fold gutter, history (undo/redo), autocompletion, Cmd/Ctrl+S → save.
*/
import { useEffect, useRef, useState, useCallback, memo } from "react";
import { Z_INDEX } from "@/lib/zindex";
import type * as React from "react"; // FIX11: React namespace types
import type { VfsTsError } from "@/lib/vfsTsChecker"; // GAP-3
// ── Language detection from filename extension ────────────────────────────────
function detectLang(filename: string): string {
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
if (ext === "py") return "python";
if (ext === "js" || ext === "mjs" || ext === "cjs") return "javascript";
if (ext === "jsx") return "jsx";
if (ext === "ts") return "typescript";
if (ext === "tsx") return "tsx";
if (ext === "html" || ext === "htm") return "html";
if (ext === "css" || ext === "scss") return "css";
if (ext === "json") return "json";
if (ext === "md" || ext === "markdown") return "markdown";
return "plain";
}
// ── Singleton CDN loader ──────────────────────────────────────────────────────
// Tutti i pacchetti dallo stesso host esm.sh con target=es2020 per Safari 15+.
// Il browser condivide le istanze dei moduli via URL cache → identità dei tipi preservata.
const ESM = "https://esm.sh";
const Q = "?target=es2020";
// S762: typed CDN interface — eliminates 18 ':any' in CMBundle
type CMExt = object; // Extension opaque type — used as array element
interface CMEditorView {
dom: HTMLElement;
state: { doc: { toString(): string }; replaceSelection(text: string): object };
dispatch(tr: object): void;
focus(): void;
destroy(): void;
}
interface CMEditorViewCtor {
new (cfg: { state?: object; parent?: HTMLElement }): CMEditorView;
updateListener: { of(fn: (upd: { docChanged: boolean; state: { doc: { toString(): string } } }) => void): CMExt };
theme(spec: Record<string, unknown>): CMExt;
}
interface CMBundle {
EditorState: { create(cfg: { doc?: string; extensions?: CMExt[] }): object };
EditorView: CMEditorViewCtor;
keymap: { of(bindings: readonly object[]): CMExt };
lineNumbers: () => CMExt;
highlightActiveLine: () => CMExt;
highlightActiveLineGutter: () => CMExt;
drawSelection: () => CMExt;
rectangularSelection: (() => CMExt) | null | undefined;
crosshairCursor: (() => CMExt) | null | undefined;
history: () => CMExt;
defaultKeymap: readonly object[];
historyKeymap: readonly object[];
indentWithTab: object;
foldKeymap: readonly object[];
indentOnInput: CMExt;
syntaxHighlighting: (style: object, opts?: Record<string, unknown>) => CMExt;
defaultHighlightStyle: object;
bracketMatching: () => CMExt;
foldGutter: () => CMExt;
codeFolding: (() => CMExt) | null | undefined;
closeBrackets: () => CMExt;
closeBracketsKeymap: readonly object[];
autocompletion: (cfg?: Record<string, unknown>) => CMExt;
completionKeymap: readonly object[];
oneDark: CMExt;
py: { python(): CMExt } | null;
js: { javascript(cfg?: Record<string, unknown>): CMExt } | null;
html: { html(): CMExt } | null;
css: { css(): CMExt } | null;
json: { json(): CMExt } | null;
md: { markdown(): CMExt } | null;
}
let _cm: CMBundle | null = null;
let _cmP: Promise<CMBundle> | null = null;
function loadCM(): Promise<CMBundle> {
if (_cm) return Promise.resolve(_cm);
if (_cmP) return _cmP;
_cmP = (async (): Promise<CMBundle> => {
const [
stateMod, viewMod, cmdMod, langMod, acMod, themeMod,
pyMod, jsMod, htmlMod, cssMod, jsonMod, mdMod,
] = await Promise.all([
import(`${ESM}/@codemirror/state${Q}`),
import(`${ESM}/@codemirror/view${Q}`),
import(`${ESM}/@codemirror/commands${Q}`),
import(`${ESM}/@codemirror/language${Q}`),
import(`${ESM}/@codemirror/autocomplete${Q}`),
import(`${ESM}/@codemirror/theme-one-dark${Q}`),
import(`${ESM}/@codemirror/lang-python${Q}`).catch(() => null),
import(`${ESM}/@codemirror/lang-javascript${Q}`).catch(() => null),
import(`${ESM}/@codemirror/lang-html${Q}`).catch(() => null),
import(`${ESM}/@codemirror/lang-css${Q}`).catch(() => null),
import(`${ESM}/@codemirror/lang-json${Q}`).catch(() => null),
import(`${ESM}/@codemirror/lang-markdown${Q}`).catch(() => null),
]);
_cm = {
EditorState: stateMod.EditorState,
EditorView: viewMod.EditorView,
keymap: viewMod.keymap,
lineNumbers: viewMod.lineNumbers,
highlightActiveLine: viewMod.highlightActiveLine,
highlightActiveLineGutter: viewMod.highlightActiveLineGutter,
drawSelection: viewMod.drawSelection,
rectangularSelection: viewMod.rectangularSelection ?? null,
crosshairCursor: viewMod.crosshairCursor ?? null,
history: cmdMod.history,
defaultKeymap: cmdMod.defaultKeymap,
historyKeymap: cmdMod.historyKeymap,
indentWithTab: cmdMod.indentWithTab,
foldKeymap: cmdMod.foldKeymap ?? [],
indentOnInput: langMod.indentOnInput,
syntaxHighlighting: langMod.syntaxHighlighting,
defaultHighlightStyle: langMod.defaultHighlightStyle,
bracketMatching: langMod.bracketMatching,
foldGutter: langMod.foldGutter,
codeFolding: langMod.codeFolding ?? null,
closeBrackets: acMod.closeBrackets,
closeBracketsKeymap: acMod.closeBracketsKeymap,
autocompletion: acMod.autocompletion,
completionKeymap: acMod.completionKeymap,
oneDark: themeMod.oneDark,
py: pyMod,
js: jsMod,
html: htmlMod,
css: cssMod,
json: jsonMod,
md: mdMod,
};
return _cm!;
})();
return _cmP;
}
// ── iOS detection (module-level, sync, zero cost) ─────────────────────────────
const _isIOS = typeof navigator !== "undefined"
&& /iPhone|iPad|iPod/.test(navigator.userAgent);
// ── Simboli toolbar mobile ────────────────────────────────────────────────────
// Ordinati per frequenza d'uso nel codice. Tab è separato (inserisce " ").
const MOBILE_SYMBOLS = [
{ label: "⇥", insert: " " }, // Tab (2 spazi)
{ label: "{", insert: "{" },
{ label: "}", insert: "}" },
{ label: "(", insert: "(" },
{ label: ")", insert: ")" },
{ label: "[", insert: "[" },
{ label: "]", insert: "]" },
{ label: "=", insert: "=" },
{ label: ":", insert: ":" },
{ label: "->", insert: "->" },
{ label: "//", insert: "//" },
{ label: '"', insert: '"' },
{ label: "'", insert: "'" },
{ label: ";", insert: ";" },
] as const;
// ── Props ─────────────────────────────────────────────────────────────────────
interface CodeMirrorEditorProps {
value: string;
onChange: (v: string) => void;
filename?: string;
onSave?: () => void;
style?: React.CSSProperties;
tsErrors?: VfsTsError[]; // GAP-3
/** S800: posizione cursore (line, col 1-based) — aggiornata ad ogni keypress */
onCursorChange?: (line: number, col: number) => void;
/** S800: dimensione font in px (default 13) — controllata da MobileEditorStatusBar */
fontSize?: number;
/** S800: ref per scrollare l'editor a un offset specifico (usato da MobileEditorFindBar) */
scrollToOffsetRef?: React.MutableRefObject<((offset: number) => void) | null>;
}
// ── Shared textarea style ─────────────────────────────────────────────────────
const TA_STYLE: React.CSSProperties = {
position: "absolute", inset: 0,
width: "100%", height: "100%",
padding: "14px 16px",
background: "hsl(222 22% 7%)", border: "none", outline: "none",
color: "#e4e4e7", fontSize: "0.83rem", lineHeight: 1.7,
fontFamily: "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace",
resize: "none", boxSizing: "border-box", zIndex: 1,
};
// ── N7: SelectionTooltip — Inline AI su testo selezionato ────────────────────
// Appare quando l'utente seleziona testo nell'editor CodeMirror.
// Pulsanti: Spiega / Fixa → pre-compilano ChatInputBar via useChatStore.setInput().
interface _TooltipState { top: number; left: number; text: string; visible: boolean; }
function _SelectionTooltip({ hostRef }: { hostRef: React.RefObject<HTMLDivElement | null> }) {
const [tip, setTip] = useState<_TooltipState>({ top: 0, left: 0, text: "", visible: false });
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Ascolta il DOM event "selectionchange" sul contenitore CM
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const onSel = () => {
const sel = window.getSelection();
const txt = sel?.toString().trim() ?? "";
if (!txt || txt.length < 5) { setTip(p => ({ ...p, visible: false })); return; }
if (hideTimer.current) { clearTimeout(hideTimer.current); hideTimer.current = null; }
const range = sel?.getRangeAt(0);
if (!range) return;
const rect = range.getBoundingClientRect();
const hRect = host.getBoundingClientRect();
setTip({
visible: true,
text: txt,
top: rect.top - hRect.top - 42,
left: rect.left - hRect.left + rect.width / 2,
});
};
document.addEventListener("selectionchange", onSel, { passive: true });
return () => document.removeEventListener("selectionchange", onSel);
}, [hostRef]);
const send = useCallback((prefix: string) => {
const msg = `${prefix}:\n\`\`\`\n${tip.text}\n\`\`\``;
try {
// Importa useChatStore in modo lazy per evitare dipendenza circolare al caricamento CDN
import("@/store/chatStore").then(({ useChatStore: cs }) => {
cs.getState().setInput?.(msg);
}).catch(() => {});
} catch { /* non-blocking */ }
setTip(p => ({ ...p, visible: false }));
}, [tip.text]);
if (!tip.visible || !tip.text) return null;
return (
<div
style={{
position: "absolute",
top: tip.top,
left: tip.left,
transform: "translateX(-50%)",
zIndex: Z_INDEX.BANNER,
display: "flex", gap: 4,
background: "rgba(14,16,40,0.97)",
border: "1px solid rgba(99,102,241,0.35)",
borderRadius: 8,
padding: "4px 6px",
boxShadow: "0 8px 24px rgba(0,0,0,0.6)",
pointerEvents: "auto",
userSelect: "none",
}}
onMouseDown={e => e.preventDefault()} // evita blur dell'editor
>
{[
{ label: "🔍 Spiega", prefix: "Spiega questo codice" },
{ label: "🔧 Fixa", prefix: "Fixa questo codice" },
].map(({ label, prefix }) => (
<button
key={label}
onPointerDown={e => { e.preventDefault(); send(prefix); }}
style={{
all: "unset",
cursor: "pointer",
padding: "3px 9px",
borderRadius: 6,
fontSize: "0.7rem",
fontWeight: 600,
color: "#c7d2fe",
background: "rgba(99,102,241,0.12)",
border: "1px solid rgba(99,102,241,0.22)",
touchAction: "manipulation",
transition: "background 0.1s",
}}
onMouseEnter={e => (e.currentTarget.style.background = "rgba(99,102,241,0.25)")}
onMouseLeave={e => (e.currentTarget.style.background = "rgba(99,102,241,0.12)")}
>
{label}
</button>
))}
</div>
);
}
// ── Component ─────────────────────────────────────────────────────────────────
const CodeMirrorEditor = memo(function CodeMirrorEditor({
value, onChange, filename = "", onSave, style, tsErrors = [],
onCursorChange, fontSize = 13, scrollToOffsetRef,
}: CodeMirrorEditorProps) {
const hostRef = useRef<HTMLDivElement>(null);
const viewRef = useRef<CMEditorView | null>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const onChangeRef = useRef(onChange);
const onSaveRef = useRef(onSave);
const onCursorChangeRef = useRef(onCursorChange);
const latestValue = useRef(value); // sempre aggiornato nel render per catturare il valore corrente al mount
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
// ── Inserisce testo al cursore (toolbar mobile) ──────────────────────────
const insertAtCursor = (text: string) => {
// Caso 1: CodeMirror montato e pronto
const view = viewRef.current;
if (view && status === "ready") {
view.dispatch(view.state.replaceSelection(text));
view.focus();
return;
}
// Caso 2: textarea di fallback
const ta = taRef.current;
if (!ta) return;
const s = ta.selectionStart ?? 0;
const en = ta.selectionEnd ?? 0;
const next = value.slice(0, s) + text + value.slice(en);
onChange(next);
requestAnimationFrame(() => {
ta.focus();
ta.selectionStart = ta.selectionEnd = s + text.length;
});
};
// Mantieni refs aggiornate senza rimontare l'editor
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => { onSaveRef.current = onSave; }, [onSave]);
useEffect(() => { onCursorChangeRef.current = onCursorChange ?? undefined; }, [onCursorChange]);
// Aggiornamento sincrono nel render: al momento del mount effect questo
// conterrà il valore più recente passato dal parent (es. dopo switch file).
latestValue.current = value;
// ── Mount / remount quando cambia il filename (= lingua) ────────────────
useEffect(() => {
if (!hostRef.current) return;
let aborted = false;
setStatus("loading");
const lang = detectLang(filename);
loadCM()
.then((CM) => {
if (aborted || !hostRef.current) return;
// ── Language extension ─────────────────────────────────────────
let langExt: object[] = [];
try {
if (lang === "python" && CM.py?.python) langExt = [CM.py.python()];
if (lang === "javascript" && CM.js?.javascript) langExt = [CM.js.javascript({ jsx: false })];
if (lang === "jsx" && CM.js?.javascript) langExt = [CM.js.javascript({ jsx: true })];
if (lang === "typescript" && CM.js?.javascript) langExt = [CM.js.javascript({ typescript: true })];
if (lang === "tsx" && CM.js?.javascript) langExt = [CM.js.javascript({ jsx: true, typescript: true })];
if (lang === "html" && CM.html?.html) langExt = [CM.html.html()];
if (lang === "css" && CM.css?.css) langExt = [CM.css.css()];
if (lang === "json" && CM.json?.json) langExt = [CM.json.json()];
if (lang === "markdown" && CM.md?.markdown) langExt = [CM.md.markdown()];
} catch { /* ignore lang load failure */ }
// ── Estensioni base ────────────────────────────────────────────
const extras: object[] = [];
if (CM.rectangularSelection) extras.push(CM.rectangularSelection());
if (CM.crosshairCursor) extras.push(CM.crosshairCursor());
if (CM.codeFolding) extras.push(CM.codeFolding());
const keymapBindings = [
...CM.closeBracketsKeymap,
...CM.defaultKeymap,
...CM.historyKeymap,
...CM.foldKeymap,
...CM.completionKeymap,
CM.indentWithTab,
{ key: "Mod-s", run() { onSaveRef.current?.(); return true; } },
];
const extensions = [
CM.lineNumbers(),
CM.highlightActiveLineGutter(),
CM.foldGutter(),
CM.drawSelection(),
CM.history(),
// @ts-expect-error CM.indentOnInput runtime callable not in static type
CM.indentOnInput(),
CM.syntaxHighlighting(CM.defaultHighlightStyle, { fallback: true }),
CM.bracketMatching(),
CM.closeBrackets(),
CM.autocompletion(),
CM.highlightActiveLine(),
CM.oneDark,
CM.keymap.of(keymapBindings),
...extras,
...langExt,
// Listener: propaga modifiche al parent
// @ts-expect-error CM updateListener upd type
CM.EditorView.updateListener.of((upd: { docChanged: boolean; state: { doc: { toString(): string }; selection: { main: { head: number } } } }) => {
if (upd.docChanged) {
onChangeRef.current(upd.state.doc.toString());
}
// S800: cursor tracking per status bar
if (onCursorChangeRef.current) {
try {
const offset = upd.state.selection.main.head;
const text = upd.state.doc.toString().slice(0, offset);
const lines = text.split("\n");
const line = lines.length;
const col = (lines[lines.length - 1]?.length ?? 0) + 1;
onCursorChangeRef.current(line, col);
} catch { /* non-blocking */ }
}
}),
// Tema custom per adattarsi alla UI esistente
CM.EditorView.theme({
"&": {
height: "100%",
fontSize: `${fontSize}px`,
fontFamily: "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace",
},
".cm-scroller": { overflow: "auto", lineHeight: "1.7" },
".cm-content": { padding: "12px 0", minHeight: "100%" },
".cm-gutters": {
background: "hsl(222 22% 7%)",
border: "none",
borderRight: "1px solid rgba(255,255,255,0.06)",
},
".cm-lineNumbers .cm-gutterElement": {
color: "rgba(150,150,175,0.32)",
minWidth: "38px",
padding: "0 8px 0 4px",
},
".cm-foldGutter .cm-gutterElement": {
color: "rgba(150,150,175,0.25)",
},
".cm-activeLine": { background: "rgba(255,255,255,0.025)" },
".cm-activeLineGutter":{ background: "rgba(255,255,255,0.025)" },
"&.cm-focused": { outline: "none" },
"&.cm-focused .cm-cursor": { borderLeftColor: "#60a5fa", borderLeftWidth: "2px" },
".cm-selectionBackground": { background: "rgba(96,165,250,0.20) !important" },
"&.cm-focused .cm-selectionBackground": { background: "rgba(96,165,250,0.25) !important" },
}),
];
// Monta l'editor nel host (svuota prima per sicurezza)
hostRef.current.innerHTML = "";
const view = new CM.EditorView({
state: CM.EditorState.create({
doc: latestValue.current,
extensions,
}),
parent: hostRef.current,
});
viewRef.current = view;
// S800: espone scrollToOffset per MobileEditorFindBar
if (scrollToOffsetRef) {
scrollToOffsetRef.current = (offset: number) => {
try {
view.dispatch({ selection: { anchor: offset }, scrollIntoView: true });
view.focus();
} catch { /* non-blocking */ }
};
}
if (!aborted) setStatus("ready");
})
.catch((err) => {
console.warn("[CodeMirrorEditor] CDN load failed, fallback to textarea:", err);
if (!aborted) setStatus("error");
});
return () => {
aborted = true;
viewRef.current?.destroy();
viewRef.current = null;
if (scrollToOffsetRef) scrollToOffsetRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filename]); // rimonta SOLO quando cambia il file (= lingua)
// S800-FIX: aggiorna font size senza rimontare CM (solo DOM override)
useEffect(() => {
if (!hostRef.current || status !== "ready") return;
const el = hostRef.current.querySelector(".cm-editor") as HTMLElement | null;
if (el) el.style.fontSize = `${fontSize}px`;
}, [fontSize, status]);
// ── Sincronizza valore esterno → CM (es. switch file a stesso nome) ──────
useEffect(() => {
const view = viewRef.current;
if (!view) return;
const cur = view.state.doc.toString();
if (cur === value) return;
view.dispatch({
changes: { from: 0, to: cur.length, insert: value },
});
}, [value]);
// ── Navigazione a riga specifica (agent:goto-line) ─────────────────────────
// Pattern CustomEvent coerente con agent:file-changed / agent:merge-ts-errors.
// Dispatched da ProblemsPanel dopo setRequestOpenPath (200ms delay per Dexie load).
// Safari-safe: addEventListener con cleanup, typeof window guard, fail-safe try/catch.
useEffect(() => {
if (typeof window === "undefined") return;
const handler = (e: Event) => {
const { line } = (e as CustomEvent<{ line: number }>).detail ?? {};
if (!line || line < 1) return;
const view = viewRef.current;
if (!view) return;
try {
const text = view.state.doc.toString();
const texts = text.split("\n");
let offset = 0;
for (let i = 0; i < Math.min(line - 1, texts.length - 1); i++) {
offset += (texts[i]?.length ?? 0) + 1;
}
view.dispatch({ selection: { anchor: offset }, scrollIntoView: true });
view.focus();
} catch { /* fail-safe */ }
};
window.addEventListener("agent:goto-line", handler);
return () => window.removeEventListener("agent:goto-line", handler);
}, []);
// ── Tastiera nella textarea di fallback ──────────────────────────────────
const handleFallbackKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
onSave?.();
}
if (e.key === "Tab") {
e.preventDefault();
const ta = e.currentTarget;
const s = ta.selectionStart;
const en = ta.selectionEnd;
onChange(value.slice(0, s) + " " + value.slice(en));
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 2; });
}
};
return (
<div style={{
flex: 1,
display: "flex",
flexDirection: "column",
overflow: "hidden",
background: "hsl(222 22% 7%)",
...style,
}}>
{/* ── Toolbar simboli mobile — solo iPhone/iPad ───────────────────── */}
{_isIOS && (
<div
style={{
display: "flex",
flexDirection: "row",
overflowX: "auto",
overflowY: "hidden",
gap: "2px",
padding: "4px 6px",
background: "hsl(222 22% 10%)",
borderBottom: "1px solid rgba(255,255,255,0.07)",
flexShrink: 0,
// B7: WebkitOverflowScrolling rimossodeprecato iOS 13+, causa doppio layer scroll
scrollbarWidth: "none",
}}
onScroll={e => e.stopPropagation()}
>
{MOBILE_SYMBOLS.map(sym => (
<button
key={sym.label}
onPointerDown={e => {
// pointerDown evita che il focus lasci l'editor su iOS
e.preventDefault();
insertAtCursor(sym.insert);
}}
style={{
flexShrink: 0,
minWidth: "34px",
height: "30px",
padding: "0 6px",
background: "hsl(222 22% 16%)",
border: "1px solid rgba(255,255,255,0.10)",
borderRadius: "5px",
color: "#e4e4e7",
fontSize: "0.78rem",
fontFamily: "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace",
cursor: "pointer",
userSelect: "none",
WebkitUserSelect: "none" as React.CSSProperties["WebkitUserSelect"],
touchAction: "manipulation",
}}
>
{sym.label}
</button>
))}
</div>
)}
{/* ── Editor area ────────────────────────────────────────────────── */}
<div style={{ flex: 1, position: "relative", overflow: "hidden" }}>
{/* N7: Inline AI tooltip su selezione */}
<_SelectionTooltip hostRef={hostRef} />
{/* Host CodeMirror — sempre nel DOM con dimensioni reali */}
<div
ref={hostRef}
style={{
position: "absolute", inset: 0,
opacity: status === "ready" ? 1 : 0,
pointerEvents: status === "ready" ? "auto" : "none",
transition: "opacity 0.12s",
}}
/>
{/* Textarea di fallback — visibile durante caricamento o in caso di errore */}
{status !== "ready" && (
<textarea
ref={taRef}
value={value}
onChange={e => onChange(e.target.value)}
onKeyDown={handleFallbackKey}
spellCheck={false}
placeholder={
status === "loading"
? "Caricamento editor…"
: "Editor non disponibile (offline?)"
}
style={TA_STYLE}
/>
)}
</div>
{/* GAP-3: TS error panel — compact list below editor */}
{tsErrors.length > 0 && (
<div style={{
flexShrink: 0, maxHeight: "22vh", overflowY: "auto",
borderTop: "1px solid rgba(248,113,113,0.15)",
background: "rgba(10,8,22,0.97)",
scrollbarWidth: "none",
}}>
{tsErrors.map((e, i) => (
<div key={i} style={{
display: "flex", alignItems: "baseline", gap: 6,
padding: "3px 10px",
borderBottom: "1px solid rgba(255,255,255,0.03)",
fontSize: "0.66rem",
fontFamily: "ui-monospace,SFMono-Regular,monospace",
color: e.sev === "error" ? "#f87171" : "#fbbf24",
}}>
<span style={{ opacity: 0.45, flexShrink: 0 }}>
{e.sev === "error" ? "✕" : "△"} L{e.line}
</span>
<span style={{ color: "#a0a0c0", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{e.message}
</span>
<span style={{ opacity: 0.35, flexShrink: 0 }}>TS{e.code}</span>
</div>
))}
</div>
)}
</div>
);
});
export default CodeMirrorEditor;