"use client"; import { useEffect, useRef, useCallback } from "react"; import dynamic from "@/lib/routing/client-dynamic"; import type { BeforeMount, OnMount } from "@monaco-editor/react"; import { KANDEV_MONACO_DARK } from "@/lib/theme/editor-theme"; import type { PromptReference } from "@/lib/prompts/expand-prompt-references"; import { createPlaceholderCompletionProvider, createPromptMentionCompletionProvider, type ScriptPlaceholder, } from "./script-editor-completions"; import { useTranslation } from "react-i18next"; // A component rather than an inline literal so the label resolves at render: // `dynamic()` is evaluated at module load, where a `t()` would freeze the copy // at the boot locale and never follow a locale switch. function EditorLoading() { const { t } = useTranslation(); return (
{t("executors:loadingEditor")}
); } const MonacoEditor = dynamic(() => import("@monaco-editor/react").then((m) => m.default), { ssr: false, loading: () => , }); type Monaco = typeof import("monaco-editor"); export type CompletionProviderFactory = ( monaco: Monaco, ) => import("monaco-editor").languages.CompletionItemProvider; // Per-language, per-slot singletons so a markdown editor and a shell editor // can coexist without their providers fighting over the same global slot. // "primary" holds the placeholder/custom completion provider; "mention" // holds the @prompt-name mention provider — both can be registered at once. type ProviderSlot = "primary" | "mention"; const disposablesByKey: Map void }> = new Map(); const instanceCountsByLanguage: Map = new Map(); function slotKey(language: string, slot: ProviderSlot) { return `${language}:${slot}`; } function disposeFor(language: string, slot: ProviderSlot) { const key = slotKey(language, slot); const existing = disposablesByKey.get(key); if (existing) { existing.dispose(); disposablesByKey.delete(key); } } function swapProvider( monaco: Monaco, language: string, slot: ProviderSlot, factory: CompletionProviderFactory, ) { disposeFor(language, slot); disposablesByKey.set( slotKey(language, slot), monaco.languages.registerCompletionItemProvider(language, factory(monaco)), ); } function unregisterProvider(language: string) { const next = (instanceCountsByLanguage.get(language) ?? 1) - 1; if (next <= 0) { disposeFor(language, "primary"); disposeFor(language, "mention"); instanceCountsByLanguage.delete(language); return; } instanceCountsByLanguage.set(language, next); } /** Compute editor height from content lines (min 80px, max 400px). */ export function computeEditorHeight(value: string, minLines = 3): string { const lineCount = Math.max((value || "").split("\n").length, minLines); const lineHeight = 19; const padding = 16; const height = Math.min(Math.max(lineCount * lineHeight + padding, 80), 400); return `${height}px`; } type ScriptEditorProps = { value: string; onChange: (value: string) => void; language?: string; height?: string | number; placeholders?: ScriptPlaceholder[]; executorType?: string; /** * Optional custom completion provider factory. Takes precedence over * `placeholders` — use for non-placeholder languages (e.g. markdown * file-path autocomplete). */ completionProvider?: CompletionProviderFactory; /** * Saved custom prompts to suggest as `@name` mentions. Registered * alongside the placeholder/custom completion provider (independent * trigger character, so both can be active at once). */ mentionPrompts?: PromptReference[]; readOnly?: boolean; lineNumbers?: "on" | "off"; }; export function ScriptEditor({ value, onChange, language = "shell", height = "300px", placeholders, executorType, completionProvider, mentionPrompts, readOnly = false, lineNumbers = "on", }: ScriptEditorProps) { const mountedRef = useRef(false); const monacoRef = useRef(null); useEffect(() => { return () => { if (mountedRef.current) { unregisterProvider(language); mountedRef.current = false; } }; }, [language]); const ensureProviderRegistered = useCallback( (monaco: Monaco) => { const factory = resolveFactory(completionProvider, placeholders, executorType); const mentionFactory = resolveMentionFactory(mentionPrompts); if (!factory && !mentionFactory) { // Nothing to register. If a previous render had registered providers // for this instance, dispose them now so stale suggestions (e.g. a // deleted prompt list) don't linger while the editor stays mounted. if (mountedRef.current) { disposeFor(language, "primary"); disposeFor(language, "mention"); } return; } if (!mountedRef.current) { mountedRef.current = true; instanceCountsByLanguage.set(language, (instanceCountsByLanguage.get(language) ?? 0) + 1); } if (factory) { swapProvider(monaco, language, "primary", factory); } else { disposeFor(language, "primary"); } if (mentionFactory) { swapProvider(monaco, language, "mention", mentionFactory); } else { disposeFor(language, "mention"); } }, [completionProvider, placeholders, executorType, mentionPrompts, language], ); useEffect(() => { if (monacoRef.current) ensureProviderRegistered(monacoRef.current); }, [ensureProviderRegistered]); const handleBeforeMount: BeforeMount = useCallback((monaco) => { monaco.editor.defineTheme("kandev-dark", KANDEV_MONACO_DARK); }, []); const handleMount: OnMount = useCallback( (_editor, monaco) => { monacoRef.current = monaco; ensureProviderRegistered(monaco); }, [ensureProviderRegistered], ); return ( onChange(v ?? "")} beforeMount={handleBeforeMount} onMount={handleMount} theme="kandev-dark" options={{ minimap: { enabled: false }, lineNumbers, wordWrap: "on", fontSize: 13, scrollBeyondLastLine: false, readOnly, bracketPairColorization: { enabled: true }, padding: { top: 8, bottom: 8 }, renderLineHighlight: "none", overviewRulerLanes: 0, fixedOverflowWidgets: true, wordBasedSuggestions: "off", scrollbar: { vertical: "auto", horizontal: "auto", alwaysConsumeMouseWheel: false, }, }} /> ); } function resolveFactory( custom: CompletionProviderFactory | undefined, placeholders: ScriptPlaceholder[] | undefined, executorType: string | undefined, ): CompletionProviderFactory | null { if (custom) return custom; if (placeholders && placeholders.length > 0) { return (monaco) => createPlaceholderCompletionProvider(monaco, placeholders, executorType); } return null; } function resolveMentionFactory( mentionPrompts: PromptReference[] | undefined, ): CompletionProviderFactory | null { if (!mentionPrompts || mentionPrompts.length === 0) return null; return (monaco) => createPromptMentionCompletionProvider(monaco, mentionPrompts); }