'use client'; import { useEffect, useRef, useState } from 'react'; import dynamic from 'next/dynamic'; import { useTheme } from 'next-themes'; import { Button, Switch } from '@spoon/ui'; import type { MonacoLike } from './monaco-theme'; import { languageForPath } from './languages'; import { configureSpoonMonaco, remeasureFontsWhenReady, SPOON_DARK, SPOON_LIGHT, } from './monaco-theme'; const MonacoEditor = dynamic(async () => await import('@monaco-editor/react'), { ssr: false, }); const EDITOR_FONT_FAMILY = "var(--font-victor-mono), 'Symbols Nerd Font Mono', 'Geist Mono', ui-monospace, SFMono-Regular, monospace"; type MonacoEditorInstance = { getModel?: () => unknown; dispose?: () => void; }; type VimMode = { dispose: () => void; }; export const CodeEditor = ({ path, content, savedContent, readOnly, vimEnabled, conflicted, onSave, onChange, onVimEnabledChange, }: { path?: string; content: string; savedContent: string; readOnly: boolean; vimEnabled: boolean; conflicted?: boolean; onSave: (content: string) => Promise; onChange: (content: string) => void; onVimEnabledChange: (enabled: boolean) => void; }) => { const [saving, setSaving] = useState(false); const editorRef = useRef(null); const vimRef = useRef(null); const statusRef = useRef(null); const { resolvedTheme } = useTheme(); const editorTheme = resolvedTheme === 'light' ? SPOON_LIGHT : SPOON_DARK; // Dispose the Monaco instance and clear the ref on unmount so a remount // (e.g. the Phase-1 key={jobId} swap) doesn't leave a stale editor/vim // binding leaked behind the new mount. useEffect(() => { return () => { vimRef.current?.dispose(); vimRef.current = null; editorRef.current?.dispose?.(); editorRef.current = null; }; }, []); useEffect(() => { const editor = editorRef.current; if (!editor) return; vimRef.current?.dispose(); vimRef.current = null; if (!vimEnabled) return; void import('monaco-vim').then((module) => { const initVimMode = module.initVimMode as unknown as ( editor: MonacoEditorInstance, statusNode?: HTMLElement | null, ) => VimMode; vimRef.current = initVimMode(editor, statusRef.current); }); return () => { vimRef.current?.dispose(); vimRef.current = null; }; }, [vimEnabled, path]); if (!path) { return (
Select a file to inspect or edit.
); } const save = async () => { setSaving(true); try { await onSave(content); } finally { setSaving(false); } }; const dirty = content !== savedContent; return (

Editor

{path}

{conflicted ? (

This file changed on disk — your unsaved edits may conflict.

) : dirty ? (

Unsaved changes

) : null}
{ configureSpoonMonaco(monaco as unknown as MonacoLike); }} options={{ readOnly, minimap: { enabled: false }, fontFamily: EDITOR_FONT_FAMILY, fontLigatures: true, fontSize: 13, lineHeight: 1.6, scrollBeyondLastLine: false, wordWrap: 'on', automaticLayout: true, smoothScrolling: true, cursorSmoothCaretAnimation: 'on', padding: { top: 12, bottom: 12 }, scrollbar: { alwaysConsumeMouseWheel: false }, quickSuggestions: true, suggestOnTriggerCharacters: true, tabCompletion: 'on', wordBasedSuggestions: 'matchingDocuments', bracketPairColorization: { enabled: true }, renderWhitespace: 'selection', }} onMount={(editor, monaco) => { editorRef.current = editor as MonacoEditorInstance; remeasureFontsWhenReady(monaco as unknown as MonacoLike); }} onChange={(next) => { const nextValue = next ?? ''; onChange(nextValue); }} />
); };