feat: move snippets, keybinding actions and macros out of core into plugins

This commit is contained in:
LukeGus committed 2026-09-29 11:39:14 -05:00
1 parent d9bba10612
commit e0c30a4e6f
262 files changed
+9091 -10744

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@ const RESOURCE_TYPES = [
"user",
"host",
"credential",
"snippet",
"plugin",
"session",
"api_key",
"setting",
+1 -1
View File
@@ -245,7 +245,7 @@ export function AdminEditUserDialog({
</span>
{editUserRolesLoading ? (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.snippets.loading")}
{t("common.loading")}
</span>
) : (
<>
+1 -12
View File
@@ -6,7 +6,6 @@ import { Input } from "@/components/input";
import { PasswordInput } from "@/components/password-input";
import { Globe, Pencil, Plus, Shield, Upload, X } from "lucide-react";
import { toast } from "sonner";
import { listSnippets } from "@/lib/snippet-provider";
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
import {
createSSHHost,
@@ -28,7 +27,6 @@ import {
applyHostDraft,
buildHostEditorPayload,
createHostEditorForm,
mapSnippetResponse,
omitOwnerSshAuthFromSharedEdit,
type HostAuthType,
type HostProtocols,
@@ -116,7 +114,6 @@ export function HostEditor({
};
const [saving, setSaving] = useState(false);
const [snippets, setSnippets] = useState<{ id: number; name: string }[]>([]);
const [isOidcUser, setIsOidcUser] = useState(false);
const [showSecretSources, setShowSecretSources] = useState(false);
const [quickCredentialName, setQuickCredentialName] = useState("");
@@ -131,14 +128,6 @@ export function HostEditor({
.catch(() => {});
}, []);
useEffect(() => {
listSnippets(
adminTargetUserId ? { targetUserId: adminTargetUserId } : undefined,
)
.then((res) => setSnippets(mapSnippetResponse(res)))
.catch(() => {});
}, [adminTargetUserId]);
useEffect(() => {
if (host) {
setForm(createHostEditorForm(host));
@@ -1122,7 +1111,7 @@ export function HostEditor({
updateForm={updateForm}
host={host}
credentials={availableCredentials}
snippets={snippets}
adminTargetUserId={adminTargetUserId}
protocols={protocols as unknown as Record<string, boolean>}
/>
);
-27
View File
@@ -14,13 +14,6 @@ export type { HostProtocols };
export type HostAuthType = Host["authType"];
type SnippetListItem = {
id: number;
name?: string;
title?: string;
};
type SnippetResponse = SnippetListItem[] | { snippets?: SnippetListItem[] };
/**
* Whether the Connection Origin control is meaningful for a host.
*
@@ -33,18 +26,6 @@ export function connectionOriginAppliesTo(protocols: HostProtocols): boolean {
return Object.values(protocols).some(Boolean);
}
export function mapSnippetResponse(
res: unknown,
): { id: number; name: string }[] {
const snippetRes = res as SnippetResponse;
return (
Array.isArray(snippetRes) ? snippetRes : (snippetRes.snippets ?? [])
).map((s) => ({
id: s.id,
name: s.name ?? s.title ?? `Snippet ${s.id}`,
}));
}
/** Overlays a plugin's draft on a new host's form. */
export function applyHostDraft(
form: HostEditorForm,
@@ -110,7 +91,6 @@ export function createHostEditorForm(
"local" | "remote" | null,
localOnly: host?.localOnly ?? false,
forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false,
startupSnippetId: host?.terminalConfig?.startupSnippetId ?? null,
sudoPassword: host?.hasSudoPassword
? "existing_sudo_password"
: (host?.sudoPassword ?? ""),
@@ -127,8 +107,6 @@ export function createHostEditorForm(
portKnockSequence:
host?.portKnockSequence ??
([] as { port: number; protocol: "tcp" | "udp"; delay: number }[]),
quickActions:
host?.quickActions ?? ([] as { name: string; snippetId: string }[]),
rdpCredentialId: host?.rdpCredentialId ?? "",
rdpUser: host?.rdpUser ?? "",
rdpPassword: host?.hasRdpPassword
@@ -320,10 +298,6 @@ export function buildHostEditorPayload(
// a number, and a string id does not compare equal on Postgres/MySQL.
jumpHosts: form.jumpHosts.map((j) => ({ hostId: Number(j.hostId) })),
portKnockSequence: form.portKnockSequence,
quickActions: form.quickActions.map((a) => ({
name: a.name,
snippetId: Number(a.snippetId),
})),
statusCheckEnabled: form.statusCheckEnabled,
statusCheckInterval: form.statusCheckInterval,
// Left out with SSH off, so the stored values stay as they are.
@@ -342,7 +316,6 @@ export function buildHostEditorPayload(
agentIdentity: usesAgent ? form.agentIdentity || null : null,
environmentVariables: form.environmentVariables,
},
terminalConfig: { startupSnippetId: form.startupSnippetId ?? null },
}
: {}),
};
-2
View File
@@ -29,7 +29,6 @@ const GROUPS: { key: FieldGroup; label: string }[] = [
{ key: "tags", label: "groupTags" },
{ key: "proxy", label: "groupProxy" },
{ key: "jumpHosts", label: "groupJumpHosts" },
{ key: "quickActions", label: "groupQuickActions" },
{ key: "featureFlags", label: "groupFeatureFlags" },
{ key: "advanced", label: "groupAdvanced" },
];
@@ -40,7 +39,6 @@ const DEFAULT_GROUPS: FieldGroup[] = [
"tags",
"proxy",
"jumpHosts",
"quickActions",
"featureFlags",
"advanced",
];
-5
View File
@@ -10,7 +10,6 @@ type RawSSHHost = SSHHostWithStatus & {
hasVncPassword?: boolean;
hasTelnetPassword?: boolean;
};
type HostQuickAction = Host["quickActions"][number];
type HostJumpHost = NonNullable<Host["jumpHosts"]>[number];
type RawCredential = {
isShared?: boolean;
@@ -107,10 +106,6 @@ export function sshHostToHost(h: SSHHostWithStatus): Host {
telnetUser: h.telnetUser,
telnetPassword: h.telnetPassword ?? "",
hasTelnetPassword: !!host.hasTelnetPassword || !!h.telnetPassword,
quickActions: (h.quickActions ?? []).map((a) => ({
name: a.name,
snippetId: String(a.snippetId),
})),
jumpHosts: (parseJson<HostJumpHost[]>(h.jumpHosts) ?? []).map((j) => ({
hostId: String(j.hostId ?? j.hostid ?? j),
})),
+1 -1
View File
@@ -30,7 +30,7 @@ export interface HostEditorSectionRenderProps {
) => void;
host?: unknown;
credentials?: unknown[];
snippets?: unknown[];
adminTargetUserId?: string;
protocols: Record<string, boolean>;
}
+144 -252
View File
@@ -6,12 +6,15 @@ import {
parseCustomKeybindings,
} from "@/api/open-tabs-api";
import { saveUserPreferences } from "@/main-axios";
import { listSnippets } from "@/lib/snippet-provider";
import { BUILT_IN_DEFAULTS } from "@/lib/default-keybindings";
import {
useKeybindingActions,
useKeybindingDefaults,
type KeybindingActionDef,
} from "@/shell/keybinding-registry";
import type {
CustomKeybinding,
KeybindingAction,
KeyCombo,
KeybindingActionType,
} from "@/types/keybindings";
import {
Dialog,
@@ -24,11 +27,6 @@ import { Button } from "@/components/button";
import { Input } from "@/components/input";
import { Pencil, Plus, RotateCcw, Trash2 } from "lucide-react";
interface SnippetOption {
id: number;
name: string;
}
function formatCombo(combo: KeyCombo): string {
const parts: string[] = [];
if (combo.ctrl) parts.push("Ctrl");
@@ -39,24 +37,29 @@ function formatCombo(combo: KeyCombo): string {
return parts.join(" + ");
}
const ACTION_LABEL_KEYS: Record<KeybindingActionType, string> = {
copy: "newUi.sidebar.keybindings.actionCopy",
paste: "newUi.sidebar.keybindings.actionPaste",
sendControlCode: "newUi.sidebar.keybindings.actionSendControlCode",
sendText: "newUi.sidebar.keybindings.actionSendText",
runSnippet: "newUi.sidebar.keybindings.actionRunSnippet",
nextTab: "newUi.sidebar.keybindings.actionNextTab",
previousTab: "newUi.sidebar.keybindings.actionPreviousTab",
openCommandPalette: "newUi.sidebar.keybindings.actionOpenCommandPalette",
reconnectSession: "newUi.sidebar.keybindings.actionReconnectSession",
};
function generateId(): string {
return typeof crypto !== "undefined" && "randomUUID" in crypto
? crypto.randomUUID()
: `kb-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
/** An action without the keys an editor cleared, so it saves clean. */
function withoutEmpty(action: KeybindingAction): KeybindingAction {
const out: KeybindingAction = { type: action.type };
for (const [key, value] of Object.entries(action)) {
if (key !== "type" && value !== undefined && value !== null) {
out[key] = value;
}
}
return out;
}
/**
* Appearance > Keybindings. The actions come from the keybinding registry:
* the shell's own and whatever the running plugins register, each with its
* own parameter editor. A saved binding whose plugin is off stays in the
* list and keeps working once the plugin is back.
*/
export function KeybindingsDialog({
open,
onOpenChange,
@@ -65,9 +68,10 @@ export function KeybindingsDialog({
onOpenChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
const actions = useKeybindingActions();
const defaults = useKeybindingDefaults();
const [bindings, setBindings] = useState<CustomKeybinding[]>([]);
const [loading, setLoading] = useState(false);
const [snippets, setSnippets] = useState<SnippetOption[]>([]);
const [formOpen, setFormOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [overridesDefaultId, setOverridesDefaultId] = useState<
@@ -76,32 +80,22 @@ export function KeybindingsDialog({
const [recordedCombo, setRecordedCombo] = useState<KeyCombo | null>(null);
const [recording, setRecording] = useState(false);
const [actionType, setActionType] =
useState<KeybindingActionType>("sendText");
const [text, setText] = useState("");
const [appendEnter, setAppendEnter] = useState(false);
const [controlCode, setControlCode] = useState("");
const [snippetId, setSnippetId] = useState("");
const [draft, setDraft] = useState<KeybindingAction>({ type: "" });
const actionById = useMemo(
() => new Map(actions.map((action) => [action.id, action])),
[actions],
);
const draftDef: KeybindingActionDef | undefined = actionById.get(draft.type);
useEffect(() => {
if (!open) return;
setLoading(true);
Promise.all([
getUserPreferences().catch(() => null),
listSnippets().catch(() => []),
])
.then(([prefs, snippetList]) => {
setBindings(
prefs ? parseCustomKeybindings(prefs.customKeybindings) : [],
);
const list = Array.isArray(snippetList) ? snippetList : [];
setSnippets(
list.map((s: { id: number; name: string }) => ({
id: s.id,
name: s.name,
})),
);
})
getUserPreferences()
.then((prefs) =>
setBindings(parseCustomKeybindings(prefs.customKeybindings)),
)
.catch(() => setBindings([]))
.finally(() => setLoading(false));
}, [open]);
@@ -125,16 +119,19 @@ export function KeybindingsDialog({
return null;
}, [recordedCombo, bindings, editingId, t]);
function actionLabel(type: string): string {
const def = actionById.get(type);
return def
? t(def.labelKey)
: t("newUi.sidebar.keybindings.unavailableAction", { type });
}
function resetForm() {
setEditingId(null);
setOverridesDefaultId(undefined);
setRecordedCombo(null);
setRecording(false);
setActionType("sendText");
setText("");
setAppendEnter(false);
setControlCode("");
setSnippetId("");
setDraft({ type: actions[0]?.id ?? "" });
}
function openAddForm() {
@@ -147,11 +144,7 @@ export function KeybindingsDialog({
setOverridesDefaultId(kb.overridesDefaultId);
setRecordedCombo(kb.combo);
setRecording(false);
setActionType(kb.action.type);
setText(kb.action.text ?? "");
setAppendEnter(kb.action.appendEnter ?? false);
setControlCode(kb.action.controlCode ?? "");
setSnippetId(kb.action.snippetId ?? "");
setDraft({ ...kb.action });
setFormOpen(true);
}
@@ -212,16 +205,14 @@ export function KeybindingsDialog({
toast.error(t("newUi.sidebar.keybindings.comboRequiredError"));
return;
}
if (actionType === "sendText" && !text.trim()) {
toast.error(t("newUi.sidebar.keybindings.textRequiredError"));
if (!draftDef) {
toast.error(t("newUi.sidebar.keybindings.actionRequiredError"));
return;
}
if (actionType === "sendControlCode" && !/^[a-zA-Z]$/.test(controlCode)) {
toast.error(t("newUi.sidebar.keybindings.controlCodeRequiredError"));
return;
}
if (actionType === "runSnippet" && !snippetId) {
toast.error(t("newUi.sidebar.keybindings.snippetRequiredError"));
const action = withoutEmpty(draft);
const problem = draftDef.validate?.(action);
if (problem) {
toast.error(t(problem));
return;
}
@@ -233,21 +224,7 @@ export function KeybindingsDialog({
const newBinding: CustomKeybinding = {
id: editingId ?? generateId(),
combo: recordedCombo,
action: {
type: actionType,
text: actionType === "sendText" ? text : undefined,
controlCode:
actionType === "sendControlCode"
? controlCode.toLowerCase()
: undefined,
snippetId: actionType === "runSnippet" ? snippetId : undefined,
appendEnter:
actionType === "runSnippet"
? true
: actionType === "sendText"
? appendEnter
: undefined,
},
action,
enabled: true,
overridesDefaultId,
createdAt: existing?.createdAt ?? now,
@@ -264,6 +241,7 @@ export function KeybindingsDialog({
}
const customOnlyBindings = bindings.filter((kb) => !kb.overridesDefaultId);
const Editor = draftDef?.editor;
return (
<>
@@ -279,78 +257,80 @@ export function KeybindingsDialog({
</DialogHeader>
<div className="flex flex-col gap-4 mt-1 max-h-[60vh] overflow-y-auto">
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.defaultsHeading")}
</span>
{BUILT_IN_DEFAULTS.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(def.descriptionKey)}
</span>
{defaults.length > 0 && (
<div className="flex flex-col gap-1.5">
<span className="text-xs font-semibold text-muted-foreground">
{t("newUi.sidebar.keybindings.defaultsHeading")}
</span>
{defaults.map((def) => {
const override = bindings.find(
(kb) => kb.overridesDefaultId === def.id,
);
return (
<div
key={def.id}
className="flex items-center justify-between gap-2 border border-border bg-background px-2.5 py-2"
>
<div className="flex flex-col min-w-0">
<span className="text-xs font-mono">
{override
? formatCombo(override.combo)
: formatCombo(def.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(def.descriptionKey)}
</span>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(override.id)}
title={t(
"newUi.sidebar.keybindings.resetToDefault",
)}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
{override ? (
<>
<span className="text-[10px] font-bold text-accent-brand border border-accent-brand/40 px-1">
{t("newUi.sidebar.keybindings.customizedBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => openEditForm(override)}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-destructive"
onClick={() => handleDelete(override.id)}
title={t(
"newUi.sidebar.keybindings.resetToDefault",
)}
>
<RotateCcw className="size-3.5" />
</Button>
</>
) : (
<>
<span className="text-[10px] text-muted-foreground border border-border px-1">
{t("newUi.sidebar.keybindings.defaultBadge")}
</span>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() =>
openOverrideDefaultForm(def.id, def.combo)
}
title={t("newUi.sidebar.keybindings.customize")}
>
<Pencil className="size-3.5" />
</Button>
</>
)}
</div>
</div>
);
})}
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center justify-between">
@@ -372,9 +352,7 @@ export function KeybindingsDialog({
</span>
) : (
customOnlyBindings.map((kb) => {
const orphaned =
kb.action.type === "runSnippet" &&
!snippets.some((s) => String(s.id) === kb.action.snippetId);
const Summary = actionById.get(kb.action.type)?.summary;
return (
<div
key={kb.id}
@@ -385,16 +363,12 @@ export function KeybindingsDialog({
{formatCombo(kb.combo)}
</span>
<span className="text-xs text-muted-foreground truncate">
{t(ACTION_LABEL_KEYS[kb.action.type])}
{orphaned && (
<span className="text-destructive">
{actionLabel(kb.action.type)}
{Summary && (
<>
{" "}
(
{t(
"newUi.sidebar.keybindings.orphanedSnippetWarning",
)}
)
</span>
<Summary action={kb.action} onChange={() => {}} />
</>
)}
</span>
</div>
@@ -482,104 +456,22 @@ export function KeybindingsDialog({
{t("newUi.sidebar.keybindings.actionLabel")}
</label>
<select
value={actionType}
onChange={(e) =>
setActionType(e.target.value as KeybindingActionType)
}
value={draft.type}
onChange={(e) => setDraft({ type: e.target.value })}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="copy">
{t("newUi.sidebar.keybindings.actionCopy")}
</option>
<option value="paste">
{t("newUi.sidebar.keybindings.actionPaste")}
</option>
<option value="sendControlCode">
{t("newUi.sidebar.keybindings.actionSendControlCode")}
</option>
<option value="sendText">
{t("newUi.sidebar.keybindings.actionSendText")}
</option>
<option value="runSnippet">
{t("newUi.sidebar.keybindings.actionRunSnippet")}
</option>
<option value="nextTab">
{t("newUi.sidebar.keybindings.actionNextTab")}
</option>
<option value="previousTab">
{t("newUi.sidebar.keybindings.actionPreviousTab")}
</option>
<option value="openCommandPalette">
{t("newUi.sidebar.keybindings.actionOpenCommandPalette")}
</option>
<option value="reconnectSession">
{t("newUi.sidebar.keybindings.actionReconnectSession")}
</option>
{!draftDef && draft.type && (
<option value={draft.type}>{actionLabel(draft.type)}</option>
)}
{actions.map((action) => (
<option key={action.id} value={action.id}>
{t(action.labelKey)}
</option>
))}
</select>
{actionType === "paste" && (
<span className="text-xs text-muted-foreground">
{t("newUi.sidebar.keybindings.clipboardPermissionNote")}
</span>
)}
</div>
{actionType === "sendControlCode" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.controlCodeLabel")}
</label>
<Input
value={controlCode}
maxLength={1}
onChange={(e) => setControlCode(e.target.value)}
placeholder="w"
className="w-16 font-mono"
/>
</div>
)}
{actionType === "sendText" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.textLabel")}
</label>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
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) => setAppendEnter(e.target.checked)}
/>
{t("newUi.sidebar.keybindings.appendEnterLabel")}
</label>
</div>
)}
{actionType === "runSnippet" && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-semibold">
{t("newUi.sidebar.keybindings.snippetLabel")}
</label>
<select
value={snippetId}
onChange={(e) => setSnippetId(e.target.value)}
className="h-8 border border-border bg-background px-2 text-xs outline-none focus:ring-1 focus:ring-ring"
>
<option value="">
{t("newUi.sidebar.keybindings.selectSnippetPlaceholder")}
</option>
{snippets.map((s) => (
<option key={s.id} value={String(s.id)}>
{s.name}
</option>
))}
</select>
</div>
)}
{Editor && <Editor action={draft} onChange={setDraft} />}
</div>
<div className="flex items-center justify-end gap-2 mt-2">
<Button variant="ghost" onClick={() => setFormOpen(false)}>
-856
View File
@@ -1,856 +0,0 @@
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 (
<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>
);
}
export function MacrosPanel({
terminalTabs,
activeTabId,
storageMode,
}: {
terminalTabs: Tab[];
activeTabId: string;
storageMode: "local" | "cloud";
}) {
const { t } = useTranslation();
const [macros, setMacros] = useState<TerminalMacro[]>([]);
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());
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 (
<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>
);
}
-28
View File
@@ -498,7 +498,6 @@ export function UserProfilePanel({
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
showPinAppRailButton?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
hiddenRailTabs?: string | null;
@@ -620,13 +619,6 @@ export function UserProfilePanel({
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
readRailPreference("showPinAppRailButton"),
);
// Read value is unused; the setter still backs the cloud-sync/reset/
// snapshot machinery for this localStorage-backed pref below. The
// folder-collapse setting itself now lives in the snippets plugin's own
// user settings.
const [_confirmSnippetExecution, setConfirmSnippetExecution] = useState(
() => localStorage.getItem("confirmSnippetExecution") === "true",
);
const [disableUpdateCheck, setDisableUpdateCheck] = useState(
() => localStorage.getItem("disableUpdateCheck") === "true",
);
@@ -763,8 +755,6 @@ export function UserProfilePanel({
"pinAppRail",
"expandAppRailOnHover",
"showPinAppRailButton",
"snippetShowCommands",
"confirmSnippetExecution",
"disableUpdateCheck",
"confirmTabClose",
"hiddenRailTabs",
@@ -830,13 +820,6 @@ export function UserProfilePanel({
);
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
}
if (prefs.confirmSnippetExecution != null) {
setConfirmSnippetExecution(prefs.confirmSnippetExecution);
localStorage.setItem(
"confirmSnippetExecution",
String(prefs.confirmSnippetExecution),
);
}
if (prefs.disableUpdateCheck != null) {
setDisableUpdateCheck(prefs.disableUpdateCheck);
localStorage.setItem(
@@ -902,8 +885,6 @@ export function UserProfilePanel({
setShowPinAppRailButton(false);
localStorage.setItem("showPinAppRailButton", "false");
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
setConfirmSnippetExecution(false);
localStorage.setItem("confirmSnippetExecution", "false");
setDisableUpdateCheck(false);
localStorage.setItem("disableUpdateCheck", "false");
setConfirmTabClose(false);
@@ -924,7 +905,6 @@ export function UserProfilePanel({
pinAppRail: false,
expandAppRailOnHover: true,
showPinAppRailButton: false,
confirmSnippetExecution: false,
disableUpdateCheck: false,
confirmTabClose: false,
hiddenRailTabs: "[]",
@@ -1007,14 +987,6 @@ export function UserProfilePanel({
localStorage.setItem("showPinAppRailButton", String(restoredShowPinButton));
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
const restoredConfirmSnippet =
restore("confirmSnippetExecution", "false") === "true";
setConfirmSnippetExecution(restoredConfirmSnippet);
localStorage.setItem(
"confirmSnippetExecution",
String(restoredConfirmSnippet),
);
const restoredUpdateCheck =
restore("disableUpdateCheck", "false") === "true";
setDisableUpdateCheck(restoredUpdateCheck);
-2
View File
@@ -4,7 +4,6 @@ export type FieldGroup =
| "tags"
| "proxy"
| "jumpHosts"
| "quickActions"
| "featureFlags"
| "advanced";
@@ -37,7 +36,6 @@ const FIELD_GROUP_KEYS: Record<FieldGroup, string[]> = {
"socks5ProxyChain",
],
jumpHosts: ["jumpHosts"],
quickActions: ["quickActions"],
// Every plugin's host settings, enable switches included.
featureFlags: ["pluginSettings", "forceKeyboardInteractive"],
advanced: [
-1
View File
@@ -71,7 +71,6 @@ export function createQuickConnectHost(input: QuickConnectInput): Host {
ram: null,
lastAccess: new Date().toISOString(),
pin: false,
quickActions: [],
enableSsh: true,
sshPort: input.port,
quickConnectSavable:
-9
View File
@@ -1,5 +1,4 @@
import {
Braces,
Cloud,
KeyRound,
Plug,
@@ -93,14 +92,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
separatorAfter: true,
mobilePrimary: true,
},
{
id: "macros",
icon: Braces,
labelKey: "nav.macros",
separatorAfter: true,
promotable: true,
rightDockable: true,
},
{
id: "sync",
icon: Cloud,
-7
View File
@@ -636,15 +636,8 @@ export function SidebarTree({
hostId: Number(j.hostId),
})),
portKnockSequence: host.portKnockSequence ?? [],
quickActions: (host.quickActions ?? []).map((a) => ({
name: a.name,
snippetId: Number(a.snippetId),
})),
statusCheckEnabled: host.statusCheckEnabled,
statusCheckInterval: host.statusCheckInterval,
terminalConfig: host.terminalConfig?.startupSnippetId
? { startupSnippetId: host.terminalConfig.startupSnippetId }
: null,
sshOptions: host.sshOptions ?? null,
};
const created = await createSSHHost(duplicateHost);