mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -26,7 +26,7 @@ const RESOURCE_TYPES = [
|
||||
"user",
|
||||
"host",
|
||||
"credential",
|
||||
"snippet",
|
||||
"plugin",
|
||||
"session",
|
||||
"api_key",
|
||||
"setting",
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -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>}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
|
||||
@@ -30,7 +30,7 @@ export interface HostEditorSectionRenderProps {
|
||||
) => void;
|
||||
host?: unknown;
|
||||
credentials?: unknown[];
|
||||
snippets?: unknown[];
|
||||
adminTargetUserId?: string;
|
||||
protocols: Record<string, boolean>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user