0ca00c2aca
Co-authored-by: Kandev Agent <agent@kandev.dev>
238 lines
7.3 KiB
TypeScript
238 lines
7.3 KiB
TypeScript
"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 (
|
|
<div className="h-full w-full flex items-center justify-center text-muted-foreground text-xs border rounded-md bg-muted/20">
|
|
{t("executors:loadingEditor")}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const MonacoEditor = dynamic(() => import("@monaco-editor/react").then((m) => m.default), {
|
|
ssr: false,
|
|
loading: () => <EditorLoading />,
|
|
});
|
|
|
|
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<string, { dispose: () => void }> = new Map();
|
|
const instanceCountsByLanguage: Map<string, number> = 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<Monaco | null>(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 (
|
|
<MonacoEditor
|
|
height={height}
|
|
language={language}
|
|
value={value}
|
|
onChange={(v) => 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);
|
|
}
|