import "./ScriptsModal.css"; import { useState, useEffect, useCallback } from "react"; import { getErrorMessage } from "@fusion/core"; import { fetchScripts, addScript, removeScript, type ScriptEntry } from "../api"; import type { ToastType } from "../hooks/useToast"; import { useMobileScrollLock } from "../hooks/useMobileScrollLock"; import { useOverlayDismiss } from "../hooks/useOverlayDismiss"; import { X, Plus, Play, Trash2, Terminal, Loader2, } from "lucide-react"; interface ScriptsModalProps { isOpen: boolean; onClose: () => void; addToast: (message: string, type?: ToastType) => void; projectId?: string; /** Callback when user wants to run a script - opens terminal modal */ onRunScript?: (name: string, command: string) => void; } interface ScriptFormData { name: string; command: string; } const EMPTY_FORM: ScriptFormData = { name: "", command: "", }; /** Validate script name: alphanumeric, hyphens, underscores only */ function isValidScriptName(name: string): boolean { return /^[a-zA-Z0-9_-]+$/.test(name); } /** Truncate command for display */ function truncateCommand(command: string, maxLength: number = 60): string { if (command.length <= maxLength) return command; return command.slice(0, maxLength - 3) + "..."; } export function ScriptsModal({ isOpen, onClose, addToast, projectId, onRunScript }: ScriptsModalProps) { useMobileScrollLock(isOpen); const [scripts, setScripts] = useState>({}); const [loading, setLoading] = useState(true); const [isCreating, setIsCreating] = useState(false); const [isEditing, setIsEditing] = useState(null); const [form, setForm] = useState(EMPTY_FORM); const [saving, setSaving] = useState(false); const [deleteConfirmName, setDeleteConfirmName] = useState(null); const [nameError, setNameError] = useState(null); const overlayDismissProps = useOverlayDismiss(onClose); const loadScripts = useCallback(async () => { try { setLoading(true); const data = await fetchScripts(projectId); setScripts(data); } catch (err) { addToast(getErrorMessage(err) || "Failed to load scripts", "error"); } finally { setLoading(false); } }, [addToast, projectId]); useEffect(() => { if (isOpen) { loadScripts(); } }, [isOpen, loadScripts]); const handleCreate = useCallback(() => { setIsCreating(true); setIsEditing(null); setForm(EMPTY_FORM); setNameError(null); }, []); const handleEdit = useCallback((name: string, command: string) => { setIsEditing(name); setIsCreating(false); setForm({ name, command }); setNameError(null); }, []); const handleCancel = useCallback(() => { setIsEditing(null); setIsCreating(false); setForm(EMPTY_FORM); setNameError(null); }, []); const handleNameChange = useCallback((name: string) => { setForm((prev) => ({ ...prev, name })); if (name && !isValidScriptName(name)) { setNameError("Name must contain only letters, numbers, hyphens, and underscores (no spaces)"); } else { setNameError(null); } }, []); const handleSave = useCallback(async () => { const trimmedName = form.name.trim(); const trimmedCommand = form.command.trim(); if (!trimmedName) { addToast("Script name is required", "error"); return; } if (!isValidScriptName(trimmedName)) { addToast("Script name must contain only letters, numbers, hyphens, and underscores (no spaces)", "error"); return; } if (!trimmedCommand) { addToast("Script command is required", "error"); return; } setSaving(true); try { await addScript(trimmedName, trimmedCommand, projectId); addToast(isEditing ? "Script updated" : "Script created", "success"); setIsEditing(null); setIsCreating(false); setForm(EMPTY_FORM); setNameError(null); await loadScripts(); } catch (err) { const msg = getErrorMessage(err); if (msg?.includes("already exists")) { addToast("A script with this name already exists", "error"); } else { addToast(msg || "Failed to save script", "error"); } } finally { setSaving(false); } }, [form, isEditing, addToast, loadScripts, projectId]); const handleDelete = useCallback(async (name: string) => { try { await removeScript(name, projectId); addToast("Script deleted", "success"); setDeleteConfirmName(null); if (isEditing === name) { setIsEditing(null); setForm(EMPTY_FORM); } await loadScripts(); } catch (err) { addToast(getErrorMessage(err) || "Failed to delete script", "error"); } }, [isEditing, addToast, loadScripts, projectId]); const handleRun = useCallback((name: string, command: string) => { if (onRunScript) { onRunScript(name, command); } }, [onRunScript]); if (!isOpen) return null; const isEditingAny = isCreating || isEditing !== null; const scriptEntries: ScriptEntry[] = Object.entries(scripts).map(([name, command]) => ({ name, command, })); return (
{/* Header */}

Scripts

{loading ? (
Loading scripts...
) : isEditingAny ? ( /* Form for create/edit */
handleNameChange(e.target.value)} placeholder="e.g., build, test, lint" disabled={saving || isEditing !== null} data-testid="script-name-input" style={{ width: "100%", borderColor: nameError ? "var(--color-error)" : undefined, }} /> {nameError && (
{nameError}
)}
Letters, numbers, hyphens, and underscores only