import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import {
ArrowLeft,
Braces,
ChevronDown,
Clock,
CornerDownRight,
GitBranch,
Plus,
Repeat,
Save,
Square,
Terminal as TerminalIcon,
Trash2,
Play,
} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Textarea } from "@/components/textarea";
import { Checkbox } from "@/components/checkbox";
import { EmptyState } from "@/components/empty-state";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/alert-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/dropdown-menu";
import { getUserPreferences, saveUserPreferences } from "@/api/open-tabs-api";
import { getErrorMessage } from "../lib/error-message.js";
import {
parseTerminalMacros,
runTerminalMacro,
type MacroStep,
type TerminalMacro,
} from "@/lib/terminal-macros";
import type { Tab } from "@/types/ui-types";
const STEP_TYPES = ["send", "wait", "delay", "if", "repeat"] as const;
const STEP_ICONS = {
send: TerminalIcon,
wait: CornerDownRight,
delay: Clock,
if: GitBranch,
repeat: Repeat,
} as const;
function id(): string {
return typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
}
function createStep(type: MacroStep["type"]): MacroStep {
const stepId = id();
switch (type) {
case "send":
return { id: stepId, type, text: "", pressEnter: true };
case "delay":
return { id: stepId, type, milliseconds: 1000 };
case "wait":
return {
id: stepId,
type,
pattern: "",
isRegex: false,
timeoutMs: 10_000,
onTimeout: "stop",
};
case "if":
return {
id: stepId,
type,
pattern: "",
isRegex: false,
then: [],
else: [],
};
case "repeat":
return { id: stepId, type, count: 2, steps: [] };
}
}
function isValidRegex(pattern: string): boolean {
try {
new RegExp(pattern);
return true;
} catch {
return false;
}
}
/** Milliseconds are an implementation detail, so the editor works in seconds. */
function SecondsInput({
valueMs,
onChangeMs,
minMs,
label,
}: {
valueMs: number;
onChangeMs: (ms: number) => void;
minMs: number;
label: string;
}) {
const { t } = useTranslation();
return (
);
}
/** Shared text/regex matcher field used by both "wait" and "if". */
function MatchField({
pattern,
isRegex,
label,
placeholder,
onChange,
}: {
pattern: string;
isRegex: boolean;
label: string;
placeholder: string;
onChange: (next: { pattern?: string; isRegex?: boolean }) => void;
}) {
const { t } = useTranslation();
const badRegex = isRegex && pattern.length > 0 && !isValidRegex(pattern);
return (
{label}
onChange({ pattern: event.target.value })}
/>
{badRegex && (
{t("macros.match.invalidRegex")}
)}
);
}
function AddStepMenu({
onAdd,
compact,
}: {
onAdd: (type: MacroStep["type"]) => void;
compact?: boolean;
}) {
const { t } = useTranslation();
return (
{STEP_TYPES.map((type) => {
const Icon = STEP_ICONS[type];
return (
onAdd(type)}
>
{t(`macros.stepTypes.${type}.label`)}
{t(`macros.stepTypes.${type}.help`)}
);
})}
);
}
function StepList({
steps,
onChange,
depth = 0,
}: {
steps: MacroStep[];
onChange: (steps: MacroStep[]) => void;
depth?: number;
}) {
const { t } = useTranslation();
const update = (index: number, step: MacroStep) => {
const next = [...steps];
next[index] = step;
onChange(next);
};
return (
{steps.map((step, index) => {
const Icon = STEP_ICONS[step.type];
return (
{t(`macros.stepTypes.${step.type}.label`)}
{step.type === "send" && (
)}
{step.type === "delay" && (
update(index, { ...step, milliseconds })
}
/>
)}
{step.type === "wait" && (
update(index, { ...step, ...next })}
/>
update(index, { ...step, timeoutMs })
}
/>
{t("macros.wait.onTimeout")}
{(["stop", "continue"] as const).map((mode) => (
))}
)}
{step.type === "if" && (
update(index, { ...step, ...next })}
/>
{t("macros.ifStep.then")}
update(index, { ...step, then })}
/>
{t("macros.ifStep.else")}
update(index, { ...step, else: elseSteps })
}
/>
)}
{step.type === "repeat" && (
)}
);
})}
{depth < 4 && (
0}
onAdd={(type) => onChange([...steps, createStep(type)])}
/>
)}
);
}
export function MacrosPanel({
terminalTabs,
activeTabId,
storageMode,
}: {
terminalTabs: Tab[];
activeTabId: string;
storageMode: "local" | "cloud";
}) {
const { t } = useTranslation();
const [macros, setMacros] = useState([]);
const [draft, setDraft] = useState(null);
const [dirty, setDirty] = useState(false);
const [runningId, setRunningId] = useState(null);
const [pendingDelete, setPendingDelete] = useState(
null,
);
const [pendingRun, setPendingRun] = useState(null);
const abortRef = useRef(null);
const confirmedRef = useRef>(new Set());
useEffect(() => {
let cancelled = false;
const load =
storageMode === "local"
? Promise.resolve(
parseTerminalMacros(localStorage.getItem("terminalMacros")),
)
: getUserPreferences().then((preferences) =>
parseTerminalMacros(preferences.terminalMacros),
);
load
.then((loaded) => {
if (!cancelled) setMacros(loaded);
})
.catch(() => {
if (!cancelled) toast.error(t("macros.loadFailed"));
});
return () => {
cancelled = true;
abortRef.current?.abort();
};
}, [storageMode, t]);
const target = useMemo(
() => terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0],
[activeTabId, terminalTabs],
);
const updateDraft = useCallback((next: TerminalMacro) => {
setDraft(next);
setDirty(true);
}, []);
const createMacro = () => {
const now = new Date().toISOString();
setDraft({
id: id(),
name: "",
description: "",
steps: [],
createdAt: now,
updatedAt: now,
});
setDirty(true);
};
const persist = async (next: TerminalMacro[]) => {
const serialized = JSON.stringify(next);
if (storageMode === "local")
localStorage.setItem("terminalMacros", serialized);
else await saveUserPreferences({ terminalMacros: serialized });
setMacros(next);
};
const save = async () => {
if (!draft) return;
if (!draft.name.trim()) {
toast.error(t("macros.nameRequired"));
return;
}
const saved: TerminalMacro = {
...draft,
name: draft.name.trim(),
updatedAt: new Date().toISOString(),
};
try {
await persist(
macros.some((macro) => macro.id === saved.id)
? macros.map((macro) => (macro.id === saved.id ? saved : macro))
: [...macros, saved],
);
setDraft(saved);
setDirty(false);
toast.success(t("macros.saved"));
} catch (error) {
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
}
};
const confirmDelete = async () => {
if (!pendingDelete) return;
const next = macros.filter((macro) => macro.id !== pendingDelete.id);
try {
await persist(next);
if (draft?.id === pendingDelete.id) {
setDraft(null);
setDirty(false);
}
toast.success(t("macros.deleted"));
} catch (error) {
toast.error(getErrorMessage(error) || t("macros.saveFailed"));
} finally {
setPendingDelete(null);
}
};
const execute = useCallback(
async (macro: TerminalMacro) => {
const terminal = target?.terminalRef?.current;
if (!terminal?.sendInput || !terminal.subscribeOutput) {
toast.error(t("macros.needTerminal"));
return;
}
const controller = new AbortController();
abortRef.current = controller;
setRunningId(macro.id);
try {
await runTerminalMacro(
macro,
{ send: terminal.sendInput, subscribe: terminal.subscribeOutput },
{ signal: controller.signal },
);
toast.success(t("macros.completed"));
} catch (error) {
if (!(error instanceof DOMException && error.name === "AbortError")) {
toast.error(getErrorMessage(error) || t("macros.failed"));
}
} finally {
abortRef.current = null;
setRunningId(null);
}
},
[target, t],
);
const requestRun = (macro: TerminalMacro) => {
if (!target?.terminalRef?.current?.sendInput) {
toast.error(t("macros.needTerminal"));
return;
}
if (confirmedRef.current.has(macro.id)) {
void execute(macro);
return;
}
setPendingRun(macro);
};
const targetLabel = target?.label ?? t("macros.noTerminal");
return (
{draft ? (
) : (
)}
{!draft && (
)}
{!draft ? (
macros.length === 0 ? (
{t("macros.create")}
}
/>
) : (
{macros.map((macro) => {
const isRunning = runningId === macro.id;
return (
);
})}
)
) : (
{t("macros.descriptionLabel")}
{draft.steps.length === 0 && (
{t("macros.noSteps")}
)}
updateDraft({ ...draft, steps })}
/>
{dirty && (
{t("macros.unsaved")}
)}
)}
!open && setPendingDelete(null)}
>
{t("macros.deleteTitle")}
{t("macros.deleteBody", { name: pendingDelete?.name ?? "" })}
{t("macros.cancel")}
{t("macros.delete")}
!open && setPendingRun(null)}
>
{t("macros.confirmRunTitle")}
{t("macros.confirmRunBody", {
name: pendingRun?.name ?? "",
host: targetLabel,
})}
{t("macros.cancel")}
{
const macro = pendingRun;
setPendingRun(null);
if (macro) {
confirmedRef.current.add(macro.id);
void execute(macro);
}
}}
>
{t("macros.confirmRunAction")}
);
}