mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: move snippets, keybinding actions and macros out of core into plugins
This commit is contained in:
1 parent
d9bba10612
commit
e0c30a4e6f
262 files changed
+9091
-10744
No files matched your search
@@ -22,7 +22,10 @@ import {
|
||||
import { ADMIN_KEYS } from "./settings.js";
|
||||
import { commandHistory } from "./tables.js";
|
||||
import { createTerminalSocket } from "./terminal-socket.js";
|
||||
import { validateAdminSettings } from "./settings-validation.js";
|
||||
import {
|
||||
validateAdminSettings,
|
||||
validateUserSettings,
|
||||
} from "./settings-validation.js";
|
||||
|
||||
function toInfo(session: TerminalSession): LiveSessionInfo {
|
||||
return {
|
||||
@@ -57,6 +60,7 @@ function optionalService<T extends object>(
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const log = createTerminalLogger(ctx.log);
|
||||
ctx.settings.onValidate("admin", validateAdminSettings);
|
||||
ctx.settings.onValidate("user", validateUserSettings);
|
||||
|
||||
const table = await ctx.db.define(commandHistory);
|
||||
const history = createHistoryRepository(ctx.db, table);
|
||||
|
||||
@@ -30,3 +30,38 @@ export function validateAdminSettings(
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
const MAX_MACROS = 100;
|
||||
const MAX_MACROS_BYTES = 512 * 1024;
|
||||
|
||||
/** Save-time checks for the user settings: macros stay bounded. */
|
||||
export function validateUserSettings(
|
||||
values: Record<string, unknown>,
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
if (values.macros === undefined || values.macros === null) return errors;
|
||||
let macros = values.macros;
|
||||
if (typeof macros === "string") {
|
||||
try {
|
||||
macros = JSON.parse(macros);
|
||||
} catch {
|
||||
macros = null;
|
||||
}
|
||||
}
|
||||
const valid =
|
||||
Array.isArray(macros) &&
|
||||
macros.length <= MAX_MACROS &&
|
||||
JSON.stringify(macros).length <= MAX_MACROS_BYTES &&
|
||||
macros.every(
|
||||
(macro) =>
|
||||
!!macro &&
|
||||
typeof macro === "object" &&
|
||||
typeof (macro as { id?: unknown }).id === "string" &&
|
||||
typeof (macro as { name?: unknown }).name === "string" &&
|
||||
Array.isArray((macro as { steps?: unknown }).steps),
|
||||
);
|
||||
if (!valid) {
|
||||
errors.macros = `Macros must be a list of at most ${MAX_MACROS} valid macros`;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { lazy, Suspense, type ComponentType } from "react";
|
||||
import {
|
||||
Braces,
|
||||
Hammer,
|
||||
History,
|
||||
Laptop,
|
||||
@@ -33,6 +34,17 @@ import { HostTerminalSection } from "./settings/HostTerminalSection";
|
||||
import { resetTouchInputSettingsCache } from "./terminal/touch-input-settings-store";
|
||||
import { hostSetting } from "./terminal-api";
|
||||
import { SshToolsPanel } from "./ssh-tools/SshToolsPanel";
|
||||
import { MacrosPanel } from "./macros/MacrosPanel";
|
||||
import {
|
||||
TERMINAL_KEYBINDING_DEFAULTS,
|
||||
validateSendControlCode,
|
||||
validateSendText,
|
||||
} from "./lib/keybinding-dispatch";
|
||||
import {
|
||||
PasteNote,
|
||||
SendControlCodeEditor,
|
||||
SendTextEditor,
|
||||
} from "./lib/keybinding-editors";
|
||||
import { TerminalDefaultsSettings } from "./settings/TerminalDefaultsSettings";
|
||||
import {
|
||||
NewHostFontSetting,
|
||||
@@ -191,6 +203,45 @@ export function activate(app: TermixApp): void {
|
||||
separatorAfter: true,
|
||||
});
|
||||
|
||||
app.registerPanel(
|
||||
"macros",
|
||||
MacrosPanel as unknown as ComponentType<PanelProps>,
|
||||
);
|
||||
app.registerRailItem({
|
||||
id: "macros",
|
||||
icon: Braces,
|
||||
titleKey: "nav.macros",
|
||||
after: "ssh-tools",
|
||||
hideable: true,
|
||||
promotable: true,
|
||||
rightDockable: true,
|
||||
separatorAfter: true,
|
||||
});
|
||||
|
||||
// The keys a user can bind to the terminal's own actions, and its
|
||||
// built-in keys they can rebind. The terminal runs these itself.
|
||||
app.registerKeybindingAction({ id: "copy", titleKey: "keybindings.copy" });
|
||||
app.registerKeybindingAction({
|
||||
id: "paste",
|
||||
titleKey: "keybindings.paste",
|
||||
editor: PasteNote,
|
||||
});
|
||||
app.registerKeybindingAction({
|
||||
id: "sendControlCode",
|
||||
titleKey: "keybindings.sendControlCode",
|
||||
editor: SendControlCodeEditor,
|
||||
validate: validateSendControlCode,
|
||||
});
|
||||
app.registerKeybindingAction({
|
||||
id: "sendText",
|
||||
titleKey: "keybindings.sendText",
|
||||
editor: SendTextEditor,
|
||||
validate: validateSendText,
|
||||
});
|
||||
for (const binding of TERMINAL_KEYBINDING_DEFAULTS) {
|
||||
app.registerKeybindingDefault(binding);
|
||||
}
|
||||
|
||||
// Places other plugins can fill: toolbar buttons and readouts, a side
|
||||
// panel and overlays that follow the session's connection flow.
|
||||
app.declareActionSlot({ id: TERMINAL_TOOLBAR_SLOT, accepts: ["button"] });
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import { type KeybindingAction } from "@termix/plugin-sdk/ui";
|
||||
import { invokeAction } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface SnippetHostContext {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
import type {
|
||||
KeybindingAction,
|
||||
KeybindingDefaultContribution,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
export interface KeybindingDispatchContext {
|
||||
terminal: Terminal;
|
||||
webSocketRef: React.MutableRefObject<WebSocket | null>;
|
||||
writeTextToClipboard: (text: string) => Promise<boolean>;
|
||||
readTextFromClipboard: () => Promise<string>;
|
||||
/** Host context used to resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
|
||||
hostContext?: SnippetHostContext | null;
|
||||
/**
|
||||
* Called instead of sending the snippet directly when its content still has
|
||||
* unresolved $INPUT_n placeholders after host-variable substitution -- the
|
||||
* caller is expected to collect values (e.g. via a dialog) and send itself.
|
||||
*/
|
||||
onSnippetNeedsInputs?: (snippet: { id: string; content: string }) => void;
|
||||
}
|
||||
|
||||
/** The keybinding actions the terminal runs itself. */
|
||||
export const TERMINAL_KEYBINDING_ACTIONS = [
|
||||
"copy",
|
||||
"paste",
|
||||
"sendControlCode",
|
||||
"sendText",
|
||||
] as const;
|
||||
|
||||
export function isTerminalKeybindingAction(type: string): boolean {
|
||||
return (TERMINAL_KEYBINDING_ACTIONS as readonly string[]).includes(type);
|
||||
}
|
||||
|
||||
export function sendRawToSocket(
|
||||
@@ -33,10 +32,19 @@ export function sendRawToSocket(
|
||||
}
|
||||
}
|
||||
|
||||
function text(action: KeybindingAction, key: string): string {
|
||||
const value = action[key];
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one of the terminal's own bound actions. False for any other type,
|
||||
* which the terminal hands on to whoever registered it.
|
||||
*/
|
||||
export function dispatchKeybindingAction(
|
||||
action: KeybindingAction,
|
||||
ctx: KeybindingDispatchContext,
|
||||
): void {
|
||||
): boolean {
|
||||
const sendRaw = (data: string) => sendRawToSocket(ctx.webSocketRef, data);
|
||||
|
||||
switch (action.type) {
|
||||
@@ -46,58 +54,96 @@ export function dispatchKeybindingAction(
|
||||
ctx.writeTextToClipboard(selection);
|
||||
ctx.terminal.clearSelection();
|
||||
}
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
case "paste": {
|
||||
ctx.readTextFromClipboard().then((text) => {
|
||||
if (text) ctx.terminal.paste(text);
|
||||
ctx.readTextFromClipboard().then((value) => {
|
||||
if (value) ctx.terminal.paste(value);
|
||||
});
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
case "sendControlCode": {
|
||||
if (!action.controlCode) return;
|
||||
const code = action.controlCode.toLowerCase().charCodeAt(0) - 96;
|
||||
const letter = text(action, "controlCode");
|
||||
if (!letter) return true;
|
||||
const code = letter.toLowerCase().charCodeAt(0) - 96;
|
||||
if (code >= 1 && code <= 26) sendRaw(String.fromCharCode(code));
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
case "sendText": {
|
||||
sendRaw((action.text ?? "") + (action.appendEnter ? "\r" : ""));
|
||||
return;
|
||||
}
|
||||
case "runSnippet": {
|
||||
if (!action.snippetId) return;
|
||||
const snippetId = Number(action.snippetId);
|
||||
if (!Number.isFinite(snippetId)) return;
|
||||
// Resolving with no inputValues first tells us whether the snippet
|
||||
// still needs them; the plugin decides and reports back.
|
||||
void invokeAction(
|
||||
"snippets.resolveForTerminal",
|
||||
snippetId,
|
||||
ctx.hostContext ?? null,
|
||||
).then((result) => {
|
||||
const resolved = result as
|
||||
{ needsInputs: boolean; content: string } | null | undefined;
|
||||
if (!resolved) return;
|
||||
if (resolved.needsInputs) {
|
||||
ctx.onSnippetNeedsInputs?.({
|
||||
id: action.snippetId!,
|
||||
content: resolved.content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendRaw(resolved.content + (action.appendEnter !== false ? "\r" : ""));
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "nextTab":
|
||||
case "previousTab":
|
||||
case "openCommandPalette": {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:global-keybinding", {
|
||||
detail: { type: action.type },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
sendRaw(text(action, "text") + (action.appendEnter === true ? "\r" : ""));
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateSendText(action: KeybindingAction): string | null {
|
||||
return text(action, "text").trim() ? null : "keybindings.textRequired";
|
||||
}
|
||||
|
||||
export function validateSendControlCode(
|
||||
action: KeybindingAction,
|
||||
): string | null {
|
||||
return /^[a-zA-Z]$/.test(text(action, "controlCode"))
|
||||
? null
|
||||
: "keybindings.controlCodeRequired";
|
||||
}
|
||||
|
||||
function combo(
|
||||
key: string,
|
||||
modifiers: { ctrl?: boolean; alt?: boolean; shift?: boolean; meta?: boolean },
|
||||
) {
|
||||
return {
|
||||
key,
|
||||
isCode: false,
|
||||
ctrl: !!modifiers.ctrl,
|
||||
alt: !!modifiers.alt,
|
||||
shift: !!modifiers.shift,
|
||||
meta: !!modifiers.meta,
|
||||
};
|
||||
}
|
||||
|
||||
/** The terminal's built-in keys, listed so a user can rebind them. */
|
||||
export const TERMINAL_KEYBINDING_DEFAULTS: KeybindingDefaultContribution[] = [
|
||||
{
|
||||
id: "default-copy-ctrlc",
|
||||
combo: combo("c", { ctrl: true }),
|
||||
descriptionKey: "keybindings.builtIn.copyCtrlC",
|
||||
},
|
||||
{
|
||||
id: "default-copy-ctrlshiftc",
|
||||
combo: combo("c", { ctrl: true, shift: true }),
|
||||
descriptionKey: "keybindings.builtIn.copyCtrlShiftC",
|
||||
},
|
||||
{
|
||||
id: "default-copy-cmdc",
|
||||
combo: combo("c", { meta: true }),
|
||||
descriptionKey: "keybindings.builtIn.copyCmdC",
|
||||
},
|
||||
{
|
||||
id: "default-paste-ctrlshiftv",
|
||||
combo: combo("v", { ctrl: true, shift: true }),
|
||||
descriptionKey: "keybindings.builtIn.pasteCtrlShiftV",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltw",
|
||||
combo: combo("w", { ctrl: true, alt: true }),
|
||||
descriptionKey: "keybindings.builtIn.ctrlW",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltt",
|
||||
combo: combo("t", { ctrl: true, alt: true }),
|
||||
descriptionKey: "keybindings.builtIn.ctrlT",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltn",
|
||||
combo: combo("n", { ctrl: true, alt: true }),
|
||||
descriptionKey: "keybindings.builtIn.ctrlN",
|
||||
},
|
||||
{
|
||||
id: "default-ctrlaltq",
|
||||
combo: combo("q", { ctrl: true, alt: true }),
|
||||
descriptionKey: "keybindings.builtIn.ctrlQ",
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
useTranslation,
|
||||
type KeybindingActionEditorProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export function PasteNote() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("keybindings.clipboardPermissionNote")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendControlCodeEditor({
|
||||
action,
|
||||
onChange,
|
||||
}: KeybindingActionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const value =
|
||||
typeof action.controlCode === "string" ? action.controlCode : "";
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("keybindings.controlCodeLabel")}
|
||||
</label>
|
||||
<Input
|
||||
aria-label={t("keybindings.controlCodeLabel")}
|
||||
value={value}
|
||||
maxLength={1}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
type: action.type,
|
||||
controlCode: e.target.value.toLowerCase() || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="w"
|
||||
className="w-16 font-mono"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SendTextEditor({
|
||||
action,
|
||||
onChange,
|
||||
}: KeybindingActionEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const text = typeof action.text === "string" ? action.text : "";
|
||||
const appendEnter = action.appendEnter === true;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("keybindings.textLabel")}
|
||||
</label>
|
||||
<textarea
|
||||
aria-label={t("keybindings.textLabel")}
|
||||
value={text}
|
||||
onChange={(e) =>
|
||||
onChange({ type: action.type, text: e.target.value, appendEnter })
|
||||
}
|
||||
className="w-full h-24 px-3 py-2 text-xs bg-background border border-border text-foreground placeholder:text-muted-foreground resize-none outline-none focus:ring-1 focus:ring-ring font-mono"
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={appendEnter}
|
||||
onChange={(e) =>
|
||||
onChange({
|
||||
type: action.type,
|
||||
text,
|
||||
appendEnter: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{t("keybindings.appendEnterLabel")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,864 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import {
|
||||
useSettings,
|
||||
useTranslation,
|
||||
type PanelProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
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,
|
||||
Input,
|
||||
Textarea,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
mergeLocalMacros,
|
||||
sanitizeTerminalMacros,
|
||||
runTerminalMacro,
|
||||
type MacroStep,
|
||||
type TerminalMacro,
|
||||
} from "./terminal-macros";
|
||||
import {
|
||||
getSessionHandle,
|
||||
sessionsSnapshot,
|
||||
subscribeSessions,
|
||||
} from "../session-registry";
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "";
|
||||
}
|
||||
|
||||
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 (
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<Input
|
||||
className="h-7 w-20 rounded-none text-xs"
|
||||
type="number"
|
||||
min={minMs / 1000}
|
||||
max={300}
|
||||
step={0.5}
|
||||
value={valueMs / 1000}
|
||||
onChange={(event) => {
|
||||
const seconds = Number(event.target.value);
|
||||
onChangeMs(
|
||||
Number.isFinite(seconds)
|
||||
? Math.max(minMs, Math.min(300_000, Math.round(seconds * 1000)))
|
||||
: minMs,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="text-muted-foreground">{t("macros.seconds")}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<Input
|
||||
className={`h-8 rounded-none font-mono text-xs ${
|
||||
badRegex ? "border-destructive" : ""
|
||||
}`}
|
||||
placeholder={placeholder}
|
||||
value={pattern}
|
||||
onChange={(event) => onChange({ pattern: event.target.value })}
|
||||
/>
|
||||
{badRegex && (
|
||||
<div className="text-[11px] text-destructive">
|
||||
{t("macros.match.invalidRegex")}
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={isRegex}
|
||||
onCheckedChange={(checked) => onChange({ isRegex: checked === true })}
|
||||
/>
|
||||
{t("macros.match.useRegex")}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddStepMenu({
|
||||
onAdd,
|
||||
compact,
|
||||
}: {
|
||||
onAdd: (type: MacroStep["type"]) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={`rounded-none ${compact ? "h-7 text-[11px]" : "h-8 w-full text-xs"}`}
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
{t("macros.addStep")}
|
||||
<ChevronDown className="ml-1 size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-72 rounded-none">
|
||||
{STEP_TYPES.map((type) => {
|
||||
const Icon = STEP_ICONS[type];
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={type}
|
||||
className="flex-col items-start gap-0.5 rounded-none"
|
||||
onSelect={() => onAdd(type)}
|
||||
>
|
||||
<span className="flex items-center gap-2 text-xs font-medium">
|
||||
<Icon className="size-3.5" />
|
||||
{t(`macros.stepTypes.${type}.label`)}
|
||||
</span>
|
||||
<span className="pl-5 text-[11px] leading-snug text-muted-foreground">
|
||||
{t(`macros.stepTypes.${type}.help`)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
{steps.map((step, index) => {
|
||||
const Icon = STEP_ICONS[step.type];
|
||||
return (
|
||||
<div key={step.id} className="border border-border bg-muted/20">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-2 py-1.5">
|
||||
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">
|
||||
{t(`macros.stepTypes.${step.type}.label`)}
|
||||
</span>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="ml-auto size-6 rounded-none"
|
||||
aria-label={t("macros.removeStep")}
|
||||
onClick={() => onChange(steps.filter((_, i) => i !== index))}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 p-2">
|
||||
{step.type === "send" && (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
className="min-h-16 rounded-none font-mono text-xs"
|
||||
placeholder={t("macros.send.textPlaceholder")}
|
||||
value={step.text}
|
||||
onChange={(event) =>
|
||||
update(index, { ...step, text: event.target.value })
|
||||
}
|
||||
/>
|
||||
<label className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<Checkbox
|
||||
checked={step.pressEnter}
|
||||
onCheckedChange={(checked) =>
|
||||
update(index, { ...step, pressEnter: checked === true })
|
||||
}
|
||||
/>
|
||||
{t("macros.send.pressEnter")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === "delay" && (
|
||||
<SecondsInput
|
||||
label={t("macros.delay.duration")}
|
||||
valueMs={step.milliseconds}
|
||||
minMs={0}
|
||||
onChangeMs={(milliseconds) =>
|
||||
update(index, { ...step, milliseconds })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{step.type === "wait" && (
|
||||
<div className="space-y-2">
|
||||
<MatchField
|
||||
pattern={step.pattern}
|
||||
isRegex={step.isRegex !== false}
|
||||
label={t("macros.wait.pattern")}
|
||||
placeholder={t("macros.wait.patternPlaceholder")}
|
||||
onChange={(next) => update(index, { ...step, ...next })}
|
||||
/>
|
||||
<SecondsInput
|
||||
label={t("macros.wait.timeout")}
|
||||
valueMs={step.timeoutMs}
|
||||
minMs={100}
|
||||
onChangeMs={(timeoutMs) =>
|
||||
update(index, { ...step, timeoutMs })
|
||||
}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("macros.wait.onTimeout")}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{(["stop", "continue"] as const).map((mode) => (
|
||||
<Button
|
||||
key={mode}
|
||||
size="sm"
|
||||
variant={
|
||||
step.onTimeout === mode ? "default" : "outline"
|
||||
}
|
||||
className="h-7 flex-1 rounded-none text-[11px]"
|
||||
onClick={() =>
|
||||
update(index, { ...step, onTimeout: mode })
|
||||
}
|
||||
>
|
||||
{t(`macros.wait.${mode}`)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === "if" && (
|
||||
<div className="space-y-3">
|
||||
<MatchField
|
||||
pattern={step.pattern}
|
||||
isRegex={step.isRegex !== false}
|
||||
label={t("macros.ifStep.pattern")}
|
||||
placeholder={t("macros.ifStep.patternPlaceholder")}
|
||||
onChange={(next) => update(index, { ...step, ...next })}
|
||||
/>
|
||||
<div className="border-l-2 border-emerald-500/40 pl-2">
|
||||
<div className="mb-1 text-[11px] font-medium text-emerald-500">
|
||||
{t("macros.ifStep.then")}
|
||||
</div>
|
||||
<StepList
|
||||
steps={step.then}
|
||||
depth={depth + 1}
|
||||
onChange={(then) => update(index, { ...step, then })}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-l-2 border-amber-500/40 pl-2">
|
||||
<div className="mb-1 text-[11px] font-medium text-amber-500">
|
||||
{t("macros.ifStep.else")}
|
||||
</div>
|
||||
<StepList
|
||||
steps={step.else}
|
||||
depth={depth + 1}
|
||||
onChange={(elseSteps) =>
|
||||
update(index, { ...step, else: elseSteps })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step.type === "repeat" && (
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{t("macros.repeat.count")}
|
||||
</span>
|
||||
<Input
|
||||
className="h-7 w-20 rounded-none text-xs"
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={step.count}
|
||||
onChange={(event) =>
|
||||
update(index, {
|
||||
...step,
|
||||
count: Math.max(
|
||||
1,
|
||||
Math.min(100, Number(event.target.value) || 1),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="text-muted-foreground">
|
||||
{t("macros.repeat.times")}
|
||||
</span>
|
||||
</label>
|
||||
<div className="border-l-2 border-border pl-2">
|
||||
<StepList
|
||||
steps={step.steps}
|
||||
depth={depth + 1}
|
||||
onChange={(nested) =>
|
||||
update(index, { ...step, steps: nested })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{depth < 4 && (
|
||||
<AddStepMenu
|
||||
compact={depth > 0}
|
||||
onAdd={(type) => onChange([...steps, createStep(type)])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Macros: a saved series of steps typed into the terminal the user is
|
||||
* working in, waiting for and branching on what the server sends back.
|
||||
* They are the user's own setting, so they follow them to every browser.
|
||||
*/
|
||||
export function MacrosPanel({ targetTab }: PanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const settings = useSettings("user");
|
||||
const [draft, setDraft] = useState<TerminalMacro | null>(null);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [runningId, setRunningId] = useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = useState<TerminalMacro | null>(
|
||||
null,
|
||||
);
|
||||
const [pendingRun, setPendingRun] = useState<TerminalMacro | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const confirmedRef = useRef<Set<string>>(new Set());
|
||||
const movedLocalRef = useRef(false);
|
||||
const sessions = useSyncExternalStore(subscribeSessions, sessionsSnapshot);
|
||||
|
||||
const macros = useMemo(
|
||||
() => sanitizeTerminalMacros(settings.values.macros),
|
||||
[settings.values.macros],
|
||||
);
|
||||
const { save: saveSettings } = settings;
|
||||
|
||||
useEffect(() => () => abortRef.current?.abort(), []);
|
||||
|
||||
// Macros a browser kept before 2.9.0 move into the setting once.
|
||||
useEffect(() => {
|
||||
if (!settings.loaded || movedLocalRef.current) return;
|
||||
movedLocalRef.current = true;
|
||||
const merged = mergeLocalMacros(macros);
|
||||
if (!merged) return;
|
||||
saveSettings({ macros: merged.macros })
|
||||
.then(merged.clear)
|
||||
.catch(() => toast.error(t("macros.saveFailed")));
|
||||
}, [settings.loaded, macros, saveSettings, t]);
|
||||
|
||||
const target = useMemo(
|
||||
() =>
|
||||
sessions.find((session) => session.id === targetTab?.id) ?? sessions[0],
|
||||
[sessions, targetTab?.id],
|
||||
);
|
||||
|
||||
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[]) => {
|
||||
await saveSettings({ macros: 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 ? getSessionHandle(target.id) : null;
|
||||
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 || !getSessionHandle(target.id)?.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 (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border p-3">
|
||||
{draft ? (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7 rounded-none"
|
||||
aria-label={t("macros.back")}
|
||||
onClick={() => {
|
||||
setDraft(null);
|
||||
setDirty(false);
|
||||
}}
|
||||
>
|
||||
<ArrowLeft className="size-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Braces className="size-4 shrink-0" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold">{t("macros.title")}</div>
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{t("macros.target")}: {targetLabel}
|
||||
</div>
|
||||
{!draft && (
|
||||
<a
|
||||
href="https://docs.termix.site/features/terminal/macros"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-[10px] text-accent-brand hover:underline"
|
||||
>
|
||||
{t("hosts.docsLink")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{!draft && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
className="size-7 rounded-none"
|
||||
aria-label={t("macros.create")}
|
||||
onClick={createMacro}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
{!draft ? (
|
||||
macros.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={Braces}
|
||||
title={t("macros.empty")}
|
||||
hint={t("macros.emptyHint")}
|
||||
action={
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="mt-1 rounded-none"
|
||||
onClick={createMacro}
|
||||
>
|
||||
<Plus className="mr-1 size-3" />
|
||||
{t("macros.create")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{macros.map((macro) => {
|
||||
const isRunning = runningId === macro.id;
|
||||
return (
|
||||
<div
|
||||
key={macro.id}
|
||||
className="group flex items-center gap-2 border border-border bg-muted/20 px-2 py-1.5 hover:bg-muted/40"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 flex-1 text-left"
|
||||
onClick={() => {
|
||||
setDraft(structuredClone(macro));
|
||||
setDirty(false);
|
||||
}}
|
||||
>
|
||||
<div className="truncate text-xs font-medium">
|
||||
{macro.name}
|
||||
</div>
|
||||
{macro.description && (
|
||||
<div className="truncate text-[11px] text-muted-foreground">
|
||||
{macro.description}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-6 shrink-0 rounded-none"
|
||||
aria-label={
|
||||
isRunning ? t("macros.stop") : t("macros.run")
|
||||
}
|
||||
onClick={() =>
|
||||
isRunning
|
||||
? abortRef.current?.abort()
|
||||
: requestRun(macro)
|
||||
}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Square className="size-3" />
|
||||
) : (
|
||||
<Play className="size-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("macros.name")}
|
||||
</div>
|
||||
<Input
|
||||
className="h-8 rounded-none text-xs"
|
||||
value={draft.name}
|
||||
placeholder={t("macros.namePlaceholder")}
|
||||
onChange={(event) =>
|
||||
updateDraft({ ...draft, name: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("macros.descriptionLabel")}
|
||||
</div>
|
||||
<Textarea
|
||||
className="min-h-14 rounded-none text-xs"
|
||||
value={draft.description ?? ""}
|
||||
placeholder={t("macros.descriptionPlaceholder")}
|
||||
onChange={(event) =>
|
||||
updateDraft({ ...draft, description: event.target.value })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{draft.steps.length === 0 && (
|
||||
<div className="border border-dashed border-border px-3 py-4 text-center text-[11px] text-muted-foreground">
|
||||
{t("macros.noSteps")}
|
||||
</div>
|
||||
)}
|
||||
<StepList
|
||||
steps={draft.steps}
|
||||
onChange={(steps) => updateDraft({ ...draft, steps })}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-border pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="rounded-none"
|
||||
onClick={() => setPendingDelete(draft)}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3" />
|
||||
{t("macros.delete")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="rounded-none"
|
||||
onClick={save}
|
||||
>
|
||||
<Save className="mr-1 size-3" />
|
||||
{t("macros.save")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
className="ml-auto rounded-none"
|
||||
onClick={() =>
|
||||
runningId === draft.id
|
||||
? abortRef.current?.abort()
|
||||
: requestRun(draft)
|
||||
}
|
||||
>
|
||||
{runningId === draft.id ? (
|
||||
<>
|
||||
<Square className="mr-1 size-3" />
|
||||
{t("macros.stop")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-1 size-3" />
|
||||
{t("macros.run")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{dirty && (
|
||||
<div className="text-[11px] text-amber-500">
|
||||
{t("macros.unsaved")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingDelete !== null}
|
||||
onOpenChange={(open) => !open && setPendingDelete(null)}
|
||||
>
|
||||
<AlertDialogContent className="rounded-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("macros.deleteTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("macros.deleteBody", { name: pendingDelete?.name ?? "" })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="rounded-none">
|
||||
{t("macros.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction className="rounded-none" onClick={confirmDelete}>
|
||||
{t("macros.delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingRun !== null}
|
||||
onOpenChange={(open) => !open && setPendingRun(null)}
|
||||
>
|
||||
<AlertDialogContent className="rounded-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("macros.confirmRunTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("macros.confirmRunBody", {
|
||||
name: pendingRun?.name ?? "",
|
||||
host: targetLabel,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="rounded-none">
|
||||
{t("macros.cancel")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="rounded-none"
|
||||
onClick={() => {
|
||||
const macro = pendingRun;
|
||||
setPendingRun(null);
|
||||
if (macro) {
|
||||
confirmedRef.current.add(macro.id);
|
||||
void execute(macro);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("macros.confirmRunAction")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
export type MacroStep =
|
||||
| { id: string; type: "send"; text: string; pressEnter: boolean }
|
||||
| { id: string; type: "delay"; milliseconds: number }
|
||||
| {
|
||||
id: string;
|
||||
type: "wait";
|
||||
pattern: string;
|
||||
isRegex?: boolean;
|
||||
flags?: string;
|
||||
timeoutMs: number;
|
||||
onTimeout: "stop" | "continue";
|
||||
}
|
||||
| {
|
||||
id: string;
|
||||
type: "if";
|
||||
pattern: string;
|
||||
isRegex?: boolean;
|
||||
flags?: string;
|
||||
then: MacroStep[];
|
||||
else: MacroStep[];
|
||||
}
|
||||
| { id: string; type: "repeat"; count: number; steps: MacroStep[] };
|
||||
|
||||
export interface TerminalMacro {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: MacroStep[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MacroTerminalAdapter {
|
||||
send(data: string): void;
|
||||
subscribe(listener: (data: string) => void): () => void;
|
||||
}
|
||||
|
||||
export type MacroRunEvent =
|
||||
| { type: "step"; stepId: string }
|
||||
| { type: "matched"; stepId: string; match: string }
|
||||
| { type: "timeout"; stepId: string }
|
||||
| { type: "complete" };
|
||||
|
||||
const MAX_OUTPUT = 100_000;
|
||||
|
||||
function bounded(value: number, minimum: number, maximum: number): number {
|
||||
return Number.isFinite(value)
|
||||
? Math.min(maximum, Math.max(minimum, Math.floor(value)))
|
||||
: minimum;
|
||||
}
|
||||
|
||||
function escapeLiteral(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function regex(pattern: string, flags = "", isRegex = true): RegExp {
|
||||
const source = isRegex ? pattern : escapeLiteral(pattern);
|
||||
return new RegExp(source, flags.replace(/[gy]/g, ""));
|
||||
}
|
||||
|
||||
// Literal text is the default in the editor, so a bad regex should never kill a
|
||||
// run. Fall back to matching the pattern literally instead.
|
||||
function safeRegex(pattern: string, flags = "", isRegex = true): RegExp {
|
||||
try {
|
||||
return regex(pattern, flags, isRegex);
|
||||
} catch {
|
||||
return new RegExp(escapeLiteral(pattern), flags.replace(/[gy]/g, ""));
|
||||
}
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("Macro cancelled", "AbortError");
|
||||
}
|
||||
|
||||
function delay(milliseconds: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(abortError());
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, milliseconds);
|
||||
signal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
reject(abortError());
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runTerminalMacro(
|
||||
macro: TerminalMacro,
|
||||
terminal: MacroTerminalAdapter,
|
||||
options: {
|
||||
signal?: AbortSignal;
|
||||
onEvent?: (event: MacroRunEvent) => void;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
let output = "";
|
||||
let scanFrom = 0;
|
||||
let wake: (() => void) | null = null;
|
||||
const unsubscribe = terminal.subscribe((data) => {
|
||||
const combined = output + data;
|
||||
const removed = Math.max(0, combined.length - MAX_OUTPUT);
|
||||
output = combined.slice(removed);
|
||||
scanFrom = Math.max(0, scanFrom - removed);
|
||||
wake?.();
|
||||
});
|
||||
|
||||
const runSteps = async (steps: MacroStep[]): Promise<void> => {
|
||||
for (const step of steps) {
|
||||
if (options.signal?.aborted) throw abortError();
|
||||
options.onEvent?.({ type: "step", stepId: step.id });
|
||||
|
||||
if (step.type === "send") {
|
||||
scanFrom = output.length;
|
||||
terminal.send(step.text + (step.pressEnter ? "\r" : ""));
|
||||
continue;
|
||||
}
|
||||
if (step.type === "delay") {
|
||||
await delay(bounded(step.milliseconds, 0, 300_000), options.signal);
|
||||
continue;
|
||||
}
|
||||
if (step.type === "if") {
|
||||
await runSteps(
|
||||
safeRegex(step.pattern, step.flags, step.isRegex !== false).test(
|
||||
output,
|
||||
)
|
||||
? step.then
|
||||
: step.else,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (step.type === "repeat") {
|
||||
const count = bounded(step.count, 1, 100);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
await runSteps(step.steps);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const matcher = safeRegex(
|
||||
step.pattern,
|
||||
step.flags,
|
||||
step.isRegex !== false,
|
||||
);
|
||||
const deadline = Date.now() + bounded(step.timeoutMs, 100, 300_000);
|
||||
let match = matcher.exec(output.slice(scanFrom));
|
||||
while (!match && Date.now() < deadline) {
|
||||
await Promise.race([
|
||||
new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
}),
|
||||
delay(Math.min(250, deadline - Date.now()), options.signal),
|
||||
]);
|
||||
wake = null;
|
||||
match = matcher.exec(output.slice(scanFrom));
|
||||
}
|
||||
if (match) {
|
||||
scanFrom += match.index + match[0].length;
|
||||
options.onEvent?.({
|
||||
type: "matched",
|
||||
stepId: step.id,
|
||||
match: match[0],
|
||||
});
|
||||
} else {
|
||||
options.onEvent?.({ type: "timeout", stepId: step.id });
|
||||
if (step.onTimeout === "stop") {
|
||||
throw new Error(`Timed out waiting for /${step.pattern}/`);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
await runSteps(macro.steps);
|
||||
options.onEvent?.({ type: "complete" });
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
}
|
||||
|
||||
/** Stored macros, bounded and cleaned; accepts the array or its JSON. */
|
||||
export function sanitizeTerminalMacros(value: unknown): TerminalMacro[] {
|
||||
if (value === null || value === undefined || value === "") return [];
|
||||
try {
|
||||
const parsed = typeof value === "string" ? JSON.parse(value) : value;
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
let remainingSteps = 500;
|
||||
const sanitizeSteps = (input: unknown, depth: number): MacroStep[] => {
|
||||
if (!Array.isArray(input) || depth > 4 || remainingSteps <= 0) return [];
|
||||
return input.flatMap((raw): MacroStep[] => {
|
||||
if (!raw || typeof raw !== "object" || remainingSteps-- <= 0) return [];
|
||||
const step = raw as Record<string, unknown>;
|
||||
const stepId =
|
||||
typeof step.id === "string" ? step.id : `step-${remainingSteps}`;
|
||||
if (step.type === "send") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "send",
|
||||
text:
|
||||
typeof step.text === "string" ? step.text.slice(0, 65_536) : "",
|
||||
pressEnter: step.pressEnter !== false,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "delay") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "delay",
|
||||
milliseconds: bounded(Number(step.milliseconds), 0, 300_000),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "wait") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "wait",
|
||||
pattern:
|
||||
typeof step.pattern === "string"
|
||||
? step.pattern.slice(0, 1000)
|
||||
: "",
|
||||
isRegex: step.isRegex !== false,
|
||||
flags:
|
||||
typeof step.flags === "string"
|
||||
? step.flags.replace(/[^imsu]/g, "")
|
||||
: undefined,
|
||||
timeoutMs: bounded(Number(step.timeoutMs), 100, 300_000),
|
||||
onTimeout: step.onTimeout === "continue" ? "continue" : "stop",
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "if") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "if",
|
||||
pattern:
|
||||
typeof step.pattern === "string"
|
||||
? step.pattern.slice(0, 1000)
|
||||
: "",
|
||||
isRegex: step.isRegex !== false,
|
||||
flags:
|
||||
typeof step.flags === "string"
|
||||
? step.flags.replace(/[^imsu]/g, "")
|
||||
: undefined,
|
||||
then: sanitizeSteps(step.then, depth + 1),
|
||||
else: sanitizeSteps(step.else, depth + 1),
|
||||
},
|
||||
];
|
||||
}
|
||||
if (step.type === "repeat") {
|
||||
return [
|
||||
{
|
||||
id: stepId,
|
||||
type: "repeat",
|
||||
count: bounded(Number(step.count), 1, 100),
|
||||
steps: sanitizeSteps(step.steps, depth + 1),
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
};
|
||||
return parsed.slice(0, 100).flatMap((raw): TerminalMacro[] => {
|
||||
if (!raw || typeof raw !== "object") return [];
|
||||
const macro = raw as Record<string, unknown>;
|
||||
if (typeof macro.id !== "string" || typeof macro.name !== "string")
|
||||
return [];
|
||||
const now = new Date().toISOString();
|
||||
return [
|
||||
{
|
||||
id: macro.id,
|
||||
name: macro.name.slice(0, 200),
|
||||
description:
|
||||
typeof macro.description === "string"
|
||||
? macro.description.slice(0, 2000)
|
||||
: undefined,
|
||||
steps: sanitizeSteps(macro.steps, 0),
|
||||
createdAt:
|
||||
typeof macro.createdAt === "string" ? macro.createdAt : now,
|
||||
updatedAt:
|
||||
typeof macro.updatedAt === "string" ? macro.updatedAt : now,
|
||||
},
|
||||
];
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTerminalMacros(value?: string | null): TerminalMacro[] {
|
||||
return sanitizeTerminalMacros(value);
|
||||
}
|
||||
|
||||
/** Where 2.8 kept macros for a user whose storage mode was local. */
|
||||
export const LOCAL_MACROS_KEY = "terminalMacros";
|
||||
|
||||
/**
|
||||
* The saved macros plus any this browser kept on its own before 2.9.0 that
|
||||
* the saved list does not already have, or null when there is nothing to
|
||||
* move. `clear` removes the browser copy once the merged list is saved.
|
||||
*/
|
||||
export function mergeLocalMacros(
|
||||
saved: TerminalMacro[],
|
||||
storage: Pick<Storage, "getItem" | "removeItem"> | null = safeStorage(),
|
||||
): { macros: TerminalMacro[]; clear: () => void } | null {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = storage?.getItem(LOCAL_MACROS_KEY) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (raw === null) return null;
|
||||
const clear = () => {
|
||||
try {
|
||||
storage?.removeItem(LOCAL_MACROS_KEY);
|
||||
} catch {
|
||||
// storage unavailable
|
||||
}
|
||||
};
|
||||
const known = new Set(saved.map((macro) => macro.id));
|
||||
const extra = sanitizeTerminalMacros(raw).filter(
|
||||
(macro) => !known.has(macro.id),
|
||||
);
|
||||
if (extra.length === 0) {
|
||||
clear();
|
||||
return null;
|
||||
}
|
||||
return { macros: [...saved, ...extra].slice(0, 100), clear };
|
||||
}
|
||||
|
||||
function safeStorage(): Storage | null {
|
||||
try {
|
||||
return typeof localStorage === "undefined" ? null : localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export function HostTerminalSection({
|
||||
setField,
|
||||
updateForm,
|
||||
host,
|
||||
snippets,
|
||||
}: HostEditorSectionProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -18,7 +17,6 @@ export function HostTerminalSection({
|
||||
setField={setField}
|
||||
updateForm={updateForm}
|
||||
host={host}
|
||||
snippets={snippets}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -59,22 +59,16 @@ type HostPluginSettings = Record<string, Record<string, unknown>>;
|
||||
/**
|
||||
* The host editor's terminal appearance and behavior cards. The values are
|
||||
* this plugin's host settings, kept on the editor form under pluginSettings
|
||||
* and saved with the host; the startup snippet is still a core host field.
|
||||
* and saved with the host.
|
||||
*/
|
||||
export function HostTerminalSettings({
|
||||
form: editorForm,
|
||||
setField: setCoreField,
|
||||
updateForm,
|
||||
host,
|
||||
snippets: rawSnippets,
|
||||
}: Pick<
|
||||
HostEditorSectionProps,
|
||||
"form" | "setField" | "updateForm" | "host" | "snippets"
|
||||
>) {
|
||||
}: Pick<HostEditorSectionProps, "form" | "setField" | "updateForm" | "host">) {
|
||||
const { t } = useTranslation();
|
||||
const client = useTerminalClientSettings();
|
||||
const userSettings = useSettings("user");
|
||||
const snippets = (rawSnippets ?? []) as { id: number; name: string }[];
|
||||
const { setPreviewTerminalTheme } = useTabsSafe();
|
||||
|
||||
const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[
|
||||
@@ -91,8 +85,7 @@ export function HostTerminalSettings({
|
||||
const form = {
|
||||
...own,
|
||||
...(own.inheritAppearance ? userDefaults : {}),
|
||||
startupSnippetId: (editorForm?.startupSnippetId ?? null) as number | null,
|
||||
} as HostTerminalValues & { startupSnippetId: number | null };
|
||||
} as HostTerminalValues;
|
||||
|
||||
const writeValues = (values: Record<string, unknown>) =>
|
||||
updateForm((current) => {
|
||||
@@ -883,30 +876,6 @@ export function HostTerminalSettings({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-border pt-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
|
||||
{t("hosts.startupSnippetLabel")}
|
||||
</label>
|
||||
<Select2
|
||||
value={form.startupSnippetId ?? ""}
|
||||
onChange={(e) =>
|
||||
setCoreField(
|
||||
"startupSnippetId",
|
||||
e.target.value ? Number(e.target.value) : null,
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full border border-border bg-background px-3 py-1 text-xs outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="">{t("hosts.none")}</option>
|
||||
{snippets.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionCard>
|
||||
</>
|
||||
|
||||
@@ -74,7 +74,7 @@ import { toast } from "sonner";
|
||||
import { Save } from "lucide-react";
|
||||
import { TerminalToolbar } from "./TerminalToolbar.tsx";
|
||||
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
||||
import type { Host, Snippet, TabType } from "../types";
|
||||
import type { Host, TabType } from "../types";
|
||||
import { isTabKeyEvent } from "./terminal-key-event.ts";
|
||||
import { installTouchWheelCoordinator } from "./touch-wheel-coordinator.ts";
|
||||
import { loadTouchInputSettings } from "./touch-input-settings-store.ts";
|
||||
@@ -85,6 +85,7 @@ import {
|
||||
import { quoteTerminalImagePath } from "./terminal-image-path.ts";
|
||||
import {
|
||||
dispatchKeybindingAction,
|
||||
isTerminalKeybindingAction,
|
||||
sendRawToSocket,
|
||||
} from "../lib/keybinding-dispatch";
|
||||
import {
|
||||
@@ -114,7 +115,6 @@ import {
|
||||
Button,
|
||||
hydrateLocalSharedHostAuth,
|
||||
findMatchingKeybinding,
|
||||
SnippetVariablesDialog,
|
||||
type CustomKeybinding,
|
||||
isElectron,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
@@ -124,6 +124,7 @@ import {
|
||||
usePluginApi,
|
||||
useSlotContributions,
|
||||
getCustomKeybindings,
|
||||
runKeybindingAction,
|
||||
getClientPreference,
|
||||
logActivity,
|
||||
getHostPassword,
|
||||
@@ -164,17 +165,6 @@ interface SSHTerminalProps {
|
||||
onSessionReady?: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The host's startup snippet. Core still keeps it in the host's terminal
|
||||
* config until snippets owns it.
|
||||
*/
|
||||
function startupSnippetOf(host: object): number | null {
|
||||
const config = (host as { terminalConfig?: { startupSnippetId?: unknown } })
|
||||
.terminalConfig;
|
||||
const id = Number(config?.startupSnippetId);
|
||||
return Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g;
|
||||
|
||||
function updateAlternateScreenMode(output: string, currentMode: boolean) {
|
||||
@@ -291,11 +281,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
|
||||
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
|
||||
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
||||
const [pendingKeybindingSnippet, setPendingKeybindingSnippet] = useState<{
|
||||
id: string;
|
||||
content: string;
|
||||
appendEnter: boolean;
|
||||
} | null>(null);
|
||||
const resizeTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const wasDisconnectedBySSH = useRef(false);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -1966,7 +1951,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
| null
|
||||
| undefined
|
||||
)?.environmentVariables ?? [];
|
||||
const startupSnippetId = startupSnippetOf(settingsHost);
|
||||
|
||||
if (environmentVariables.length > 0) {
|
||||
for (const envVar of environmentVariables) {
|
||||
@@ -1981,36 +1965,26 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
}
|
||||
}
|
||||
|
||||
if (startupSnippetId) {
|
||||
try {
|
||||
const resolved = (await invokeAction(
|
||||
"snippets.resolveForTerminal",
|
||||
startupSnippetId,
|
||||
{
|
||||
ip: hostConfig.ip,
|
||||
username: hostConfig.username,
|
||||
port: hostConfig.port,
|
||||
name: hostConfig.name,
|
||||
},
|
||||
)) as
|
||||
| { needsInputs: boolean; content: string }
|
||||
| null
|
||||
| undefined;
|
||||
if (
|
||||
resolved &&
|
||||
!resolved.needsInputs &&
|
||||
ws.readyState === 1
|
||||
) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "input",
|
||||
data: resolved.content + "\n",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to execute startup snippet:", err);
|
||||
// The snippets plugin owns a host's startup command; while it
|
||||
// is off nothing runs.
|
||||
try {
|
||||
const startup = await invokeAction(
|
||||
"snippets.startupCommand",
|
||||
settingsHost,
|
||||
{
|
||||
ip: hostConfig.ip,
|
||||
username: hostConfig.username,
|
||||
port: hostConfig.port,
|
||||
name: hostConfig.name,
|
||||
},
|
||||
);
|
||||
if (typeof startup === "string" && ws.readyState === 1) {
|
||||
ws.send(
|
||||
JSON.stringify({ type: "input", data: startup + "\n" }),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Failed to run the startup command:", err);
|
||||
}
|
||||
|
||||
if (settings.autoMosh && ws.readyState === 1) {
|
||||
@@ -2988,26 +2962,30 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
e,
|
||||
customKeybindingsRef.current,
|
||||
);
|
||||
if (matched) {
|
||||
// The terminal's own actions run here; anything else goes to
|
||||
// whoever registered it (the shell, or another plugin), and the key
|
||||
// passes through while nothing does.
|
||||
const handled =
|
||||
!!matched &&
|
||||
(isTerminalKeybindingAction(matched.action.type)
|
||||
? dispatchKeybindingAction(matched.action, {
|
||||
terminal,
|
||||
webSocketRef,
|
||||
writeTextToClipboard,
|
||||
readTextFromClipboard,
|
||||
})
|
||||
: runKeybindingAction(matched.action, {
|
||||
host: {
|
||||
ip: hostConfig.ip,
|
||||
username: hostConfig.username,
|
||||
port: hostConfig.port,
|
||||
name: hostConfig.name,
|
||||
},
|
||||
send: (data) => sendRawToSocket(webSocketRef, data),
|
||||
}));
|
||||
if (handled) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dispatchKeybindingAction(matched.action, {
|
||||
terminal,
|
||||
webSocketRef,
|
||||
writeTextToClipboard,
|
||||
readTextFromClipboard,
|
||||
hostContext: {
|
||||
ip: hostConfig.ip,
|
||||
username: hostConfig.username,
|
||||
port: hostConfig.port,
|
||||
name: hostConfig.name,
|
||||
},
|
||||
onSnippetNeedsInputs: (snippet) =>
|
||||
setPendingKeybindingSnippet({
|
||||
...snippet,
|
||||
appendEnter: matched.action.appendEnter !== false,
|
||||
}),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -3834,51 +3812,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
/>
|
||||
)}
|
||||
|
||||
{pendingKeybindingSnippet && (
|
||||
<SnippetVariablesDialog
|
||||
snippet={
|
||||
{
|
||||
id: 0,
|
||||
name: t("newUi.sidebar.keybindings.actionRunSnippet"),
|
||||
content: pendingKeybindingSnippet.content,
|
||||
folder: null,
|
||||
order: 0,
|
||||
} as Snippet as never
|
||||
}
|
||||
host={{
|
||||
ip: hostConfig.ip,
|
||||
username: hostConfig.username,
|
||||
port: hostConfig.port,
|
||||
name: hostConfig.name,
|
||||
}}
|
||||
onCancel={() => setPendingKeybindingSnippet(null)}
|
||||
onConfirm={(resolvedContent) => {
|
||||
const appendEnter = pendingKeybindingSnippet.appendEnter;
|
||||
setPendingKeybindingSnippet(null);
|
||||
const send = () =>
|
||||
sendRawToSocket(
|
||||
webSocketRef,
|
||||
resolvedContent + (appendEnter ? "\r" : ""),
|
||||
);
|
||||
const shouldConfirm =
|
||||
localStorage.getItem("confirmSnippetExecution") === "true";
|
||||
if (shouldConfirm) {
|
||||
confirmWithToast(
|
||||
t("newUi.sidebar.snippets.confirmRunMessage", {
|
||||
name: t("newUi.sidebar.keybindings.actionRunSnippet"),
|
||||
}),
|
||||
send,
|
||||
t("newUi.sidebar.snippets.confirmRunButton"),
|
||||
t("newUi.sidebar.snippets.cancel"),
|
||||
{ confirmOnEnter: true, duration: 6000 },
|
||||
);
|
||||
} else {
|
||||
send();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tmuxSessionPicker && (
|
||||
<TmuxSessionPicker
|
||||
isOpen={true}
|
||||
|
||||
Reference in new issue
Block a user