Repository Public
1'use client';2/** Monaco wrappers. This module is only ever loaded through next/dynamic (it pulls the whole editor). */3import { useEffect, useRef } from 'react';4import Editor, { DiffEditor, type OnMount } from '@monaco-editor/react';5import { setupMonaco } from './monaco-setup';6import { useTheme } from '@/stores/theme';78const monaco = setupMonaco();9const common = {10 fontFamily: '"JetBrains Mono", ui-monospace, Menlo, monospace',11 fontLigatures: false,12 minimap: { enabled: false },13 scrollBeyondLastLine: false,14 smoothScrolling: false,15 renderLineHighlight: 'line' as const,16 padding: { top: 8, bottom: 8 },17 automaticLayout: true,18 tabSize: 2,19 stickyScroll: { enabled: false },20 overviewRulerBorder: false,21 unicodeHighlight: { ambiguousCharacters: false },22};23const Loading = <div className="flex h-full items-center justify-center text-xs text-muted">Loading editor…</div>;2425export interface CodeEditorProps {26 /** Model identity (keeps undo history per file). */27 modelPath: string;28 value: string;29 onChange?: (value: string) => void;30 onSave?: () => void;31 readOnly?: boolean;32 language?: string;33 fontSize?: number;34 mobile?: boolean;35}36export function CodeEditor({ modelPath, value, onChange, onSave, readOnly, language, fontSize = 13, mobile }: CodeEditorProps) {37 const theme = useTheme((t) => t.theme);38 const saveRef = useRef(onSave);39 saveRef.current = onSave;40 const onMount: OnMount = (editor) => {41 editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => saveRef.current?.());42 if (process.env.NEXT_PUBLIC_E2E === '1') (window as unknown as { __crEditor: unknown }).__crEditor = editor;43 };44 return (45 <Editor46 height="100%"47 theme={theme === 'dark' ? 'cr-dark' : 'cr-light'}48 path={`inmemory://cr/${modelPath}`}49 language={language}50 value={value}51 onChange={(v) => onChange?.(v ?? '')}52 onMount={onMount}53 loading={Loading}54 options={{ ...common, fontSize: mobile ? Math.max(fontSize, 14) : fontSize, readOnly, lineNumbers: mobile ? 'off' : 'on', folding: !mobile, wordWrap: mobile ? 'on' : 'off', glyphMargin: false, lineDecorationsWidth: mobile ? 4 : 10 }}55 />56 );57}5859const diffRefs = new Map<string, number>();6061export function DiffView({ original, modified, modelPath, mobile }: { original: string; modified: string; modelPath: string; mobile?: boolean }) {62 const theme = useTheme((t) => t.theme);63 // The DiffEditor keeps its models (disposing them while the widget is alive throws); they are64 // released shortly after the last view of this diff unmounts.65 useEffect(() => {66 diffRefs.set(modelPath, (diffRefs.get(modelPath) ?? 0) + 1);67 return () => {68 diffRefs.set(modelPath, (diffRefs.get(modelPath) ?? 1) - 1);69 setTimeout(() => {70 if ((diffRefs.get(modelPath) ?? 0) > 0) return;71 diffRefs.delete(modelPath);72 for (const side of ['a', 'b']) monaco.editor.getModel(monaco.Uri.parse(`inmemory://cr-diff/${side}/${modelPath}`))?.dispose();73 }, 250);74 };75 }, [modelPath]);76 return (77 <DiffEditor78 height="100%"79 theme={theme === 'dark' ? 'cr-dark' : 'cr-light'}80 original={original}81 modified={modified}82 originalModelPath={`inmemory://cr-diff/a/${modelPath}`}83 modifiedModelPath={`inmemory://cr-diff/b/${modelPath}`}84 keepCurrentOriginalModel85 keepCurrentModifiedModel86 loading={Loading}87 options={{ ...common, readOnly: true, renderSideBySide: !mobile, originalEditable: false, renderOverviewRuler: false }}88 />89 );90}91