import { findNext, findPrevious, SearchQuery, setSearchQuery, } from "@codemirror/search"; import { keymap } from "@codemirror/view"; import { usePreferencesStore } from "@/modules/settings/preferences"; import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; import { EDITOR_THEME_EXT } from "./lib/themes"; import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, } from "react"; import { Prec } from "@codemirror/state"; import { vim } from "@replit/codemirror-vim"; import { buildSharedExtensions, languageCompartment, vimCompartment, } from "./lib/extensions"; import { initVimGlobals, vimHandlersExtension } from "./lib/vim"; initVimGlobals(); import { resolveLanguage } from "./lib/languageResolver"; import { useDocument } from "./lib/useDocument"; import { inlineCompletion } from "./lib/autocomplete/inlineExtension"; import { getKey } from "@/modules/ai/lib/keyring"; import { onKeysChanged } from "@/modules/settings/store"; export type EditorPaneHandle = { setQuery: (q: string) => void; findNext: () => void; findPrevious: () => void; clearQuery: () => void; focus: () => void; getSelection: () => string | null; getPath: () => string; /** Re-read the file from disk. Skips silently if the buffer is dirty. */ reload: () => boolean; }; type Props = { path: string; onDirtyChange?: (dirty: boolean) => void; onSaved?: () => void; onClose?: () => void; }; function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; return `${(n / 1024 / 1024).toFixed(1)} MB`; } export const EditorPane = forwardRef( function EditorPane({ path, onDirtyChange, onSaved, onClose }, ref) { const { doc, onChange, save, reload } = useDocument({ path, onDirtyChange }); const reloadRef = useRef(reload); reloadRef.current = reload; const cmRef = useRef(null); const editorThemeId = usePreferencesStore((s) => s.editorTheme); const vimMode = usePreferencesStore((s) => s.vimMode); const languageRef = useRef(null); const apiKeyRef = useRef(null); useEffect(() => { let cancelled = false; const refresh = async () => { const provider = usePreferencesStore.getState().autocompleteProvider; if (provider === "lmstudio") { apiKeyRef.current = null; return; } const k = await getKey(provider); if (!cancelled) apiKeyRef.current = k; }; void refresh(); let unlistenKeys: (() => void) | undefined; void onKeysChanged(() => void refresh()).then((un) => { unlistenKeys = un; }); const unsubPrefs = usePreferencesStore.subscribe((state, prev) => { if (state.autocompleteProvider !== prev.autocompleteProvider) { void refresh(); } }); return () => { cancelled = true; unlistenKeys?.(); unsubPrefs(); }; }, []); const themeExt = EDITOR_THEME_EXT[editorThemeId] ?? EDITOR_THEME_EXT.atomone; // Stabilize save + onSaved via refs so the extensions array never changes // identity — a new identity makes @uiw/react-codemirror reconfigure the // whole state, wiping the language compartment. const saveRef = useRef(save); saveRef.current = save; const onSavedRef = useRef(onSaved); onSavedRef.current = onSaved; const onCloseRef = useRef(onClose); onCloseRef.current = onClose; const pathRef = useRef(path); pathRef.current = path; const extensions = useMemo( () => [ // basicSetup is added before user extensions by @uiw/react-codemirror, // so we must elevate vim's precedence to win the keymap. vimCompartment.of( usePreferencesStore.getState().vimMode ? Prec.highest(vim()) : [], ), vimHandlersExtension(() => ({ save: () => { void (async () => { await saveRef.current(); onSavedRef.current?.(); })(); }, close: () => onCloseRef.current?.(), })), ...buildSharedExtensions(), languageCompartment.of([]), inlineCompletion({ getPrefs: () => { const s = usePreferencesStore.getState(); return { enabled: s.autocompleteEnabled, provider: s.autocompleteProvider, modelId: s.autocompleteModelId, apiKey: apiKeyRef.current, lmstudioBaseURL: s.lmstudioBaseURL, }; }, getPath: () => pathRef.current, getLanguage: () => languageRef.current, }), keymap.of([ { key: "Mod-s", preventDefault: true, run: () => { void (async () => { await saveRef.current(); onSavedRef.current?.(); })(); return true; }, }, ]), ], [], ); useEffect(() => { const view = cmRef.current?.view; if (!view) return; view.dispatch({ effects: vimCompartment.reconfigure( vimMode ? Prec.highest(vim()) : [], ), }); }, [vimMode]); useEffect(() => { let cancelled = false; const ext = path.split(".").pop()?.toLowerCase() ?? null; languageRef.current = ext; resolveLanguage(path).then((ext) => { if (cancelled) return; const view = cmRef.current?.view; if (!view) return; view.dispatch({ effects: languageCompartment.reconfigure(ext ?? []), }); }); return () => { cancelled = true; }; }, [path, doc.status]); useImperativeHandle( ref, () => ({ setQuery: (q: string) => { const view = cmRef.current?.view; if (!view) return; view.dispatch({ effects: setSearchQuery.of( new SearchQuery({ search: q, caseSensitive: false }), ), }); if (q) findNext(view); }, findNext: () => { const view = cmRef.current?.view; if (view) findNext(view); }, findPrevious: () => { const view = cmRef.current?.view; if (view) findPrevious(view); }, clearQuery: () => { const view = cmRef.current?.view; if (!view) return; view.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: "" })), }); }, focus: () => { cmRef.current?.view?.focus(); }, getSelection: () => { const view = cmRef.current?.view; if (!view) return null; const { from, to } = view.state.selection.main; if (from === to) return null; return view.state.sliceDoc(from, to); }, getPath: () => path, reload: () => reloadRef.current(), }), [path], ); if (doc.status === "loading") { return (
Loading…
); } if (doc.status === "error") { return (
{doc.message}
); } if (doc.status === "binary") { return (
Binary file
{formatBytes(doc.size)} · preview not supported
); } if (doc.status === "toolarge") { return (
File too large
{formatBytes(doc.size)} exceeds the {formatBytes(doc.limit)} limit.
); } return (
); }, );