diff --git a/src/ui/AppShell.tsx b/src/ui/AppShell.tsx
index 2df54e6be..1d8d76ba9 100644
--- a/src/ui/AppShell.tsx
+++ b/src/ui/AppShell.tsx
@@ -86,9 +86,6 @@ const SshToolsPanel = lazy(() =>
const CollabPanel = lazy(() =>
import("@/sidebar/CollabPanel").then((m) => ({ default: m.CollabPanel })),
);
-const SnippetsPanel = lazy(() =>
- import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
-);
const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
);
@@ -1018,7 +1015,6 @@ export function AppShell({
"hostTrayOnClick",
"pinAppRail",
"expandAppRailOnHover",
- "defaultSnippetFoldersCollapsed",
"confirmSnippetExecution",
"disableUpdateCheck",
"confirmTabClose",
@@ -1086,14 +1082,6 @@ export function AppShell({
);
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
}
- if (
- prefs.foldersCollapsed !== null &&
- prefs.foldersCollapsed !== undefined
- )
- localStorage.setItem(
- "defaultSnippetFoldersCollapsed",
- String(prefs.foldersCollapsed),
- );
if (
prefs.confirmSnippetExecution !== null &&
prefs.confirmSnippetExecution !== undefined
@@ -2570,18 +2558,6 @@ export function AppShell({
)}
- {railView === "snippets" && (
-
-
-
- )}
-
{railView === "macros" && (
> {
try {
- const response = await authApi.get("/snippets", {
+ const response = await authApi.get("/plugin-api/snippets", {
headers: adminHeaders(targetUserId),
});
return response.data;
@@ -209,7 +209,7 @@ export async function adminCreateUserSnippet(
snippetData: Record,
): Promise> {
try {
- const response = await authApi.post("/snippets", snippetData, {
+ const response = await authApi.post("/plugin-api/snippets", snippetData, {
headers: adminHeaders(targetUserId),
});
return response.data;
@@ -224,9 +224,13 @@ export async function adminUpdateUserSnippet(
snippetData: Record,
): Promise> {
try {
- const response = await authApi.put(`/snippets/${snippetId}`, snippetData, {
- headers: adminHeaders(targetUserId),
- });
+ const response = await authApi.put(
+ `/plugin-api/snippets/${snippetId}`,
+ snippetData,
+ {
+ headers: adminHeaders(targetUserId),
+ },
+ );
return response.data;
} catch (error) {
throw handleApiError(error, "update user's snippet");
@@ -238,7 +242,7 @@ export async function adminDeleteUserSnippet(
snippetId: number,
): Promise> {
try {
- const response = await authApi.delete(`/snippets/${snippetId}`, {
+ const response = await authApi.delete(`/plugin-api/snippets/${snippetId}`, {
headers: adminHeaders(targetUserId),
});
return response.data;
diff --git a/src/ui/api/open-tabs-api.ts b/src/ui/api/open-tabs-api.ts
index 4320ab7fc..c4615b4b4 100644
--- a/src/ui/api/open-tabs-api.ts
+++ b/src/ui/api/open-tabs-api.ts
@@ -115,7 +115,6 @@ export interface UserPreferences {
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
showPinAppRailButton?: boolean | null;
- foldersCollapsed?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
diff --git a/src/ui/api/rbac-api.ts b/src/ui/api/rbac-api.ts
index 77e661cd8..1de0286b2 100644
--- a/src/ui/api/rbac-api.ts
+++ b/src/ui/api/rbac-api.ts
@@ -397,47 +397,6 @@ export async function setHostAuthOverride(
}
}
-// ============================================================================
-// SNIPPET SHARING
-// ============================================================================
-
-export async function shareSnippet(
- snippetId: number,
- shareData: {
- targetType: "user" | "role";
- targetUserId?: string;
- targetRoleId?: number;
- durationHours?: number;
- },
-): Promise<{ success: boolean }> {
- try {
- const response = await rbacApi.post(
- `/rbac/snippet/${snippetId}/share`,
- shareData,
- );
- return response.data;
- } catch (error) {
- throw handleApiError(error, "share snippet");
- }
-}
-
-export async function shareSnippetFolder(
- folder: string,
- targets: ShareTarget[],
- durationHours?: number,
-): Promise<{ success: boolean; snippetsShared: number }> {
- try {
- const response = await rbacApi.post("/rbac/snippet-folder/share", {
- folder,
- targets,
- durationHours,
- });
- return response.data;
- } catch (error) {
- throw handleApiError(error, "share snippet folder");
- }
-}
-
export type CredentialPermissionLevel = "use" | "manage";
export async function shareCredential(
@@ -480,48 +439,3 @@ export async function revokeCredentialAccess(
throw handleApiError(error, "revoke credential access");
}
}
-
-export async function getSnippetAccess(
- snippetId: number,
-): Promise<{ accessList: AccessRecord[] }> {
- try {
- const response = await rbacApi.get(`/rbac/snippet/${snippetId}/access`);
- return response.data;
- } catch (error) {
- throw handleApiError(error, "fetch snippet access");
- }
-}
-
-export async function revokeSnippetAccess(
- snippetId: number,
- accessId: number,
-): Promise<{ success: boolean }> {
- try {
- const response = await rbacApi.delete(
- `/rbac/snippet/${snippetId}/access/${accessId}`,
- );
- return response.data;
- } catch (error) {
- throw handleApiError(error, "revoke snippet access");
- }
-}
-
-export async function getSharedSnippets(): Promise<{
- sharedSnippets: Array<{
- id: number;
- name: string;
- content: string;
- description: string | null;
- folder: string | null;
- ownerUsername: string;
- permissionLevel: string;
- expiresAt: string | null;
- }>;
-}> {
- try {
- const response = await rbacApi.get("/rbac/shared-snippets");
- return response.data;
- } catch (error) {
- handleApiError(error, "fetch shared snippets");
- }
-}
diff --git a/src/ui/api/snippets-api.ts b/src/ui/api/snippets-api.ts
index 3eb043032..155fe49e9 100644
--- a/src/ui/api/snippets-api.ts
+++ b/src/ui/api/snippets-api.ts
@@ -3,6 +3,12 @@ import { authApi, handleApiError } from "@/main-axios";
/**
* A snippet row as the list endpoint returns it. Callers that need the full
* shape narrow it themselves; only the id is relied on across the app.
+ *
+ * Snippets themselves are owned by the snippets plugin
+ * (/plugin-api/snippets); this is the thin read/run surface used by a few
+ * core and plugin callers (the host editor's startup snippet, the command
+ * palette, custom keybindings, host-metrics quick actions) that cannot
+ * reach into the snippets plugin's own frontend code.
*/
export interface SnippetRow {
id: number;
@@ -11,54 +17,20 @@ export interface SnippetRow {
export async function getSnippets(): Promise {
try {
- const response = await authApi.get("/snippets");
+ const response = await authApi.get("/plugin-api/snippets");
return response.data;
} catch (error) {
throw handleApiError(error, "fetch snippets");
}
}
-export async function createSnippet(
- snippetData: Record,
-): Promise> {
- try {
- const response = await authApi.post("/snippets", snippetData);
- return response.data;
- } catch (error) {
- throw handleApiError(error, "create snippet");
- }
-}
-
-export async function updateSnippet(
- snippetId: number,
- snippetData: Record,
-): Promise> {
- try {
- const response = await authApi.put(`/snippets/${snippetId}`, snippetData);
- return response.data;
- } catch (error) {
- throw handleApiError(error, "update snippet");
- }
-}
-
-export async function deleteSnippet(
- snippetId: number,
-): Promise> {
- try {
- const response = await authApi.delete(`/snippets/${snippetId}`);
- return response.data;
- } catch (error) {
- throw handleApiError(error, "delete snippet");
- }
-}
-
export async function executeSnippet(
snippetId: number,
hostId: number,
inputValues?: Record,
): Promise<{ success: boolean; output: string; error?: string }> {
try {
- const response = await authApi.post("/snippets/execute", {
+ const response = await authApi.post("/plugin-api/snippets/execute", {
snippetId,
hostId,
...(inputValues ? { inputValues } : {}),
@@ -68,130 +40,3 @@ export async function executeSnippet(
throw handleApiError(error, "execute snippet");
}
}
-
-export async function getSnippetFolders(): Promise> {
- try {
- const response = await authApi.get("/snippets/folders");
- return response.data;
- } catch (error) {
- throw handleApiError(error, "fetch snippet folders");
- }
-}
-
-export async function createSnippetFolder(folderData: {
- name: string;
- color?: string;
- icon?: string;
-}): Promise> {
- try {
- const response = await authApi.post("/snippets/folders", folderData);
- return response.data;
- } catch (error) {
- throw handleApiError(error, "create snippet folder");
- }
-}
-
-export async function updateSnippetFolderMetadata(
- folderName: string,
- metadata: { color?: string; icon?: string },
-): Promise> {
- try {
- const response = await authApi.put(
- `/snippets/folders/${encodeURIComponent(folderName)}/metadata`,
- metadata,
- );
- return response.data;
- } catch (error) {
- throw handleApiError(error, "update snippet folder metadata");
- }
-}
-
-export async function renameSnippetFolder(
- oldName: string,
- newName: string,
-): Promise<{ success: boolean; oldName: string; newName: string }> {
- try {
- const response = await authApi.put("/snippets/folders/rename", {
- oldName,
- newName,
- });
- return response.data;
- } catch (error) {
- throw handleApiError(error, "rename snippet folder");
- }
-}
-
-export async function deleteSnippetFolder(
- folderName: string,
-): Promise<{ success: boolean }> {
- try {
- const response = await authApi.delete(
- `/snippets/folders/${encodeURIComponent(folderName)}`,
- );
- return response.data;
- } catch (error) {
- throw handleApiError(error, "delete snippet folder");
- }
-}
-
-export async function reorderSnippets(
- updates: Array<{ id: number; order: number; folder?: string }>,
-): Promise<{ success: boolean }> {
- try {
- const response = await authApi.put("/snippets/reorder", {
- snippets: updates,
- });
- return response.data;
- } catch (error) {
- throw handleApiError(error, "reorder snippets");
- }
-}
-
-export interface SnippetExportData {
- snippets: Array<{
- name: string;
- content: string;
- description?: string | null;
- folder?: string | null;
- order?: number;
- hostFilter?: string | null;
- }>;
- folders: Array<{
- name: string;
- color?: string | null;
- icon?: string | null;
- }>;
-}
-
-export async function exportSnippets(): Promise {
- try {
- const response = await authApi.get("/snippets/export");
- return response.data;
- } catch (error) {
- throw handleApiError(error, "export snippets");
- }
-}
-
-export async function importSnippets(
- data: SnippetExportData,
- overwrite: boolean,
-): Promise<{
- success: boolean;
- snippetsImported: number;
- snippetsSkipped: number;
- snippetsUpdated: number;
- foldersImported: number;
- foldersSkipped: number;
- failed: number;
- errors: string[];
-}> {
- try {
- const response = await authApi.post("/snippets/bulk-import", {
- ...data,
- overwrite,
- });
- return response.data;
- } catch (error) {
- throw handleApiError(error, "import snippets");
- }
-}
diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx
index 942147ca2..dd2267959 100644
--- a/src/ui/features/terminal/Terminal.tsx
+++ b/src/ui/features/terminal/Terminal.tsx
@@ -34,11 +34,11 @@ import {
getCookie,
isElectron,
logActivity,
- getSnippets,
deleteCommandFromHistory,
getCommandHistory,
getHostPassword,
} from "@/main-axios.ts";
+import { invokeAction } from "@/shell/action-registry";
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.tsx";
@@ -272,9 +272,6 @@ const TerminalInner = forwardRef(
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
const localEchoRef = useRef(null);
const customKeybindingsRef = useRef([]);
- const cachedSnippetsRef = useRef<{ id: number; content: string }[] | null>(
- null,
- );
const [pendingKeybindingSnippet, setPendingKeybindingSnippet] = useState<{
id: string;
content: string;
@@ -1961,16 +1958,28 @@ const TerminalInner = forwardRef(
if (terminalConfig.startupSnippetId) {
try {
- const snippets = await getSnippets();
- const snippet = snippets.find(
- (s: { id: number }) =>
- s.id === terminalConfig.startupSnippetId,
- );
- if (snippet && ws.readyState === 1) {
+ const resolved = (await invokeAction(
+ "snippets.resolveForTerminal",
+ terminalConfig.startupSnippetId,
+ {
+ ip: hostConfig.ip,
+ username: hostConfig.username,
+ port: hostConfig.port,
+ name: hostConfig.name,
+ },
+ )) as
+ | { needsInputs: boolean; content: string }
+ | null
+ | undefined;
+ if (
+ resolved &&
+ !resolved.needsInputs &&
+ ws.readyState === 1
+ ) {
ws.send(
JSON.stringify({
type: "input",
- data: snippet.content + "\n",
+ data: resolved.content + "\n",
}),
);
}
@@ -3141,7 +3150,6 @@ const TerminalInner = forwardRef(
useEffect(() => {
let cancelled = false;
const loadKeybindings = () => {
- cachedSnippetsRef.current = null;
getUserPreferences()
.then((prefs) => {
if (!cancelled) {
@@ -3165,23 +3173,6 @@ const TerminalInner = forwardRef(
useEffect(() => {
if (!terminal) return;
- const getSnippetById = async (
- id: string,
- ): Promise<{ content: string } | undefined> => {
- try {
- if (!cachedSnippetsRef.current) {
- cachedSnippetsRef.current = (await getSnippets()) as unknown as {
- id: number;
- content: string;
- }[];
- }
- const numericId = Number(id);
- return cachedSnippetsRef.current?.find((s) => s.id === numericId);
- } catch {
- return undefined;
- }
- };
-
const handleCustomKey = (e: KeyboardEvent): boolean => {
if (e.type !== "keydown") {
return true;
@@ -3202,7 +3193,6 @@ const TerminalInner = forwardRef(
webSocketRef,
writeTextToClipboard,
readTextFromClipboard,
- getSnippetById,
hostContext: {
ip: hostConfig.ip,
username: hostConfig.username,
diff --git a/src/ui/hooks/use-snippet-runner.tsx b/src/ui/hooks/use-snippet-runner.tsx
index 5bf924a17..4e1e0b2ca 100644
--- a/src/ui/hooks/use-snippet-runner.tsx
+++ b/src/ui/hooks/use-snippet-runner.tsx
@@ -2,28 +2,30 @@ import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
-import {
- hasSnippetInputs,
- resolveSnippetContent,
- type SnippetHostContext,
-} from "@/lib/snippet-variables";
+import { invokeAction } from "@/shell/action-registry";
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
import type { Snippet, Tab } from "@/types/ui-types";
+interface ResolvedForTerminal {
+ needsInputs: boolean;
+ content: string;
+ isNote: boolean;
+}
+
/**
- * Shared "run this snippet against these terminal tabs" flow: resolves
- * $HOST-style vars per target, prompts once for $INPUT_n placeholders when
- * present, and gates on the confirm-before-running setting. Used by both the
- * Snippets sidebar panel and the command palette's snippet entries so the
- * behavior (and the dialog) is identical everywhere a snippet gets sent to a
- * terminal.
+ * Shared "run this snippet against these terminal tabs" flow, for the few
+ * core surfaces (the command palette) that still reach for a snippet
+ * directly rather than through the snippets plugin's own panel. Variable
+ * resolution runs through the snippets plugin's "snippets.resolveForTerminal"
+ * action, and sending runs through ssh-terminal's "terminal.sendToSession",
+ * since core no longer owns snippet content or a terminal ref.
*/
export function useSnippetRunner() {
const { t } = useTranslation();
const { confirmWithToast } = useConfirmation();
const [runningSnippet, setRunningSnippet] = useState<{
snippet: Snippet;
- host: SnippetHostContext | null;
+ host: Tab["host"] | null;
onConfirm: (
resolvedContent: string,
inputValues: Record,
@@ -49,38 +51,41 @@ export function useSnippetRunner() {
[confirmWithToast, t],
);
- function sendResolvedToTerminal(
+ async function sendResolvedToTerminal(
tab: Tab,
snippet: Snippet,
inputValues: Record,
) {
- const content = resolveSnippetContent(
- snippet.content,
+ const resolved = (await invokeAction(
+ "snippets.resolveForTerminal",
+ snippet.id,
tab.host ?? null,
inputValues,
- );
- if (snippet.isNote) {
- tab.terminalRef?.current?.paste?.(content);
- } else {
- tab.terminalRef?.current?.sendInput?.(content + "\r");
- }
+ )) as ResolvedForTerminal | null | undefined;
+ if (!resolved || resolved.needsInputs) return;
+ await invokeAction("terminal.sendToSession", tab.id, resolved.content, {
+ run: !resolved.isNote,
+ });
}
const runSnippet = useCallback(
(snippet: Snippet, targets: Tab[]) => {
const runWithInputs = (inputValues: Record) => {
const doSend = () => {
- targets.forEach((tab) =>
- sendResolvedToTerminal(tab, snippet, inputValues),
- );
- toast.success(
- t(
- snippet.isNote
- ? "newUi.sidebar.snippets.pasteSuccess"
- : "newUi.sidebar.snippets.runSuccess",
- { name: snippet.name, count: targets.length },
+ void Promise.all(
+ targets.map((tab) =>
+ sendResolvedToTerminal(tab, snippet, inputValues),
),
- );
+ ).then(() => {
+ toast.success(
+ t(
+ snippet.isNote
+ ? "newUi.sidebar.snippets.pasteSuccess"
+ : "newUi.sidebar.snippets.runSuccess",
+ { name: snippet.name, count: targets.length },
+ ),
+ );
+ });
};
if (snippet.isNote) {
doSend();
@@ -89,18 +94,25 @@ export function useSnippetRunner() {
}
};
- if (hasSnippetInputs(snippet.content)) {
- setRunningSnippet({
- snippet,
- host: targets[0]?.host ?? null,
- onConfirm: (_resolvedContent, inputValues) => {
- setRunningSnippet(null);
- runWithInputs(inputValues);
- },
- });
- } else {
+ void invokeAction(
+ "snippets.resolveForTerminal",
+ snippet.id,
+ targets[0]?.host ?? null,
+ ).then((result) => {
+ const resolved = result as ResolvedForTerminal | null | undefined;
+ if (resolved?.needsInputs) {
+ setRunningSnippet({
+ snippet,
+ host: targets[0]?.host ?? null,
+ onConfirm: (_resolvedContent, inputValues) => {
+ setRunningSnippet(null);
+ runWithInputs(inputValues);
+ },
+ });
+ return;
+ }
runWithInputs({});
- }
+ });
},
[handleConfirmRun, t],
);
diff --git a/src/ui/lib/keybinding-dispatch.ts b/src/ui/lib/keybinding-dispatch.ts
index 3e7180142..6d7650f02 100644
--- a/src/ui/lib/keybinding-dispatch.ts
+++ b/src/ui/lib/keybinding-dispatch.ts
@@ -1,18 +1,20 @@
import type { Terminal } from "@xterm/xterm";
import type { KeybindingAction } from "@/types/keybindings";
-import {
- hasSnippetInputs,
- resolveSnippetContent,
- type SnippetHostContext,
-} from "@/lib/snippet-variables";
+import { invokeAction } from "@/shell/action-registry";
+
+export interface SnippetHostContext {
+ ip?: string;
+ username?: string;
+ port?: number | string;
+ name?: string;
+}
export interface KeybindingDispatchContext {
terminal: Terminal;
webSocketRef: React.MutableRefObject;
writeTextToClipboard: (text: string) => Promise;
readTextFromClipboard: () => Promise;
- getSnippetById: (id: string) => Promise<{ content: string } | undefined>;
- /** Host context used to silently resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
+ /** Host context used to resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
hostContext?: SnippetHostContext | null;
/**
* Called instead of sending the snippet directly when its content still has
@@ -64,21 +66,26 @@ export function dispatchKeybindingAction(
}
case "runSnippet": {
if (!action.snippetId) return;
- ctx.getSnippetById(action.snippetId).then((snippet) => {
- if (!snippet) return;
- if (hasSnippetInputs(snippet.content)) {
+ const snippetId = Number(action.snippetId);
+ if (!Number.isFinite(snippetId)) return;
+ // Resolving with no inputValues first tells us whether the snippet
+ // still needs them; the plugin decides and reports back.
+ void invokeAction(
+ "snippets.resolveForTerminal",
+ snippetId,
+ ctx.hostContext ?? null,
+ ).then((result) => {
+ const resolved = result as
+ { needsInputs: boolean; content: string } | null | undefined;
+ if (!resolved) return;
+ if (resolved.needsInputs) {
ctx.onSnippetNeedsInputs?.({
id: action.snippetId!,
- content: snippet.content,
+ content: resolved.content,
});
return;
}
- const resolved = resolveSnippetContent(
- snippet.content,
- ctx.hostContext ?? null,
- {},
- );
- sendRaw(resolved + (action.appendEnter !== false ? "\r" : ""));
+ sendRaw(resolved.content + (action.appendEnter !== false ? "\r" : ""));
});
return;
}
diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json
index 2c8e79be7..93dde68d0 100644
--- a/src/ui/locales/en.json
+++ b/src/ui/locales/en.json
@@ -624,8 +624,6 @@
"feature_files_desc": "Browse, upload, download and edit files over SFTP without leaving the app.",
"feature_tunnels": "Tunnels",
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed.",
- "feature_snippets": "Snippets",
- "feature_snippets_desc": "Save commands you run often and send them to a terminal in one click.",
"featuresRailHint": "Do not need one of these? Hide any icon from the sidebar in Settings, under Navigation.",
"workflowTitle": "Move around faster",
"workflowIntro": "A few habits that save the most time once you have a couple of servers.",
@@ -710,7 +708,6 @@
"multiPanelHintDockHow": "Right-click its rail icon, or use the panel icon in the tab bar.",
"sidebarSettings": "Sidebar Settings...",
"hosts": "Hosts",
- "snippets": "Snippets",
"macros": "Macros",
"hostManager": "Host Manager",
"credentials": "Credentials",
@@ -3201,7 +3198,6 @@
"pluginDisabled": "Plugin disabled",
"groups": {
"hosts": "Hosts",
- "snippets": "Snippets",
"credentials": "Credentials",
"admin": "Administration"
}
diff --git a/src/ui/main-axios.ts b/src/ui/main-axios.ts
index f4befa6ca..274eb4600 100644
--- a/src/ui/main-axios.ts
+++ b/src/ui/main-axios.ts
@@ -2150,20 +2150,10 @@ export {
// ============================================================================
// SNIPPETS API
// ============================================================================
+// Snippets themselves are owned by the snippets plugin. This is the thin
+// list read a few core pickers still need (see src/ui/api/snippets-api.ts).
-export {
- getSnippets,
- createSnippet,
- updateSnippet,
- deleteSnippet,
- executeSnippet,
- getSnippetFolders,
- createSnippetFolder,
- updateSnippetFolderMetadata,
- renameSnippetFolder,
- deleteSnippetFolder,
- reorderSnippets,
-} from "@/api/snippets-api";
+export { getSnippets, executeSnippet } from "@/api/snippets-api";
// ============================================================================
export type {
@@ -2216,10 +2206,6 @@ export {
setHostAuthOverride,
getPermissionsCatalog,
getSharedHosts,
- shareSnippet,
- getSnippetAccess,
- revokeSnippetAccess,
- getSharedSnippets,
} from "@/api/rbac-api";
export type {
SharePermissionLevel,
diff --git a/src/ui/onboarding/steps/FeaturesStep.tsx b/src/ui/onboarding/steps/FeaturesStep.tsx
index 4435b421b..63e89cb04 100644
--- a/src/ui/onboarding/steps/FeaturesStep.tsx
+++ b/src/ui/onboarding/steps/FeaturesStep.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
-import { FolderTree, Play, Plug, type LucideIcon } from "lucide-react";
+import { FolderTree, Plug, type LucideIcon } from "lucide-react";
import { useActionSlot } from "@/hooks/use-action-slot";
/**
@@ -11,7 +11,6 @@ import { useActionSlot } from "@/hooks/use-action-slot";
const FEATURES: { icon: LucideIcon; key: string }[] = [
{ icon: FolderTree, key: "files" },
{ icon: Plug, key: "tunnels" },
- { icon: Play, key: "snippets" },
];
export function FeaturesStep() {
diff --git a/src/ui/plugin-host/bridge.ts b/src/ui/plugin-host/bridge.ts
index 24eed84c1..7c1ec2332 100644
--- a/src/ui/plugin-host/bridge.ts
+++ b/src/ui/plugin-host/bridge.ts
@@ -29,9 +29,10 @@ import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
/**
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
* manifest module, which is not imported here because it pulls semver into
- * the browser bundle.
+ * the browser bundle. A group that is itself a plugin id (snippets, once)
+ * does not need an entry here: knownPluginIds() below already covers it.
*/
-const CORE_PERMISSION_GROUPS = ["hosts", "snippets", "credentials", "admin"];
+const CORE_PERMISSION_GROUPS = ["hosts", "credentials", "admin"];
/**
* Resolves a permission the way the backend's ctx.rbac does: a short name
diff --git a/src/ui/shell/shell-layout.ts b/src/ui/shell/shell-layout.ts
index 7553ac25f..f130ca01a 100644
--- a/src/ui/shell/shell-layout.ts
+++ b/src/ui/shell/shell-layout.ts
@@ -26,7 +26,6 @@ const CORE_CAPTURABLE = new Set([
"homepage",
"termix-id",
"session-logs",
- "snippets",
"macros",
"history",
"ssh-tools",
@@ -39,7 +38,6 @@ const CORE_SINGLETON = new Set([
"homepage",
"termix-id",
"session-logs",
- "snippets",
"macros",
"history",
"ssh-tools",
diff --git a/src/ui/shell/tabUtils.tsx b/src/ui/shell/tabUtils.tsx
index ad43de344..f08446cae 100644
--- a/src/ui/shell/tabUtils.tsx
+++ b/src/ui/shell/tabUtils.tsx
@@ -16,7 +16,6 @@ import {
Clock,
Fingerprint,
Hammer,
- Play,
ScrollText,
Presentation,
} from "lucide-react";
@@ -91,9 +90,6 @@ const SessionLogsPanel = lazy(() =>
default: m.SessionLogsPanel,
})),
);
-const SnippetsPanel = lazy(() =>
- import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
-);
const MacrosPanel = lazy(() =>
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
);
@@ -197,8 +193,6 @@ export function tabIcon(type: TabType) {
return ;
case "session-logs":
return ;
- case "snippets":
- return ;
case "macros":
return ;
case "history":
@@ -371,17 +365,6 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
case "split-screen":
return null;
- case "snippets":
- return withTabSuspense(
-
-
- ,
- );
-
case "macros":
return withTabSuspense(
diff --git a/src/ui/sidebar/AdminUserManagePanel.tsx b/src/ui/sidebar/AdminUserManagePanel.tsx
index 2fa74a7af..1627924f2 100644
--- a/src/ui/sidebar/AdminUserManagePanel.tsx
+++ b/src/ui/sidebar/AdminUserManagePanel.tsx
@@ -66,8 +66,10 @@ type ApiErrorLike = {
};
};
+// user-snippets rather than the snippets plugin's own id: this is a tab key
+// in this admin panel's own local state, not a lookup into the plugin registry.
type ManageTabId =
- "account" | "hosts" | "credentials" | "snippets" | "sessions" | "danger";
+ "account" | "hosts" | "credentials" | "user-snippets" | "sessions" | "danger";
type ManagedSnippet = {
id: number;
@@ -253,7 +255,7 @@ export function AdminUserManagePanel({
{ id: "account", label: t("admin.manageTabAccount") },
{ id: "hosts", label: t("admin.manageTabHosts") },
{ id: "credentials", label: t("admin.manageTabCredentials") },
- { id: "snippets", label: t("admin.manageTabSnippets") },
+ { id: "user-snippets", label: t("admin.manageTabSnippets") },
{ id: "sessions", label: t("admin.manageTabSessions") },
{ id: "danger", label: t("admin.manageTabDanger") },
];
@@ -996,7 +998,7 @@ export function AdminUserManagePanel({
))}
- {activeTab === "snippets" &&
+ {activeTab === "user-snippets" &&
(!dataUnlocked ? (
lockedNotice
) : (
diff --git a/src/ui/sidebar/SnippetsPanel.tsx b/src/ui/sidebar/SnippetsPanel.tsx
deleted file mode 100644
index bf1488250..000000000
--- a/src/ui/sidebar/SnippetsPanel.tsx
+++ /dev/null
@@ -1,2403 +0,0 @@
-import { getErrorMessage } from "../lib/error-message.js";
-import { useState, useEffect, useCallback, useMemo, useRef } from "react";
-import { useTranslation } from "react-i18next";
-import { copyToClipboard } from "@/lib/clipboard";
-import { useSnippetRunner } from "@/hooks/use-snippet-runner.tsx";
-import {
- getSnippets,
- createSnippet as apiCreateSnippet,
- updateSnippet as apiUpdateSnippet,
- deleteSnippet as apiDeleteSnippet,
- getSnippetFolders,
- createSnippetFolder as apiCreateSnippetFolder,
- deleteSnippetFolder as apiDeleteSnippetFolder,
- renameSnippetFolder as apiRenameSnippetFolder,
- updateSnippetFolderMetadata as apiUpdateSnippetFolderMetadata,
- shareSnippet as apiShareSnippet,
- getSnippetAccess,
- revokeSnippetAccess,
- getUserList,
- getRoles,
- reorderSnippets,
- saveUserPreferences,
-} from "@/main-axios";
-import {
- hasSnippetInputs,
- type SnippetHostContext,
-} from "@/lib/snippet-variables";
-import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
-import {
- exportSnippets,
- importSnippets,
- type SnippetExportData,
- executeSnippet as apiExecuteSnippet,
-} from "@/api/snippets-api";
-import { getSSHHosts } from "@/api/ssh-host-management-api";
-import type { SSHHost } from "@/types/index";
-import { Button } from "@/components/button";
-import { Input } from "@/components/input";
-import { FolderPathPicker } from "./FolderPathPicker";
-import { Separator } from "@/components/separator";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
-} from "@/components/dialog";
-import {
- Box,
- ChevronDown,
- ChevronsDownUp,
- ChevronsUpDown,
- Clipboard,
- Copy,
- Cpu,
- Database,
- Download,
- Folder,
- Globe,
- GripVertical,
- Network,
- Pencil,
- Play,
- Plus,
- Search,
- Server,
- Settings,
- Share2,
- Terminal,
- Trash2,
- Upload,
- UserPlus,
- X,
- MoreHorizontal,
- Zap,
-} from "lucide-react";
-import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuTrigger,
-} from "@/components/dropdown-menu";
-import { SettingRow, FakeSwitch } from "@/components/section-card";
-import { toast } from "sonner";
-import { FOLDER_COLORS } from "@/lib/theme";
-import { FOLDER_ICONS } from "@/types/ui-types";
-import type {
- Snippet,
- SnippetFolder,
- FolderIconId,
- Tab,
-} from "@/types/ui-types";
-import { Select2 } from "@/components/select2";
-
-function FolderIconEl({
- icon,
- className,
- style,
-}: {
- icon: FolderIconId;
- className?: string;
- style?: React.CSSProperties;
-}) {
- const props = { className, style };
- switch (icon) {
- case "folder":
- return ;
- case "server":
- return ;
- case "cloud":
- return (
-
- );
- case "database":
- return ;
- case "box":
- return ;
- case "network":
- return ;
- case "copy":
- return ;
- case "settings":
- return ;
- case "cpu":
- return ;
- case "globe":
- return ;
- }
-}
-
-function SnippetFormDialog({
- open,
- onOpenChange,
- folders,
- snippet,
- onSave,
- availableHosts,
-}: {
- open: boolean;
- onOpenChange: (v: boolean) => void;
- folders: SnippetFolder[];
- snippet: Snippet | null;
- onSave: (data: Omit, id?: number) => void;
- availableHosts: SSHHost[];
-}) {
- const { t } = useTranslation();
- const [name, setName] = useState("");
- const [description, setDescription] = useState("");
- const [folder, setFolder] = useState(null);
- const [content, setContent] = useState("");
- const [isNote, setIsNote] = useState(false);
- const [selectedHostIds, setSelectedHostIds] = useState>(
- new Set(),
- );
-
- const folderMeta = useMemo(() => {
- const map = new Map();
- for (const f of folders) {
- map.set(f.name, { color: f.color ?? undefined, icon: f.icon });
- }
- return map;
- }, [folders]);
-
- useEffect(() => {
- if (open) {
- setName(snippet?.name ?? "");
- setDescription(snippet?.description ?? "");
- setFolder(snippet?.folder ?? null);
- setContent(snippet?.content ?? "");
- setIsNote(snippet?.isNote ?? false);
- setSelectedHostIds(new Set(snippet?.hostIds ?? []));
- }
- }, [open, snippet]);
-
- function toggleHost(id: number) {
- setSelectedHostIds((prev) => {
- const next = new Set(prev);
- if (next.has(id)) {
- next.delete(id);
- } else {
- next.add(id);
- }
- return next;
- });
- }
-
- function handleSave() {
- if (!name.trim() || !content.trim()) return;
- onSave(
- {
- name: name.trim(),
- description: description.trim() || undefined,
- content: content.trim(),
- folder,
- isNote,
- hostIds:
- !isNote && selectedHostIds.size > 0
- ? Array.from(selectedHostIds)
- : undefined,
- },
- snippet?.id,
- );
- onOpenChange(false);
- }
-
- const isEdit = snippet !== null;
-
- return (
-
- );
-}
-
-function CreateFolderDialog({
- open,
- onOpenChange,
- onCreate,
-}: {
- open: boolean;
- onOpenChange: (v: boolean) => void;
- onCreate: (f: Omit) => void;
-}) {
- const { t } = useTranslation();
- const [name, setName] = useState("");
- const [color, setColor] = useState(FOLDER_COLORS[0]);
- const [icon, setIcon] = useState("folder");
-
- function handleCreate() {
- if (!name.trim()) return;
- onCreate({ name: name.trim(), color, icon });
- setName("");
- setColor(FOLDER_COLORS[0]);
- setIcon("folder");
- onOpenChange(false);
- }
-
- return (
-
- );
-}
-
-function EditFolderDialog({
- open,
- onOpenChange,
- folder,
- onSave,
-}: {
- open: boolean;
- onOpenChange: (v: boolean) => void;
- folder: SnippetFolder | null;
- onSave: (
- oldName: string,
- data: { name: string; color: string; icon: FolderIconId },
- ) => void;
-}) {
- const { t } = useTranslation();
- const [name, setName] = useState("");
- const [color, setColor] = useState(FOLDER_COLORS[0]);
- const [icon, setIcon] = useState("folder");
-
- useEffect(() => {
- if (open && folder) {
- setName(folder.name);
- setColor(folder.color ?? FOLDER_COLORS[0]);
- setIcon(folder.icon ?? "folder");
- }
- }, [open, folder]);
-
- function handleSave() {
- if (!name.trim() || !folder) return;
- onSave(folder.name, { name: name.trim(), color, icon });
- onOpenChange(false);
- }
-
- return (
-
- );
-}
-
-type AccessRecord = {
- id: number;
- targetType: "user" | "role";
- username: string | null;
- roleName: string | null;
- roleDisplayName: string | null;
- permissionLevel: string;
- expiresAt: string | null;
-};
-
-type SnippetPayload = Omit;
-type RawSnippet = Omit & {
- id: number;
- order?: number | null;
- hostFilter?: string | null;
-};
-
-function parseHostFilter(raw: string | null | undefined): number[] | undefined {
- if (!raw) return undefined;
- try {
- const parsed = JSON.parse(raw);
- if (Array.isArray(parsed) && parsed.every((x) => typeof x === "number")) {
- return parsed.length > 0 ? parsed : undefined;
- }
- } catch {
- // ignore malformed values
- }
- return undefined;
-}
-
-function mapRawSnippet(s: RawSnippet): Snippet {
- return {
- id: s.id,
- name: s.name,
- description: s.description,
- content: s.content,
- folder: s.folder ?? null,
- order: s.order ?? 0,
- hostIds: parseHostFilter(s.hostFilter),
- isNote: s.isNote ?? false,
- };
-}
-type RawSnippetFolder = {
- id: number;
- name: string;
- color?: string | null;
- icon?: FolderIconId | null;
-};
-
-function ShareSnippetDialog({
- snippet,
- onClose,
-}: {
- snippet: Snippet | null;
- onClose: () => void;
-}) {
- const { t } = useTranslation();
- const [users, setUsers] = useState<{ id: string; username: string }[]>([]);
- const [roles, setRoles] = useState<
- { id: number; name: string; displayName?: string }[]
- >([]);
- const [accessList, setAccessList] = useState([]);
- const [targetType, setTargetType] = useState<"user" | "role">("user");
- const [targetId, setTargetId] = useState("");
- const [loading, setLoading] = useState(false);
-
- useEffect(() => {
- if (!snippet) return;
- setLoading(true);
- Promise.all([getUserList(), getRoles(), getSnippetAccess(snippet.id)])
- .then(([usersData, rolesData, accessData]) => {
- setUsers(
- (usersData?.users || []).map((u) => ({
- id: u.userId,
- username: u.username,
- })),
- );
- setRoles(rolesData?.roles || []);
- setAccessList(accessData.accessList || []);
- })
- .catch(() => toast.error(t("newUi.sidebar.snippets.shareLoadError")))
- .finally(() => setLoading(false));
- }, [snippet, t]);
-
- async function handleShare() {
- if (!snippet || !targetId) return;
- try {
- await apiShareSnippet(snippet.id, {
- targetType,
- targetUserId: targetType === "user" ? targetId : undefined,
- targetRoleId: targetType === "role" ? parseInt(targetId) : undefined,
- });
- toast.success(t("newUi.sidebar.snippets.shareSuccess"));
- const accessData = await getSnippetAccess(snippet.id);
- setAccessList(accessData.accessList || []);
- setTargetId("");
- } catch {
- toast.error(t("newUi.sidebar.snippets.shareFailed"));
- }
- }
-
- async function handleRevoke(accessId: number) {
- if (!snippet) return;
- try {
- await revokeSnippetAccess(snippet.id, accessId);
- toast.success(t("newUi.sidebar.snippets.revokeSuccess"));
- const accessData = await getSnippetAccess(snippet.id);
- setAccessList(accessData.accessList || []);
- } catch {
- toast.error(t("newUi.sidebar.snippets.revokeFailed"));
- }
- }
-
- return (
-
- );
-}
-
-function SnippetCard({
- snippet,
- showCommands,
- selectedTabIds,
- terminalTabs,
- activeTabId,
- onDelete,
- onEdit,
- onShare,
- onRunSnippet,
- onDirectExecute,
- onDragStart,
- onDragEnd,
- onDragOver,
- dropIndicator,
- isDragging,
- availableHosts,
- t,
-}: {
- snippet: Snippet;
- showCommands: boolean;
- selectedTabIds: Set;
- terminalTabs: Tab[];
- activeTabId: string;
- onDelete: (id: number) => void;
- onEdit: (snippet: Snippet) => void;
- onShare: (snippet: Snippet) => void;
- onRunSnippet: (snippet: Snippet, targets: Tab[]) => void;
- onDirectExecute: (snippet: Snippet) => void;
- onDragStart: () => void;
- onDragEnd: () => void;
- onDragOver: (e: React.DragEvent) => void;
- dropIndicator: "above" | "below" | null;
- isDragging: boolean;
- availableHosts: SSHHost[];
- t: (key: string, opts?: Record) => string;
-}) {
- const hasTargetHosts = !snippet.isNote && (snippet.hostIds?.length ?? 0) > 0;
-
- function handleRun() {
- if (hasTargetHosts) {
- onDirectExecute(snippet);
- return;
- }
- const targets = terminalTabs.filter((tab) => selectedTabIds.has(tab.id));
- if (targets.length > 0) {
- onRunSnippet(snippet, targets);
- } else if (terminalTabs.length > 0) {
- const activeTab =
- terminalTabs.find((tab) => tab.id === activeTabId) ?? terminalTabs[0];
- onRunSnippet(snippet, [activeTab]);
- } else {
- toast.error(t("newUi.sidebar.snippets.noTerminalTabsOpen"));
- }
- if (document.activeElement instanceof HTMLElement) {
- document.activeElement.blur();
- }
- }
-
- function handleCopy() {
- copyToClipboard(snippet.content);
- toast.success(
- t("newUi.sidebar.snippets.copySuccess", { name: snippet.name }),
- );
- }
-
- const targetHosts = hasTargetHosts
- ? availableHosts.filter((h) => snippet.hostIds!.includes(h.id))
- : [];
-
- return (
-
- {dropIndicator === "above" && (
-
- )}
-
{
- e.dataTransfer.effectAllowed = "move";
- onDragStart();
- }}
- onDragEnd={onDragEnd}
- className={`border bg-background p-2.5 flex flex-col gap-2 group/card transition-opacity ${isDragging ? "opacity-40" : "opacity-100"} border-border`}
- >
-
-
-
-
- {snippet.name}
-
- {snippet.description && (
-
- {snippet.description}
-
- )}
-
- {snippet.isNote && (
-
- {t("newUi.sidebar.snippets.typeNote")}
-
- )}
- {hasTargetHosts && (
-
- )}
-
- {showCommands && (
-
- {snippet.content}
-
- )}
- {targetHosts.length > 0 && (
-
- {targetHosts.map((host) => (
-
-
- {host.name || host.ip}
-
- ))}
-
- )}
-
-
-
-
-
-
-
-
- {dropIndicator === "below" && (
-
- )}
-
- );
-}
-
-function ImportSnippetsDialog({
- open,
- onOpenChange,
- onImportDone,
-}: {
- open: boolean;
- onOpenChange: (v: boolean) => void;
- onImportDone: () => void;
-}) {
- const { t } = useTranslation();
- const [file, setFile] = useState(null);
- const [overwrite, setOverwrite] = useState(false);
- const [importing, setImporting] = useState(false);
- const fileInputRef = useRef(null);
-
- useEffect(() => {
- if (!open) {
- setFile(null);
- setOverwrite(false);
- }
- }, [open]);
-
- function handleFileChange(e: React.ChangeEvent) {
- setFile(e.target.files?.[0] ?? null);
- }
-
- function handleDrop(e: React.DragEvent) {
- e.preventDefault();
- const dropped = e.dataTransfer.files?.[0];
- if (dropped?.name.endsWith(".json")) setFile(dropped);
- }
-
- async function handleImport() {
- if (!file) return;
- setImporting(true);
- try {
- const text = await file.text();
- let parsed: SnippetExportData;
- try {
- parsed = JSON.parse(text);
- } catch {
- toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
- return;
- }
-
- if (
- typeof parsed !== "object" ||
- parsed === null ||
- (!Array.isArray(parsed.snippets) && !Array.isArray(parsed.folders))
- ) {
- toast.error(t("newUi.sidebar.snippets.importInvalidFile"));
- return;
- }
-
- const result = await importSnippets(parsed, overwrite);
- toast.success(
- t("newUi.sidebar.snippets.importSuccess", {
- snippets: result.snippetsImported,
- updated: result.snippetsUpdated,
- skipped: result.snippetsSkipped,
- folders: result.foldersImported,
- }),
- );
- onImportDone();
- onOpenChange(false);
- } catch {
- toast.error(t("newUi.sidebar.snippets.importFailed"));
- } finally {
- setImporting(false);
- }
- }
-
- return (
-
- );
-}
-
-interface ExecutionResult {
- hostLabel: string;
- success: boolean;
- output: string;
- error?: string;
-}
-
-function ExecutionResultDialog({
- open,
- onOpenChange,
- snippetName,
- results,
-}: {
- open: boolean;
- onOpenChange: (v: boolean) => void;
- snippetName: string;
- results: ExecutionResult[];
-}) {
- const { t } = useTranslation();
- return (
-
- );
-}
-
-function VirtualFolderGroup({
- folderName,
- showCommands,
- snippets: folderSnippets,
- selectedTabIds,
- terminalTabs,
- activeTabId,
- onDelete,
- onEdit,
- onShare,
- onRunSnippet,
- onDirectExecute,
- draggedSnippet,
- dropTarget,
- onDragStart,
- onDragEnd,
- onDragOver,
- onDrop,
- availableHosts,
- t,
- open,
- onToggleOpen,
-}: {
- showCommands: boolean;
- folderName: string;
- snippets: Snippet[];
- selectedTabIds: Set;
- terminalTabs: Tab[];
- activeTabId: string;
- onDelete: (id: number) => void;
- onEdit: (snippet: Snippet) => void;
- onShare: (snippet: Snippet) => void;
- onRunSnippet: (snippet: Snippet, targets: Tab[]) => void;
- onDirectExecute: (snippet: Snippet) => void;
- draggedSnippet: Snippet | null;
- dropTarget: { id: number; position: "above" | "below" } | null;
- onDragStart: (snippet: Snippet) => void;
- onDragEnd: () => void;
- onDragOver: (e: React.DragEvent, id: number) => void;
- onDrop: (folder: string | null) => void;
- availableHosts: SSHHost[];
- t: (key: string, opts?: Record) => string;
- open: boolean;
- onToggleOpen: () => void;
-}) {
- return (
-
-
- {open && (
-
e.preventDefault()}
- onDrop={() => onDrop(folderName)}
- >
- {folderSnippets.map((snippet) => (
- onDragStart(snippet)}
- onDragEnd={onDragEnd}
- onDragOver={(e) => onDragOver(e, snippet.id)}
- dropIndicator={
- dropTarget?.id === snippet.id ? dropTarget.position : null
- }
- isDragging={draggedSnippet?.id === snippet.id}
- availableHosts={availableHosts}
- t={t}
- />
- ))}
-
- )}
-
- );
-}
-
-const UNCATEGORIZED_KEY = "__uncategorized__";
-
-function readOpenFolders(): Set {
- try {
- const saved = localStorage.getItem("snippetOpenFolders");
- if (saved) return new Set(JSON.parse(saved));
- } catch {
- // ignore malformed storage
- }
- return new Set();
-}
-
-export function SnippetsPanel({
- terminalTabs,
- activeTabId,
- storageMode = "local",
-}: {
- terminalTabs: Tab[];
- activeTabId: string;
- storageMode?: "local" | "cloud";
-}) {
- const { t } = useTranslation();
- const [snippetSearch, setSnippetSearch] = useState("");
- const [folders, setFolders] = useState([]);
- const [snippets, setSnippets] = useState([]);
- const snippetsRef = useRef([]);
- const [snippetFormOpen, setSnippetFormOpen] = useState(false);
- const [editingSnippet, setEditingSnippet] = useState(null);
- const [createFolderOpen, setCreateFolderOpen] = useState(false);
- const [editFolder, setEditFolder] = useState(null);
- const [editFolderOpen, setEditFolderOpen] = useState(false);
- const [shareSnippet, setShareSnippet] = useState(null);
- const [importOpen, setImportOpen] = useState(false);
- const [availableHosts, setAvailableHosts] = useState([]);
- const [executionResultOpen, setExecutionResultOpen] = useState(false);
- const [executionResults, setExecutionResults] = useState(
- [],
- );
- const [executionSnippetName, setExecutionSnippetName] = useState("");
- const [foldersCollapsedDefault, setFoldersCollapsedDefault] = useState(
- () => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
- );
- // Compact list: names only, the command stays in the tooltip/editor.
- const [showCommands, setShowCommands] = useState(
- () => localStorage.getItem("snippetShowCommands") !== "false",
- );
- const [confirmExecution, setConfirmExecution] = useState(
- () => localStorage.getItem("confirmSnippetExecution") === "true",
- );
- const [selectedTabIds, setSelectedTabIds] = useState>(
- () =>
- new Set(
- activeTabId && terminalTabs.some((tab) => tab.id === activeTabId)
- ? [activeTabId]
- : [],
- ),
- );
- // Path B (direct SSH execute) needs its own variables-dialog state since it
- // resolves against the first target host and posts inputValues to the
- // backend, unlike Path A (terminal send) which the shared hook covers.
- const [directRunningSnippet, setDirectRunningSnippet] = useState<{
- snippet: Snippet;
- host: SnippetHostContext | null;
- onConfirm: (
- resolvedContent: string,
- inputValues: Record,
- ) => void;
- } | null>(null);
- const {
- runSnippet: handleRunSnippet,
- handleConfirmRun,
- dialog: runSnippetDialog,
- } = useSnippetRunner();
-
- function updateSnippets(next: Snippet[] | ((prev: Snippet[]) => Snippet[])) {
- setSnippets((prev) => {
- const resolved = typeof next === "function" ? next(prev) : next;
- snippetsRef.current = resolved;
- return resolved;
- });
- }
-
- const getFoldersCollapsed = () =>
- localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false";
-
- const [openFolders, setOpenFolders] = useState>(() =>
- readOpenFolders(),
- );
-
- function persistOpenFolders(next: Set) {
- try {
- localStorage.setItem("snippetOpenFolders", JSON.stringify([...next]));
- } catch {
- // ignore quota/serialization failures
- }
- }
-
- function toggleOpenFolder(key: string) {
- setOpenFolders((prev) => {
- const next = new Set(prev);
- if (next.has(key)) {
- next.delete(key);
- } else {
- next.add(key);
- }
- persistOpenFolders(next);
- return next;
- });
- }
-
- const allFolderKeys = useMemo(() => {
- const keys = new Set([UNCATEGORIZED_KEY]);
- for (const f of folders) keys.add(f.name);
- for (const s of snippets) {
- if (s.folder) keys.add(s.folder);
- }
- return keys;
- }, [folders, snippets]);
-
- useEffect(() => {
- const handler = () => {
- const collapsed = getFoldersCollapsed();
- const next = collapsed ? new Set() : new Set(allFolderKeys);
- persistOpenFolders(next);
- setOpenFolders(next);
- };
- const expandAll = () => {
- const next = new Set(allFolderKeys);
- persistOpenFolders(next);
- setOpenFolders(next);
- };
- const collapseAll = () => {
- const next = new Set();
- persistOpenFolders(next);
- setOpenFolders(next);
- };
- window.addEventListener("defaultSnippetFoldersCollapsedChanged", handler);
- window.addEventListener("snippets:expand-all", expandAll);
- window.addEventListener("snippets:collapse-all", collapseAll);
- return () => {
- window.removeEventListener(
- "defaultSnippetFoldersCollapsedChanged",
- handler,
- );
- window.removeEventListener("snippets:expand-all", expandAll);
- window.removeEventListener("snippets:collapse-all", collapseAll);
- };
- }, [allFolderKeys]);
-
- const [draggedSnippet, setDraggedSnippet] = useState(null);
- const [dropTarget, setDropTarget] = useState<{
- id: number;
- position: "above" | "below";
- } | null>(null);
-
- function handleDragStart(snippet: Snippet) {
- setDraggedSnippet(snippet);
- }
-
- function handleDragOver(e: React.DragEvent, snippetId: number) {
- e.preventDefault();
- if (!draggedSnippet || draggedSnippet.id === snippetId) return;
- const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
- const position = e.clientY < rect.top + rect.height / 2 ? "above" : "below";
- setDropTarget((prev) =>
- prev?.id === snippetId && prev?.position === position
- ? prev
- : { id: snippetId, position },
- );
- }
-
- async function handleDrop(folder: string | null) {
- const dragged = draggedSnippet;
- const target = dropTarget;
- setDraggedSnippet(null);
- setDropTarget(null);
-
- if (!dragged || !target || dragged.id === target.id) return;
-
- const group = snippetsRef.current.filter((s) => s.folder === folder);
- const draggedIdx = group.findIndex((s) => s.id === dragged.id);
- const targetIdx = group.findIndex((s) => s.id === target.id);
- if (draggedIdx === -1 || targetIdx === -1) return;
-
- const reordered = [...group];
- reordered.splice(draggedIdx, 1);
- const insertAt =
- target.position === "below"
- ? reordered.findIndex((s) => s.id === target.id) + 1
- : reordered.findIndex((s) => s.id === target.id);
- reordered.splice(insertAt, 0, dragged);
-
- const rest = snippetsRef.current.filter((s) => s.folder !== folder);
- const next = [...rest, ...reordered];
- snippetsRef.current = next;
- setSnippets(next);
-
- const payload = reordered.map((s, idx) => ({
- id: s.id,
- order: idx,
- folder: folder ?? undefined,
- }));
- try {
- await reorderSnippets(payload);
- } catch {
- toast.error(t("newUi.sidebar.snippets.reorderFailed"));
- }
- }
-
- function handleDragEnd() {
- setDraggedSnippet(null);
- setDropTarget(null);
- }
-
- // For folder keys the user has never explicitly toggled (not present in the
- // persisted set), fall back to the default-collapsed setting instead of
- // always treating them as closed.
- function seedNewFolderKeys(keys: string[]) {
- if (keys.length === 0) return;
- const collapsed = getFoldersCollapsed();
- if (collapsed) return;
- setOpenFolders((prev) => {
- const hadAny = localStorage.getItem("snippetOpenFolders") !== null;
- const next = new Set(prev);
- let changed = false;
- for (const key of keys) {
- if (!hadAny || !next.has(key)) {
- if (!next.has(key)) {
- next.add(key);
- changed = true;
- }
- }
- }
- if (!changed) return prev;
- persistOpenFolders(next);
- return next;
- });
- }
-
- useEffect(() => {
- getSnippets()
- .then((data) => {
- const arr = (Array.isArray(data)
- ? data
- : []) as unknown as RawSnippet[];
- const mapped = arr.map(mapRawSnippet);
- updateSnippets(mapped);
- const folderKeys = Array.from(
- new Set(mapped.map((s) => s.folder ?? UNCATEGORIZED_KEY)),
- );
- seedNewFolderKeys(folderKeys);
- })
- .catch(() => {});
-
- getSnippetFolders()
- .then((data) => {
- const arr: RawSnippetFolder[] = Array.isArray(data) ? data : [];
- const mapped: SnippetFolder[] = arr.map((f) => ({
- id: f.id,
- name: f.name,
- color: f.color ?? FOLDER_COLORS[0],
- icon: (f.icon as FolderIconId) ?? "folder",
- open: true,
- }));
- setFolders(mapped);
- seedNewFolderKeys(mapped.map((f) => f.name));
- })
- .catch(() => {});
-
- getSSHHosts()
- .then((hosts) => setAvailableHosts(hosts))
- .catch(() => {});
- }, []);
-
- function toggleTab(id: string) {
- setSelectedTabIds((prev) => {
- const next = new Set(prev);
- if (next.has(id)) {
- next.delete(id);
- } else {
- next.add(id);
- }
- return next;
- });
- }
-
- async function handleSaveSnippet(
- data: Omit,
- id?: number,
- ) {
- const { hostIds, ...rest } = data;
- const payload = {
- ...rest,
- hostFilter: hostIds && hostIds.length > 0 ? hostIds : null,
- };
- try {
- if (id !== undefined) {
- await apiUpdateSnippet(id, payload);
- updateSnippets((prev) =>
- prev.map((s) => (s.id === id ? { ...s, ...data } : s)),
- );
- toast.success(t("newUi.sidebar.snippets.updateSuccess"));
- } else {
- const created = await apiCreateSnippet(payload);
- updateSnippets((prev) => [
- ...prev,
- {
- ...data,
- id:
- (created.id as number) ??
- Math.max(0, ...prev.map((x) => x.id)) + 1,
- order: (created.order as number) ?? prev.length,
- },
- ]);
- toast.success(t("newUi.sidebar.snippets.createSuccess"));
- }
- } catch {
- toast.error(
- id !== undefined
- ? t("newUi.sidebar.snippets.updateFailed")
- : t("newUi.sidebar.snippets.createFailed"),
- );
- }
- }
-
- async function handleCreateFolder(f: Omit) {
- try {
- const created = await apiCreateSnippetFolder({
- name: f.name,
- color: f.color,
- icon: f.icon,
- });
- const id =
- typeof created.id === "number" ? created.id : Number(created.id);
- setFolders((prev) => [...prev, { ...f, id, open: true }]);
- setOpenFolders((prev) => {
- const next = new Set(prev);
- next.add(f.name);
- persistOpenFolders(next);
- return next;
- });
- toast.success(t("newUi.sidebar.snippets.folderCreateSuccess"));
- } catch {
- toast.error(t("newUi.sidebar.snippets.folderCreateFailed"));
- }
- }
-
- async function handleDeleteFolder(folder: SnippetFolder) {
- try {
- await apiDeleteSnippetFolder(folder.name);
- setFolders((prev) => prev.filter((f) => f.id !== folder.id));
- updateSnippets((prev) =>
- prev.map((s) =>
- s.folder === folder.name ? { ...s, folder: null } : s,
- ),
- );
- setOpenFolders((prev) => {
- if (!prev.has(folder.name)) return prev;
- const next = new Set(prev);
- next.delete(folder.name);
- persistOpenFolders(next);
- return next;
- });
- toast.success(
- t("newUi.sidebar.snippets.folderDeleteSuccess", { name: folder.name }),
- );
- } catch {
- toast.error(t("newUi.sidebar.snippets.folderDeleteFailed"));
- }
- }
-
- async function handleSaveFolder(
- oldName: string,
- data: { name: string; color: string; icon: FolderIconId },
- ) {
- try {
- const nameChanged = data.name !== oldName;
- if (nameChanged) {
- await apiRenameSnippetFolder(oldName, data.name);
- updateSnippets((prev) =>
- prev.map((s) =>
- s.folder === oldName ? { ...s, folder: data.name } : s,
- ),
- );
- setOpenFolders((prev) => {
- const next = new Set(prev);
- if (next.has(oldName)) {
- next.delete(oldName);
- next.add(data.name);
- }
- persistOpenFolders(next);
- return next;
- });
- }
- await apiUpdateSnippetFolderMetadata(nameChanged ? data.name : oldName, {
- color: data.color,
- icon: data.icon,
- });
- setFolders((prev) =>
- prev.map((f) =>
- f.name === oldName
- ? { ...f, name: data.name, color: data.color, icon: data.icon }
- : f,
- ),
- );
- toast.success(t("newUi.sidebar.snippets.folderEditSuccess"));
- } catch {
- toast.error(t("newUi.sidebar.snippets.folderEditFailed"));
- }
- }
-
- async function handleExport() {
- try {
- const data = await exportSnippets();
- const blob = new Blob([JSON.stringify(data, null, 2)], {
- type: "application/json",
- });
- const url = URL.createObjectURL(blob);
- const a = document.createElement("a");
- a.href = url;
- a.download = "snippets-export.json";
- a.click();
- URL.revokeObjectURL(url);
- toast.success(t("newUi.sidebar.snippets.exportSuccess"));
- } catch {
- toast.error(t("newUi.sidebar.snippets.exportFailed"));
- }
- }
-
- function reloadData() {
- getSnippets()
- .then((data) => {
- const arr = (Array.isArray(data)
- ? data
- : []) as unknown as RawSnippet[];
- const mapped = arr.map(mapRawSnippet);
- updateSnippets(mapped);
- seedNewFolderKeys(
- Array.from(new Set(mapped.map((s) => s.folder ?? UNCATEGORIZED_KEY))),
- );
- })
- .catch(() => {});
- getSnippetFolders()
- .then((data) => {
- const arr: RawSnippetFolder[] = Array.isArray(data) ? data : [];
- const mapped: SnippetFolder[] = arr.map((f) => ({
- id: f.id,
- name: f.name,
- color: f.color ?? FOLDER_COLORS[0],
- icon: (f.icon as FolderIconId) ?? "folder",
- open: true,
- }));
- setFolders(mapped);
- seedNewFolderKeys(mapped.map((f) => f.name));
- })
- .catch(() => {});
- }
-
- async function handleDeleteSnippet(id: number) {
- try {
- await apiDeleteSnippet(id);
- updateSnippets((prev) => prev.filter((s) => s.id !== id));
- } catch {
- toast.error(t("newUi.sidebar.snippets.deleteFailed"));
- }
- }
-
- function handleEditSnippet(snippet: Snippet) {
- setEditingSnippet(snippet);
- setSnippetFormOpen(true);
- }
-
- async function executeDirectRun(
- snippet: Snippet,
- inputValues: Record,
- ) {
- const hostIds = snippet.hostIds ?? [];
- if (hostIds.length === 0) return;
-
- setExecutionSnippetName(snippet.name);
- setExecutionResults([]);
- setExecutionResultOpen(true);
-
- const results: ExecutionResult[] = await Promise.all(
- hostIds.map(async (hostId) => {
- const host = availableHosts.find((h) => h.id === hostId);
- const hostLabel = host ? host.name || host.ip : String(hostId);
- try {
- const result = await apiExecuteSnippet(
- snippet.id,
- hostId,
- Object.keys(inputValues).length > 0 ? inputValues : undefined,
- );
- return {
- hostLabel,
- success: result.success,
- output: result.output,
- error: result.error,
- };
- } catch (err) {
- return {
- hostLabel,
- success: false,
- output: "",
- error: getErrorMessage(err, String(err)),
- };
- }
- }),
- );
-
- setExecutionResults(results);
-
- const allOk = results.every((r) => r.success);
- if (allOk) {
- toast.success(
- t("newUi.sidebar.snippets.directRunSuccess", {
- name: snippet.name,
- count: results.length,
- }),
- );
- } else {
- toast.error(
- t("newUi.sidebar.snippets.directRunPartialFail", {
- name: snippet.name,
- }),
- );
- }
- }
-
- const handleDirectExecute = useCallback(
- (snippet: Snippet) => {
- const runWithInputs = (inputValues: Record) => {
- handleConfirmRun(snippet, () => {
- void executeDirectRun(snippet, inputValues);
- });
- };
-
- if (hasSnippetInputs(snippet.content)) {
- const firstHost = snippet.hostIds?.length
- ? availableHosts.find((h) => h.id === snippet.hostIds![0])
- : undefined;
- setDirectRunningSnippet({
- snippet,
- host: firstHost
- ? {
- ip: firstHost.ip,
- username: firstHost.username,
- port: firstHost.port,
- name: firstHost.name,
- }
- : null,
- onConfirm: (_resolvedContent, inputValues) => {
- setDirectRunningSnippet(null);
- runWithInputs(inputValues);
- },
- });
- } else {
- runWithInputs({});
- }
- },
- [availableHosts, handleConfirmRun],
- );
-
- const filtered = snippetSearch
- ? snippets.filter(
- (s) =>
- s.name.toLowerCase().includes(snippetSearch.toLowerCase()) ||
- s.content.toLowerCase().includes(snippetSearch.toLowerCase()),
- )
- : snippets;
-
- const uncategorizedSnippets = filtered.filter((s) => s.folder === null);
-
- const ownedFolderNames = new Set(folders.map((f) => f.name));
- const virtualFolderNames = Array.from(
- new Set(
- filtered
- .filter((s) => s.folder !== null && !ownedFolderNames.has(s.folder!))
- .map((s) => s.folder!),
- ),
- ).sort();
-
- return (
- <>
-
-
-
- {t("newUi.sidebar.snippets.title")}
-
-
-
- {t("hosts.docsLink")}
-
-
-
-
-
-
-
-
-
- {
- setShowCommands(v);
- localStorage.setItem("snippetShowCommands", v.toString());
- }}
- />
-
-
- {
- setFoldersCollapsedDefault(v);
- localStorage.setItem(
- "defaultSnippetFoldersCollapsed",
- v.toString(),
- );
- window.dispatchEvent(
- new Event("defaultSnippetFoldersCollapsedChanged"),
- );
- if (storageMode === "cloud")
- saveUserPreferences({ foldersCollapsed: v }).catch(
- () => {},
- );
- }}
- />
-
-
- {
- setConfirmExecution(v);
- localStorage.setItem(
- "confirmSnippetExecution",
- v.toString(),
- );
- if (storageMode === "cloud")
- saveUserPreferences({
- confirmSnippetExecution: v,
- }).catch(() => {});
- }}
- />
-
-
-
-
-
-
-
-
- {t("newUi.sidebar.snippets.targetTerminals")}{" "}
-
- ({t("newUi.sidebar.snippets.optional")})
-
-
- {terminalTabs.length > 0 && (
-
-
-
-
- )}
-
- {terminalTabs.length === 0 ? (
-
-
-
- {t("newUi.sidebar.snippets.noTerminalTabsOpen")}
-
-
- ) : (
-
- {terminalTabs.map((tab) => {
- const selected = selectedTabIds.has(tab.id);
- return (
-
- );
- })}
-
- )}
-
-
-
-
- setSnippetSearch(e.target.value)}
- className="pl-8"
- />
-
-
-
-
-
-
-
-
-
-
- {(!snippetSearch || uncategorizedSnippets.length > 0) && (
-
-
- {openFolders.has(UNCATEGORIZED_KEY) && (
-
handleDrop(null)}
- onDragOver={(e) => e.preventDefault()}
- >
- {uncategorizedSnippets.map((snippet) => (
- handleDragStart(snippet)}
- onDragEnd={handleDragEnd}
- onDragOver={(e) => handleDragOver(e, snippet.id)}
- dropIndicator={
- dropTarget?.id === snippet.id
- ? dropTarget.position
- : null
- }
- isDragging={draggedSnippet?.id === snippet.id}
- availableHosts={availableHosts}
- t={t}
- />
- ))}
- {uncategorizedSnippets.length === 0 && (
-
- {t("newUi.sidebar.snippets.noSnippetsInFolder")}
-
- )}
-
- )}
-
- )}
- {folders.map((folder) => {
- const folderSnippets = filtered.filter(
- (s) => s.folder === folder.name,
- );
- if (folderSnippets.length === 0 && snippetSearch) return null;
- const isOpen = openFolders.has(folder.name);
- return (
-
-
-
-
-
-
-
-
- {
- setEditFolder(folder);
- setEditFolderOpen(true);
- }}
- >
-
- {t("newUi.sidebar.snippets.editFolder")}
-
- handleDeleteFolder(folder)}
- className="text-destructive focus:text-destructive"
- >
-
- {t("newUi.sidebar.snippets.deleteFolder")}
-
-
-
-
- {isOpen && (
-
e.preventDefault()}
- onDrop={() => handleDrop(folder.name)}
- >
- {folderSnippets.map((snippet) => (
- handleDragStart(snippet)}
- onDragEnd={handleDragEnd}
- onDragOver={(e) => handleDragOver(e, snippet.id)}
- dropIndicator={
- dropTarget?.id === snippet.id
- ? dropTarget.position
- : null
- }
- isDragging={draggedSnippet?.id === snippet.id}
- availableHosts={availableHosts}
- t={t}
- />
- ))}
- {folderSnippets.length === 0 && (
-
- {t("newUi.sidebar.snippets.noSnippetsInFolder")}
-
- )}
-
- )}
-
- );
- })}
- {virtualFolderNames.map((folderName) => {
- const folderSnippets = filtered.filter(
- (s) => s.folder === folderName,
- );
- if (folderSnippets.length === 0) return null;
- return (
-
toggleOpenFolder(folderName)}
- />
- );
- })}
-
-
-
- {runSnippetDialog}
-
- {directRunningSnippet && (
- setDirectRunningSnippet(null)}
- onConfirm={directRunningSnippet.onConfirm}
- />
- )}
-
- {
- setSnippetFormOpen(v);
- if (!v) setEditingSnippet(null);
- }}
- folders={folders}
- snippet={editingSnippet}
- onSave={handleSaveSnippet}
- availableHosts={availableHosts}
- />
-
- {
- setEditFolderOpen(v);
- if (!v) setEditFolder(null);
- }}
- folder={editFolder}
- onSave={handleSaveFolder}
- />
- setShareSnippet(null)}
- />
-
-
- >
- );
-}
diff --git a/src/ui/sidebar/UserProfilePanel.tsx b/src/ui/sidebar/UserProfilePanel.tsx
index f72f4d5ae..32f13d360 100644
--- a/src/ui/sidebar/UserProfilePanel.tsx
+++ b/src/ui/sidebar/UserProfilePanel.tsx
@@ -524,7 +524,6 @@ export function UserProfilePanel({
pinAppRail?: boolean | null;
expandAppRailOnHover?: boolean | null;
showPinAppRailButton?: boolean | null;
- foldersCollapsed?: boolean | null;
confirmSnippetExecution?: boolean | null;
disableUpdateCheck?: boolean | null;
confirmTabClose?: boolean | null;
@@ -708,12 +707,10 @@ export function UserProfilePanel({
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
readRailPreference("showPinAppRailButton"),
);
- // Read values are unused now that the Snippets settings UI lives in
- // SnippetsPanel.tsx; the setters still back the cloud-sync/reset/snapshot
- // machinery for these two localStorage-backed prefs below.
- const [_foldersCollapsed, setFoldersCollapsed] = useState(
- () => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
- );
+ // 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",
);
@@ -850,7 +847,6 @@ export function UserProfilePanel({
"pinAppRail",
"expandAppRailOnHover",
"showPinAppRailButton",
- "defaultSnippetFoldersCollapsed",
"snippetShowCommands",
"confirmSnippetExecution",
"disableUpdateCheck",
@@ -926,13 +922,6 @@ export function UserProfilePanel({
);
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
}
- if (prefs.foldersCollapsed != null) {
- setFoldersCollapsed(prefs.foldersCollapsed);
- localStorage.setItem(
- "defaultSnippetFoldersCollapsed",
- String(prefs.foldersCollapsed),
- );
- }
if (prefs.confirmSnippetExecution != null) {
setConfirmSnippetExecution(prefs.confirmSnippetExecution);
localStorage.setItem(
@@ -1007,8 +996,6 @@ export function UserProfilePanel({
setShowPinAppRailButton(false);
localStorage.setItem("showPinAppRailButton", "false");
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
- setFoldersCollapsed(true);
- localStorage.removeItem("defaultSnippetFoldersCollapsed");
setConfirmSnippetExecution(false);
localStorage.setItem("confirmSnippetExecution", "false");
setDisableUpdateCheck(false);
@@ -1032,7 +1019,6 @@ export function UserProfilePanel({
pinAppRail: false,
expandAppRailOnHover: true,
showPinAppRailButton: false,
- foldersCollapsed: true,
confirmSnippetExecution: false,
disableUpdateCheck: false,
confirmTabClose: false,
@@ -1121,18 +1107,6 @@ export function UserProfilePanel({
localStorage.setItem("showPinAppRailButton", String(restoredShowPinButton));
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
- const restoredFolders =
- restore("defaultSnippetFoldersCollapsed", null) !== "false";
- setFoldersCollapsed(restoredFolders);
- const snapFolders = hasSnap
- ? snap["defaultSnippetFoldersCollapsed"]
- : localStorage.getItem("defaultSnippetFoldersCollapsed");
- if (snapFolders == null) {
- localStorage.removeItem("defaultSnippetFoldersCollapsed");
- } else {
- localStorage.setItem("defaultSnippetFoldersCollapsed", snapFolders);
- }
-
const restoredConfirmSnippet =
restore("confirmSnippetExecution", "false") === "true";
setConfirmSnippetExecution(restoredConfirmSnippet);
diff --git a/src/ui/sidebar/rail-items.ts b/src/ui/sidebar/rail-items.ts
index a6715e4d6..afd7ccd07 100644
--- a/src/ui/sidebar/rail-items.ts
+++ b/src/ui/sidebar/rail-items.ts
@@ -7,7 +7,6 @@ import {
KeyRound,
LayoutPanelLeft,
Network,
- Play,
Plug,
ScrollText,
Server,
@@ -132,15 +131,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
labelKey: "nav.sftp",
separatorAfter: true,
},
- {
- id: "snippets",
- icon: Play,
- labelKey: "nav.snippets",
- separatorAfter: true,
- mobilePrimary: true,
- promotable: true,
- rightDockable: true,
- },
{
id: "macros",
icon: Braces,
diff --git a/src/ui/tests/lib/keybinding-dispatch.test.ts b/src/ui/tests/lib/keybinding-dispatch.test.ts
index d6598f336..0ad58d53a 100644
--- a/src/ui/tests/lib/keybinding-dispatch.test.ts
+++ b/src/ui/tests/lib/keybinding-dispatch.test.ts
@@ -1,7 +1,8 @@
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect, vi, beforeEach } from "vitest";
import { dispatchKeybindingAction } from "../../lib/keybinding-dispatch";
import type { Terminal } from "@xterm/xterm";
import type { KeybindingDispatchContext } from "../../lib/keybinding-dispatch";
+import * as actionRegistry from "../../shell/action-registry";
function makeContext(
overrides: Partial = {},
@@ -25,7 +26,6 @@ function makeContext(
webSocketRef: { current: ws as unknown as WebSocket },
writeTextToClipboard: vi.fn().mockResolvedValue(true),
readTextFromClipboard: vi.fn().mockResolvedValue(""),
- getSnippetById: vi.fn().mockResolvedValue(undefined),
sentData,
...overrides,
};
@@ -33,6 +33,10 @@ function makeContext(
}
describe("dispatchKeybindingAction", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
it("copy writes the selection to clipboard and clears it", () => {
const ctx = makeContext();
(ctx.terminal.getSelection as ReturnType).mockReturnValue(
@@ -84,45 +88,58 @@ describe("dispatchKeybindingAction", () => {
expect(ctx.sentData).toEqual(["ls -la\r"]);
});
- it("runSnippet resolves the snippet and sends its content with a trailing return", async () => {
- const ctx = makeContext({
- getSnippetById: vi.fn().mockResolvedValue({ content: "uptime" }),
+ it("runSnippet resolves the snippet through the snippets.resolveForTerminal action and sends its content with a trailing return", async () => {
+ vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue({
+ needsInputs: false,
+ content: "uptime",
});
+ const ctx = makeContext();
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
+ expect(actionRegistry.invokeAction).toHaveBeenCalledWith(
+ "snippets.resolveForTerminal",
+ 1,
+ null,
+ );
expect(ctx.sentData).toEqual(["uptime\r"]);
});
it("runSnippet does nothing when the snippet no longer exists", async () => {
- const ctx = makeContext({
- getSnippetById: vi.fn().mockResolvedValue(undefined),
- });
+ vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue(undefined);
+ const ctx = makeContext();
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(ctx.sentData).toEqual([]);
});
- it("runSnippet silently resolves host variables from hostContext", async () => {
+ it("runSnippet passes hostContext through to the resolve action", async () => {
+ vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue({
+ needsInputs: false,
+ content: "ping 10.0.0.5 -p 22",
+ });
const ctx = makeContext({
- getSnippetById: vi
- .fn()
- .mockResolvedValue({ content: "ping $HOST -p $PORT" }),
hostContext: { ip: "10.0.0.5", username: "root", port: 22 },
});
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
+ expect(actionRegistry.invokeAction).toHaveBeenCalledWith(
+ "snippets.resolveForTerminal",
+ 1,
+ { ip: "10.0.0.5", username: "root", port: 22 },
+ );
expect(ctx.sentData).toEqual(["ping 10.0.0.5 -p 22\r"]);
});
- it("runSnippet defers to onSnippetNeedsInputs instead of sending when $INPUT_n is present", async () => {
- const onSnippetNeedsInputs = vi.fn();
- const ctx = makeContext({
- getSnippetById: vi.fn().mockResolvedValue({ content: "echo $INPUT_1" }),
- onSnippetNeedsInputs,
+ it("runSnippet defers to onSnippetNeedsInputs instead of sending when the action reports needsInputs", async () => {
+ vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue({
+ needsInputs: true,
+ content: "echo $INPUT_1",
});
+ const onSnippetNeedsInputs = vi.fn();
+ const ctx = makeContext({ onSnippetNeedsInputs });
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
diff --git a/src/ui/tests/lib/snippet-variables.test.ts b/src/ui/tests/lib/snippet-variables.test.ts
deleted file mode 100644
index 9e2340313..000000000
--- a/src/ui/tests/lib/snippet-variables.test.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { describe, it, expect } from "vitest";
-import {
- extractSnippetInputs,
- hasSnippetInputs,
- resolveSnippetContent,
-} from "../../lib/snippet-variables";
-
-describe("extractSnippetInputs", () => {
- it("extracts a single unlabeled input", () => {
- expect(extractSnippetInputs("ping $INPUT_1")).toEqual([
- { key: "INPUT_1", label: "Input 1" },
- ]);
- });
-
- it("extracts a labeled input using brace syntax", () => {
- expect(
- extractSnippetInputs("nc -zv $HOST ${INPUT_1:Port to check}"),
- ).toEqual([{ key: "INPUT_1", label: "Port to check" }]);
- });
-
- it("plain (non-braced) references have no label", () => {
- expect(extractSnippetInputs("echo $INPUT_2")).toEqual([
- { key: "INPUT_2", label: "Input 2" },
- ]);
- });
-
- it("de-duplicates repeated references and preserves first-seen order", () => {
- expect(
- extractSnippetInputs("$INPUT_2 then $INPUT_1 then $INPUT_2 again"),
- ).toEqual([
- { key: "INPUT_2", label: "Input 2" },
- { key: "INPUT_1", label: "Input 1" },
- ]);
- });
-
- it("returns an empty array when there are no placeholders", () => {
- expect(extractSnippetInputs("uptime")).toEqual([]);
- });
-});
-
-describe("hasSnippetInputs", () => {
- it("returns true when a snippet has an input placeholder", () => {
- expect(hasSnippetInputs("echo $INPUT_1")).toBe(true);
- });
-
- it("returns false when a snippet has no input placeholder", () => {
- expect(hasSnippetInputs("echo $HOST")).toBe(false);
- });
-});
-
-describe("resolveSnippetContent", () => {
- const host = { ip: "10.0.0.5", username: "root", port: 22, name: "web-01" };
-
- it("substitutes host variables", () => {
- expect(
- resolveSnippetContent("ssh $USER@$HOST -p $PORT # $NAME", host),
- ).toBe("ssh root@10.0.0.5 -p 22 # web-01");
- });
-
- it("supports brace syntax for host variables", () => {
- expect(resolveSnippetContent("ping ${HOST}", host)).toBe("ping 10.0.0.5");
- });
-
- it("substitutes input placeholders", () => {
- expect(
- resolveSnippetContent("nc -zv $HOST ${INPUT_1:Port}", host, {
- INPUT_1: "8080",
- }),
- ).toBe("nc -zv 10.0.0.5 8080");
- });
-
- it("leaves host variables literal when no host context is given", () => {
- expect(resolveSnippetContent("ping $HOST", null)).toBe("ping $HOST");
- });
-
- it("leaves input placeholders literal when no value was supplied", () => {
- expect(resolveSnippetContent("echo $INPUT_1", null)).toBe("echo $INPUT_1");
- });
-});
diff --git a/src/ui/tests/onboarding/onboarding-steps.test.ts b/src/ui/tests/onboarding/onboarding-steps.test.ts
index 379ff96ce..40a42f34c 100644
--- a/src/ui/tests/onboarding/onboarding-steps.test.ts
+++ b/src/ui/tests/onboarding/onboarding-steps.test.ts
@@ -51,7 +51,7 @@ describe("ONBOARDING_STEPS", () => {
describe("onboarding step body translations", () => {
const KEYS: Record = {
welcome: ["hosts", "terminal", "files"],
- feature: ["files", "tunnels", "snippets"],
+ feature: ["files", "tunnels"],
workflow: ["palette", "split", "dock"],
security: ["credentials", "twofa", "identity", "sharing"],
done: ["settings", "rerun", "docs"],
diff --git a/src/ui/tests/sidebar/rail-items.test.ts b/src/ui/tests/sidebar/rail-items.test.ts
index d14c1bdab..52418465b 100644
--- a/src/ui/tests/sidebar/rail-items.test.ts
+++ b/src/ui/tests/sidebar/rail-items.test.ts
@@ -59,7 +59,6 @@ describe("RAIL_ITEMS", () => {
"serial",
"ssh-tools",
"sftp",
- "snippets",
"macros",
"history",
"session-logs",
@@ -74,10 +73,10 @@ describe("RAIL_ITEMS", () => {
);
});
- it("marks exactly the four mobile primary slots", () => {
+ it("marks exactly the three mobile primary slots", () => {
expect(
RAIL_ITEMS.filter((item) => item.mobilePrimary).map((item) => item.id),
- ).toEqual(["hosts", "quick-connect", "ssh-tools", "snippets"]);
+ ).toEqual(["hosts", "quick-connect", "ssh-tools"]);
});
it("marks the panels that can open as a tab", () => {
@@ -85,14 +84,7 @@ describe("RAIL_ITEMS", () => {
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
.filter((item) => item.promotable)
.map((item) => item.id),
- ).toEqual([
- "termix-id",
- "ssh-tools",
- "snippets",
- "macros",
- "history",
- "session-logs",
- ]);
+ ).toEqual(["termix-id", "ssh-tools", "macros", "history", "session-logs"]);
});
it("derives promotableIds from the promotable flag", () => {
@@ -132,7 +124,6 @@ describe("RAIL_ITEMS", () => {
expect(rightDockableIds()).toEqual([
"connections",
"ssh-tools",
- "snippets",
"macros",
"history",
"session-logs",