Files
spoon/apps/next/src/components/agent-workspace/code-editor.tsx
T

192 lines
5.5 KiB
TypeScript

'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<void>;
onChange: (content: string) => void;
onVimEnabledChange: (enabled: boolean) => void;
}) => {
const [saving, setSaving] = useState(false);
const editorRef = useRef<MonacoEditorInstance | null>(null);
const vimRef = useRef<VimMode | null>(null);
const statusRef = useRef<HTMLDivElement | null>(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 (
<div className='text-muted-foreground flex h-full items-center justify-center text-sm'>
Select a file to inspect or edit.
</div>
);
}
const save = async () => {
setSaving(true);
try {
await onSave(content);
} finally {
setSaving(false);
}
};
const dirty = content !== savedContent;
return (
<div className='flex h-full min-h-0 flex-col'>
<div className='border-border flex h-14 items-center justify-between gap-3 border-b px-3'>
<div className='min-w-0'>
<p className='text-muted-foreground text-[11px] font-medium tracking-wide uppercase'>
Editor
</p>
<p className='truncate font-mono text-xs'>{path}</p>
{conflicted ? (
<p className='text-amber-500 text-xs'>
This file changed on disk your unsaved edits may conflict.
</p>
) : dirty ? (
<p className='text-muted-foreground text-xs'>Unsaved changes</p>
) : null}
</div>
<div className='flex items-center gap-3'>
<label className='flex items-center gap-2 text-xs'>
Vim
<Switch checked={vimEnabled} onCheckedChange={onVimEnabledChange} />
</label>
<Button
type='button'
size='sm'
disabled={readOnly || saving || !dirty}
onClick={save}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
<div className='min-h-0 flex-1'>
<MonacoEditor
height='100%'
width='100%'
path={path}
language={languageForPath(path)}
value={content}
theme={editorTheme}
beforeMount={(monaco) => {
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);
}}
/>
</div>
<div
ref={statusRef}
className='border-border text-muted-foreground h-6 border-t px-3 py-1 font-mono text-xs'
/>
</div>
);
};