- Add useMobileScrollLock hook using position:fixed body lock (Bootstrap/ Headless UI/Stripe pattern) to prevent iOS Safari from shifting the document and visualViewport when an input inside a fixed-position modal is focused. Wire into 15 input-bearing modals plus ChatView, replacing ChatView's inline body-overflow effect. - Widen computeBuildVersion in vite.config.ts to hash the entire app/ tree so the version-check poll actually notices rebuilds (FN-3333 follow-up; previously only main.tsx and package.json were hashed). - ChatView: on input blur, suppress keyboard-aware sizing for 450ms while reserving mobile-nav-bar space, so the composer snaps to its final height in one move instead of crawling down with iOS's keyboard slide and then jumping again when the nav bar reappears. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
494 lines
18 KiB
TypeScript
494 lines
18 KiB
TypeScript
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<Record<string, string>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [isCreating, setIsCreating] = useState(false);
|
|
const [isEditing, setIsEditing] = useState<string | null>(null);
|
|
const [form, setForm] = useState<ScriptFormData>(EMPTY_FORM);
|
|
const [saving, setSaving] = useState(false);
|
|
const [deleteConfirmName, setDeleteConfirmName] = useState<string | null>(null);
|
|
const [nameError, setNameError] = useState<string | null>(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 (
|
|
<div className="modal-overlay open" {...overlayDismissProps} data-testid="scripts-modal">
|
|
<div
|
|
className="modal scripts-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Scripts"
|
|
>
|
|
{/* Header */}
|
|
<div className="modal-header">
|
|
<h2>
|
|
<Terminal size={18} style={{ marginRight: "8px", verticalAlign: "middle" }} />
|
|
Scripts
|
|
</h2>
|
|
<button className="modal-close" onClick={onClose} aria-label="Close">
|
|
×
|
|
</button>
|
|
</div>
|
|
|
|
<div className="modal-body scripts-modal-body">
|
|
{loading ? (
|
|
<div style={{ textAlign: "center", padding: "32px", color: "var(--text-muted)" }}>
|
|
<Loader2 size={24} className="spin" style={{ margin: "0 auto 8px", display: "block" }} />
|
|
Loading scripts...
|
|
</div>
|
|
) : isEditingAny ? (
|
|
/* Form for create/edit */
|
|
<div className="scripts-modal-form" style={{ display: "flex", flexDirection: "column", gap: "16px" }}>
|
|
<div>
|
|
<label
|
|
htmlFor="script-name"
|
|
style={{
|
|
display: "block",
|
|
marginBottom: "4px",
|
|
fontSize: "13px",
|
|
fontWeight: 500,
|
|
color: "var(--text)",
|
|
}}
|
|
>
|
|
Script Name
|
|
</label>
|
|
<input
|
|
id="script-name"
|
|
type="text"
|
|
className="input"
|
|
value={form.name}
|
|
onChange={(e) => 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 && (
|
|
<div
|
|
style={{
|
|
fontSize: "12px",
|
|
color: "var(--color-error)",
|
|
marginTop: "4px",
|
|
}}
|
|
data-testid="script-name-error"
|
|
>
|
|
{nameError}
|
|
</div>
|
|
)}
|
|
<div
|
|
style={{
|
|
fontSize: "11px",
|
|
color: "var(--text-muted)",
|
|
marginTop: "4px",
|
|
}}
|
|
>
|
|
Letters, numbers, hyphens, and underscores only
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="script-command"
|
|
style={{
|
|
display: "block",
|
|
marginBottom: "4px",
|
|
fontSize: "13px",
|
|
fontWeight: 500,
|
|
color: "var(--text)",
|
|
}}
|
|
>
|
|
Command
|
|
</label>
|
|
<textarea
|
|
id="script-command"
|
|
className="input"
|
|
value={form.command}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, command: e.target.value }))}
|
|
placeholder="e.g., npm run build"
|
|
rows={3}
|
|
disabled={saving}
|
|
data-testid="script-command-input"
|
|
style={{
|
|
width: "100%",
|
|
resize: "vertical",
|
|
fontFamily: "monospace",
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="scripts-modal-form-actions" style={{ display: "flex", gap: "8px", justifyContent: "flex-end" }}>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={handleCancel}
|
|
disabled={saving}
|
|
data-testid="script-cancel-btn"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleSave}
|
|
disabled={saving || !!nameError}
|
|
data-testid="script-save-btn"
|
|
>
|
|
{saving ? (
|
|
<>
|
|
<Loader2 size={14} className="spin" style={{ marginRight: "6px" }} />
|
|
Saving...
|
|
</>
|
|
) : isEditing ? (
|
|
"Update"
|
|
) : (
|
|
"Create"
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
/* List view */
|
|
<>
|
|
<div
|
|
className="scripts-modal-list-header"
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "center",
|
|
marginBottom: "16px",
|
|
}}
|
|
>
|
|
<div style={{ fontSize: "13px", color: "var(--text-muted)" }}>
|
|
{scriptEntries.length === 0
|
|
? "No scripts defined"
|
|
: `${scriptEntries.length} script${scriptEntries.length === 1 ? "" : "s"}`}
|
|
</div>
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={handleCreate}
|
|
data-testid="add-script-btn"
|
|
style={{ display: "flex", alignItems: "center", gap: "6px" }}
|
|
>
|
|
<Plus size={14} />
|
|
Add Script
|
|
</button>
|
|
</div>
|
|
|
|
{scriptEntries.length === 0 ? (
|
|
<div
|
|
style={{
|
|
textAlign: "center",
|
|
padding: "32px",
|
|
color: "var(--text-muted)",
|
|
fontSize: "14px",
|
|
border: "1px dashed var(--border)",
|
|
borderRadius: "8px",
|
|
}}
|
|
data-testid="empty-state"
|
|
>
|
|
<Terminal size={32} style={{ margin: "0 auto 12px", opacity: 0.5 }} />
|
|
<div>No scripts defined yet.</div>
|
|
<div style={{ marginTop: "4px", fontSize: "12px" }}>
|
|
Add scripts to quickly run common commands from the dashboard.
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className="scripts-modal-list" style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
|
|
{scriptEntries.map((script) => (
|
|
<div
|
|
key={script.name}
|
|
className="script-card"
|
|
data-testid={`script-${script.name}`}
|
|
style={{
|
|
padding: "12px 16px",
|
|
border: "1px solid var(--border)",
|
|
borderRadius: "8px",
|
|
background: "var(--card)",
|
|
}}
|
|
>
|
|
<div
|
|
className="script-card-header"
|
|
style={{
|
|
display: "flex",
|
|
justifyContent: "space-between",
|
|
alignItems: "flex-start",
|
|
}}
|
|
>
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "8px",
|
|
marginBottom: "4px",
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
fontWeight: 600,
|
|
fontSize: "14px",
|
|
fontFamily: "monospace",
|
|
}}
|
|
>
|
|
{script.name}
|
|
</span>
|
|
</div>
|
|
<div
|
|
style={{
|
|
fontSize: "12px",
|
|
color: "var(--text-muted)",
|
|
fontFamily: "monospace",
|
|
overflow: "hidden",
|
|
textOverflow: "ellipsis",
|
|
whiteSpace: "nowrap",
|
|
}}
|
|
title={script.command}
|
|
>
|
|
{truncateCommand(script.command)}
|
|
</div>
|
|
</div>
|
|
<div
|
|
className="script-card-actions"
|
|
style={{
|
|
display: "flex",
|
|
gap: "4px",
|
|
marginLeft: "8px",
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<button
|
|
className="btn btn-secondary"
|
|
onClick={() => handleRun(script.name, script.command)}
|
|
title="Run script"
|
|
aria-label={`Run ${script.name}`}
|
|
data-testid={`run-script-${script.name}`}
|
|
style={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
gap: "4px",
|
|
padding: "4px 10px",
|
|
fontSize: "12px",
|
|
}}
|
|
>
|
|
<Play size={12} />
|
|
Run
|
|
</button>
|
|
<button
|
|
className="btn-icon"
|
|
onClick={() => handleEdit(script.name, script.command)}
|
|
title="Edit"
|
|
aria-label={`Edit ${script.name}`}
|
|
data-testid={`edit-script-${script.name}`}
|
|
>
|
|
<Plus size={14} style={{ transform: "rotate(45deg)" }} />
|
|
</button>
|
|
{deleteConfirmName === script.name ? (
|
|
<div className="script-delete-confirm" style={{ display: "flex", gap: "4px", alignItems: "center" }}>
|
|
<button
|
|
className="btn-icon"
|
|
onClick={() => handleDelete(script.name)}
|
|
title="Confirm delete"
|
|
aria-label={`Confirm delete ${script.name}`}
|
|
data-testid={`confirm-delete-script-${script.name}`}
|
|
style={{ color: "var(--color-error)" }}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
<button
|
|
className="btn-icon"
|
|
onClick={() => setDeleteConfirmName(null)}
|
|
title="Cancel delete"
|
|
aria-label="Cancel delete"
|
|
data-testid={`cancel-delete-script-${script.name}`}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
className="btn-icon"
|
|
onClick={() => setDeleteConfirmName(script.name)}
|
|
title="Delete"
|
|
aria-label={`Delete ${script.name}`}
|
|
data-testid={`delete-script-${script.name}`}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|