192 lines
5.5 KiB
TypeScript
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>
|
|
);
|
|
};
|