| 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; |
| |
| 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<EditorPaneHandle, Props>( |
| 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<ReactCodeMirrorRef>(null); |
| const editorThemeId = usePreferencesStore((s) => s.editorTheme); |
| const vimMode = usePreferencesStore((s) => s.vimMode); |
| const languageRef = useRef<string | null>(null); |
| const apiKeyRef = useRef<string | null>(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; |
|
|
| |
| |
| |
| 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( |
| () => [ |
| |
| |
| 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 ( |
| <div className="flex h-full items-center justify-center text-xs text-muted-foreground"> |
| Loading… |
| </div> |
| ); |
| } |
| if (doc.status === "error") { |
| return ( |
| <div className="flex h-full items-center justify-center px-6 text-center text-xs text-destructive"> |
| {doc.message} |
| </div> |
| ); |
| } |
| if (doc.status === "binary") { |
| return ( |
| <div className="flex h-full flex-col items-center justify-center gap-1 px-6 text-center"> |
| <div className="text-sm text-foreground">Binary file</div> |
| <div className="text-xs text-muted-foreground"> |
| {formatBytes(doc.size)} · preview not supported |
| </div> |
| </div> |
| ); |
| } |
| if (doc.status === "toolarge") { |
| return ( |
| <div className="flex h-full flex-col items-center justify-center gap-1 px-6 text-center"> |
| <div className="text-sm text-foreground">File too large</div> |
| <div className="text-xs text-muted-foreground"> |
| {formatBytes(doc.size)} exceeds the {formatBytes(doc.limit)} limit. |
| </div> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="flex h-full min-h-0 flex-col"> |
| <CodeMirror |
| ref={cmRef} |
| value={doc.content} |
| onChange={onChange} |
| theme={themeExt} |
| extensions={extensions} |
| height="100%" |
| className="flex-1 min-h-0 overflow-hidden" |
| basicSetup={{ |
| lineNumbers: true, |
| highlightActiveLineGutter: true, |
| foldGutter: true, |
| bracketMatching: true, |
| closeBrackets: true, |
| autocompletion: true, |
| highlightActiveLine: true, |
| highlightSelectionMatches: true, |
| searchKeymap: true, |
| }} |
| /> |
| </div> |
| ); |
| }, |
| ); |
|
|