mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-15 00:17:27 +00:00
fix(hooks/ui): native script editor + redesigned detail + error toast
User report: hook detail page ugly, script not syntax-highlighted, edit
dialog crashes silently with no toast.
- Replace react-simple-code-editor (React 19 compat issues — likely cause
of the silent edit-dialog crash) with a native textarea + <pre> overlay.
Same UX: caret over highlighted source, scroll-synced. Drops the dep
entirely and removes a render-path black box. Bundle size went DOWN
(62 KB → was 65 KB before Phase 06; net ~+1 KB for Prism core).
- Import prismjs/themes/prism-tomorrow.css so token colors actually show.
Editor surface uses #2d2d2d to match the Tomorrow theme palette.
- Harden HookFormDialog reset against null `initial.config` — previous
cast threw when the row was inserted with no config payload.
- Redesign HookOverviewTab:
- Header card with event/handler/scope chips + status pill
- Metadata grid (timeout/on_timeout/priority/source/version/timestamps)
- Handler-specific cards: script source rendered via the read-only
ScriptEditor (full Prism highlight), HTTP shows url/method/body,
prompt shows template + model. No more raw JSON dump.
- Extract HttpConfigCard / PromptConfigCard subcomponents to keep
types narrow at the unknown→string boundary.
- ErrorBoundary now toast.error()s when it catches — user sees the
failure visually instead of just the generic fallback card. Full stack
still logs to console for devs.
This commit is contained in:
1 parent
f3d6c99dda
commit
7096a8e02c
6 files changed
+259
-83
No files matched your search
@@ -45,7 +45,6 @@
|
||||
"react-i18next": "^17.0.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router": "^7.13.2",
|
||||
"react-simple-code-editor": "^0.14.1",
|
||||
"recharts": "^3.8.1",
|
||||
"rehype-highlight": "^7.0.2",
|
||||
"rehype-katex": "^7.0.1",
|
||||
|
||||
Generated
+3
-14
@@ -4,6 +4,9 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
prismjs: ^1.30.0
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -101,9 +104,6 @@ importers:
|
||||
react-router:
|
||||
specifier: ^7.13.2
|
||||
version: 7.13.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
react-simple-code-editor:
|
||||
specifier: ^0.14.1
|
||||
version: 0.14.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
recharts:
|
||||
specifier: ^3.8.1
|
||||
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@19.2.4)(react@19.2.4)(redux@5.0.1)
|
||||
@@ -2915,12 +2915,6 @@ packages:
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
react-simple-code-editor@0.14.1:
|
||||
resolution: {integrity: sha512-BR5DtNRy+AswWJECyA17qhUDvrrCZ6zXOCfkQY5zSmb96BVUbpVAv03WpcjcwtCwiLbIANx3gebHOcXYn1EHow==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6525,11 +6519,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
react-simple-code-editor@0.14.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
react: 19.2.4
|
||||
react-dom: 19.2.4(react@19.2.4)
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.4):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Component, Fragment, type ErrorInfo, type ReactNode } from "react";
|
||||
import { AlertTriangle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import i18n from "@/i18n";
|
||||
import { toast } from "@/stores/use-toast-store";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -22,6 +23,11 @@ export class ErrorBoundary extends Component<Props, State> {
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("[ErrorBoundary]", error, info.componentStack);
|
||||
// Surface the error name + first line of message via toast so users see a
|
||||
// visual indicator instead of just the fallback card. Full stack stays in
|
||||
// console for devs; toast keeps it short to avoid wall-of-text.
|
||||
const msg = (error.message || "Unknown error").split("\n")[0]?.slice(0, 200);
|
||||
toast.error(error.name || "Render error", msg);
|
||||
}
|
||||
|
||||
private handleRetry = () => {
|
||||
|
||||
@@ -64,7 +64,10 @@ export function HookFormDialog({ open, onOpenChange, onSubmit, initial }: HookFo
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (initial) {
|
||||
const cfg = initial.config as Record<string, unknown>;
|
||||
// Defensive: backend may emit config as null when a row was inserted
|
||||
// without a config payload. Treat null/undefined as empty so the
|
||||
// property reads below don't crash the edit dialog.
|
||||
const cfg = (initial.config ?? {}) as Record<string, unknown>;
|
||||
// Legacy `command` rows coerce to `http` in the form — user cannot save as
|
||||
// `command` (enum narrowed post-Wave-1). Phase 07 auto-disables them regardless.
|
||||
const handlerType: HookFormData["handler_type"] =
|
||||
|
||||
@@ -1,79 +1,178 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { HookConfig } from "@/hooks/use-hooks";
|
||||
import { ScriptEditor } from "./script-editor";
|
||||
import { SystemBadge } from "./system-badge";
|
||||
|
||||
interface HookOverviewTabProps {
|
||||
hook: HookConfig;
|
||||
}
|
||||
|
||||
function InfoRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
const HANDLER_COLORS: Record<string, string> = {
|
||||
command: "bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300",
|
||||
http: "bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300",
|
||||
prompt: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300",
|
||||
script: "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300",
|
||||
};
|
||||
|
||||
const EVENT_COLORS: Record<string, string> = {
|
||||
session_start: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300",
|
||||
user_prompt_submit: "bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-300",
|
||||
pre_tool_use: "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300",
|
||||
post_tool_use: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300",
|
||||
stop: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300",
|
||||
subagent_start: "bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-300",
|
||||
subagent_stop: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300",
|
||||
};
|
||||
|
||||
function MetaItem({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-start gap-3 py-2 border-b last:border-b-0">
|
||||
<span className="w-36 shrink-0 text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-xs">{children}</span>
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{label}</p>
|
||||
<div className="text-sm">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HookOverviewTab({ hook }: HookOverviewTabProps) {
|
||||
const { t } = useTranslation("hooks");
|
||||
const cfg = hook.config as Record<string, unknown> | undefined;
|
||||
const scriptSource = hook.handler_type === "script" ? ((cfg?.source as string) ?? "") : "";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<InfoRow label={t("table.event")}>
|
||||
<span className="font-mono">{hook.event}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label={t("table.type")}>
|
||||
<Badge variant="outline">{hook.handler_type}</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label={t("table.scope")}>
|
||||
<div className="space-y-6">
|
||||
{/* Header card — primary identity + status */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${EVENT_COLORS[hook.event] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{hook.event}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-2 py-0.5 text-xs font-medium ${HANDLER_COLORS[hook.handler_type] ?? "bg-muted text-muted-foreground"}`}
|
||||
>
|
||||
{hook.handler_type}
|
||||
</span>
|
||||
<Badge variant="outline">{hook.scope}</Badge>
|
||||
</InfoRow>
|
||||
{hook.source === "builtin" && <SystemBadge />}
|
||||
<div className="flex-1" />
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
||||
hook.enabled
|
||||
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
: "bg-muted text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${hook.enabled ? "bg-emerald-500" : "bg-muted-foreground"}`} />
|
||||
{hook.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
{hook.matcher && (
|
||||
<InfoRow label={t("table.matcher")}>
|
||||
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">{hook.matcher}</code>
|
||||
</InfoRow>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("table.matcher")}</p>
|
||||
<code className="block rounded bg-muted px-2 py-1 text-xs font-mono">{hook.matcher}</code>
|
||||
</div>
|
||||
)}
|
||||
{hook.if_expr && (
|
||||
<InfoRow label={t("form.ifExpr")}>
|
||||
<code className="font-mono text-xs bg-muted px-1.5 py-0.5 rounded">{hook.if_expr}</code>
|
||||
</InfoRow>
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.ifExpr")}</p>
|
||||
<code className="block rounded bg-muted px-2 py-1 text-xs font-mono">{hook.if_expr}</code>
|
||||
</div>
|
||||
)}
|
||||
<InfoRow label={t("form.timeout")}>
|
||||
<span className="font-mono">{hook.timeout_ms}ms</span>
|
||||
{" · "}
|
||||
<span className="text-muted-foreground">{t("form.onTimeout")}: {hook.on_timeout}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label={t("form.priority")}>
|
||||
<span className="font-mono">{hook.priority}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label={t("table.enabled")}>
|
||||
<span className={hook.enabled ? "text-emerald-600" : "text-muted-foreground"}>
|
||||
{hook.enabled ? "Yes" : "No"}
|
||||
</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Source">
|
||||
<Badge variant="secondary">{hook.source}</Badge>
|
||||
</InfoRow>
|
||||
<InfoRow label="Version">
|
||||
<span className="font-mono">v{hook.version}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Created">
|
||||
<span>{new Date(hook.created_at).toLocaleString()}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Updated">
|
||||
<span>{new Date(hook.updated_at).toLocaleString()}</span>
|
||||
</InfoRow>
|
||||
</div>
|
||||
|
||||
{/* Config preview */}
|
||||
{hook.config && Object.keys(hook.config).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("tabs.config")}</p>
|
||||
<pre className="overflow-x-auto rounded border bg-muted/40 p-3 text-xs font-mono">
|
||||
{JSON.stringify(hook.config, null, 2)}
|
||||
</pre>
|
||||
{/* Metadata grid */}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<MetaItem label={t("form.timeout")}>
|
||||
<span className="font-mono">{hook.timeout_ms}ms</span>
|
||||
</MetaItem>
|
||||
<MetaItem label={t("form.onTimeout")}>
|
||||
<Badge variant="outline" className="capitalize">{hook.on_timeout}</Badge>
|
||||
</MetaItem>
|
||||
<MetaItem label={t("form.priority")}>
|
||||
<span className="font-mono">{hook.priority}</span>
|
||||
</MetaItem>
|
||||
<MetaItem label="Source">
|
||||
<Badge variant="secondary" className="capitalize">{hook.source}</Badge>
|
||||
</MetaItem>
|
||||
<MetaItem label="Version">
|
||||
<span className="font-mono">v{hook.version}</span>
|
||||
</MetaItem>
|
||||
<MetaItem label="Created">
|
||||
<span className="text-muted-foreground">{new Date(hook.created_at).toLocaleString()}</span>
|
||||
</MetaItem>
|
||||
<MetaItem label="Updated">
|
||||
<span className="text-muted-foreground">{new Date(hook.updated_at).toLocaleString()}</span>
|
||||
</MetaItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Handler-specific content */}
|
||||
{hook.handler_type === "script" && scriptSource && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("form.scriptSource")}</p>
|
||||
<ScriptEditor value={scriptSource} onChange={() => {}} readOnly minLines={Math.min(20, scriptSource.split("\n").length + 1)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hook.handler_type === "http" && cfg && <HttpConfigCard cfg={cfg} t={t} />}
|
||||
{hook.handler_type === "prompt" && cfg && <PromptConfigCard cfg={cfg} t={t} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Narrow Record<string, unknown> values to strings before rendering. JSX
|
||||
// won't accept `unknown` directly even when the surrounding `&&` proves
|
||||
// truthiness, so we extract typed locals at the boundary.
|
||||
type TFn = (key: string) => string;
|
||||
|
||||
function HttpConfigCard({ cfg, t }: { cfg: Record<string, unknown>; t: TFn }) {
|
||||
const url = typeof cfg.url === "string" ? cfg.url : "";
|
||||
const method = typeof cfg.method === "string" ? cfg.method : "";
|
||||
const bodyTemplate = typeof cfg.body_template === "string" ? cfg.body_template : "";
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("tabs.config")}</p>
|
||||
{url && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.url")}</p>
|
||||
<code className="block rounded bg-muted px-2 py-1 text-xs font-mono break-all">{url}</code>
|
||||
</div>
|
||||
)}
|
||||
{method && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.method")}</p>
|
||||
<Badge variant="outline" className="font-mono">{method}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{bodyTemplate && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.bodyTemplate")}</p>
|
||||
<pre className="overflow-x-auto rounded bg-muted px-2 py-1 text-xs font-mono">{bodyTemplate}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PromptConfigCard({ cfg, t }: { cfg: Record<string, unknown>; t: TFn }) {
|
||||
const promptTemplate = typeof cfg.prompt_template === "string" ? cfg.prompt_template : "";
|
||||
const model = typeof cfg.model === "string" ? cfg.model : "";
|
||||
return (
|
||||
<div className="rounded-lg border bg-card p-4 space-y-3">
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("tabs.config")}</p>
|
||||
{promptTemplate && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.promptTemplate")}</p>
|
||||
<pre className="overflow-x-auto rounded bg-muted px-2 py-1 text-xs whitespace-pre-wrap">{promptTemplate}</pre>
|
||||
</div>
|
||||
)}
|
||||
{model && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-2xs uppercase tracking-wide text-muted-foreground">{t("form.model")}</p>
|
||||
<Badge variant="outline">{model}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMemo } from "react";
|
||||
import Editor from "react-simple-code-editor";
|
||||
import { useId, useMemo, useRef, type ChangeEvent, type UIEvent } from "react";
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/components/prism-javascript";
|
||||
// Tomorrow theme matches the dark editor surface defined below; tokens get
|
||||
// proper color without requiring app-wide CSS coordination.
|
||||
import "prismjs/themes/prism-tomorrow.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface ScriptEditorProps {
|
||||
@@ -13,6 +15,7 @@ interface ScriptEditorProps {
|
||||
// string unchanged.
|
||||
error?: string;
|
||||
readOnly?: boolean;
|
||||
minLines?: number;
|
||||
}
|
||||
|
||||
// parseLineCol extracts {line, col} from a goja compile-error string. Format
|
||||
@@ -25,26 +28,96 @@ export function parseLineCol(err?: string): { line: number; col: number } | null
|
||||
return { line: parseInt(m[1], 10), col: parseInt(m[2], 10) };
|
||||
}
|
||||
|
||||
export function ScriptEditor({ value, onChange, error, readOnly }: ScriptEditorProps) {
|
||||
// Minimal native overlay editor: a textarea sits on top of a <pre> showing
|
||||
// the highlighted source. Keeping this in-house instead of pulling in
|
||||
// react-simple-code-editor avoids a React 19 compat surface and lets the
|
||||
// component lazy-create Prism grammar without crashing when the language
|
||||
// hasn't loaded yet.
|
||||
export function ScriptEditor({
|
||||
value,
|
||||
onChange,
|
||||
error,
|
||||
readOnly,
|
||||
minLines = 12,
|
||||
}: ScriptEditorProps) {
|
||||
const { t } = useTranslation("hooks");
|
||||
const id = useId();
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const taRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const loc = useMemo(() => parseLineCol(error), [error]);
|
||||
|
||||
const safeValue = value ?? "";
|
||||
// Keep a trailing newline so the highlighted layer matches textarea height
|
||||
// when the file ends without one (avoids the last line getting clipped).
|
||||
const displayValue =
|
||||
safeValue.length === 0 || safeValue.endsWith("\n") ? safeValue : safeValue + "\n";
|
||||
|
||||
const highlighted = useMemo(() => {
|
||||
const lang = Prism.languages.javascript;
|
||||
if (!lang) return escapeHtml(displayValue);
|
||||
try {
|
||||
return Prism.highlight(displayValue, lang, "javascript");
|
||||
} catch {
|
||||
return escapeHtml(displayValue);
|
||||
}
|
||||
}, [displayValue]);
|
||||
|
||||
const handleScroll = (e: UIEvent<HTMLTextAreaElement>) => {
|
||||
if (!preRef.current) return;
|
||||
preRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
preRef.current.scrollLeft = e.currentTarget.scrollLeft;
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!readOnly) onChange(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-sm font-medium">{t("form.scriptSource")}</label>
|
||||
<div className="rounded-lg border bg-muted/30 overflow-hidden">
|
||||
<Editor
|
||||
value={value}
|
||||
onValueChange={readOnly ? () => {} : onChange}
|
||||
highlight={(code) => {
|
||||
const lang = Prism.languages.javascript;
|
||||
return lang ? Prism.highlight(code, lang, "javascript") : code;
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{t("form.scriptSource")}
|
||||
</label>
|
||||
<div
|
||||
className="relative rounded-lg border bg-[#2d2d2d] overflow-hidden"
|
||||
style={{ minHeight: `${minLines * 1.5}em` }}
|
||||
>
|
||||
<pre
|
||||
ref={preRef}
|
||||
aria-hidden="true"
|
||||
className="m-0 overflow-auto p-3 text-xs leading-6 font-mono pointer-events-none"
|
||||
style={{
|
||||
color: "#ccc",
|
||||
background: "transparent",
|
||||
whiteSpace: "pre",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
}}
|
||||
padding={12}
|
||||
textareaClassName="outline-none"
|
||||
>
|
||||
<code
|
||||
className="language-javascript"
|
||||
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||
/>
|
||||
</pre>
|
||||
<textarea
|
||||
ref={taRef}
|
||||
id={id}
|
||||
value={safeValue}
|
||||
onChange={handleChange}
|
||||
onScroll={handleScroll}
|
||||
readOnly={readOnly}
|
||||
className="text-base md:text-sm font-mono min-h-[240px] leading-relaxed"
|
||||
style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
className="absolute inset-0 w-full h-full resize-none border-0 bg-transparent p-3 text-xs leading-6 font-mono outline-none"
|
||||
style={{
|
||||
color: "transparent",
|
||||
caretColor: "#ccc",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
whiteSpace: "pre",
|
||||
overflow: "auto",
|
||||
}}
|
||||
placeholder="function handle(event) {\n return { decision: 'allow' };\n}"
|
||||
/>
|
||||
</div>
|
||||
{error ? (
|
||||
@@ -59,3 +132,10 @@ export function ScriptEditor({ value, onChange, error, readOnly }: ScriptEditorP
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
Reference in new issue
Block a user