feat: point core frontend snippets pickers at the plugin (B2)

This commit is contained in:
LukeGus committed 2026-09-23 08:59:06 -05:00
1 parent e07221eaf3
commit 1eac0f54b0
22 files changed
+170 -2968

No files matched your search

-24
View File
@@ -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({
</div>
)}
{railView === "snippets" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<SnippetsPanel
terminalTabs={terminalTabs}
activeTabId={targetTerminalTabId}
storageMode={
userPrefs.storageMode === "cloud" ? "cloud" : "local"
}
/>
</div>
)}
{railView === "macros" && (
<div className="flex-1 min-h-0 overflow-y-auto">
<MacrosPanel
+10 -6
View File
@@ -195,7 +195,7 @@ export async function adminGetUserSnippets(
targetUserId: string,
): Promise<Record<string, unknown>> {
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<string, unknown>,
): Promise<Record<string, unknown>> {
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<string, unknown>,
): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
try {
const response = await authApi.delete(`/snippets/${snippetId}`, {
const response = await authApi.delete(`/plugin-api/snippets/${snippetId}`, {
headers: adminHeaders(targetUserId),
});
return response.data;
-1
View File
@@ -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;
-86
View File
@@ -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");
}
}
+8 -163
View File
@@ -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<SnippetRow[]> {
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<string, unknown>,
): Promise<Record<string, unknown>> {
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<string, unknown>,
): Promise<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, string>,
): 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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<SnippetExportData> {
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");
}
}
+20 -30
View File
@@ -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<TerminalHandle, SSHTerminalProps>(
const terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
const cachedSnippetsRef = useRef<{ id: number; content: string }[] | null>(
null,
);
const [pendingKeybindingSnippet, setPendingKeybindingSnippet] = useState<{
id: string;
content: string;
@@ -1961,16 +1958,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
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<TerminalHandle, SSHTerminalProps>(
useEffect(() => {
let cancelled = false;
const loadKeybindings = () => {
cachedSnippetsRef.current = null;
getUserPreferences()
.then((prefs) => {
if (!cancelled) {
@@ -3165,23 +3173,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
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<TerminalHandle, SSHTerminalProps>(
webSocketRef,
writeTextToClipboard,
readTextFromClipboard,
getSnippetById,
hostContext: {
ip: hostConfig.ip,
username: hostConfig.username,
+54 -42
View File
@@ -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<string, string>,
@@ -49,38 +51,41 @@ export function useSnippetRunner() {
[confirmWithToast, t],
);
function sendResolvedToTerminal(
async function sendResolvedToTerminal(
tab: Tab,
snippet: Snippet,
inputValues: Record<string, string>,
) {
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<string, string>) => {
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],
);
+24 -17
View File
@@ -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<WebSocket | null>;
writeTextToClipboard: (text: string) => Promise<boolean>;
readTextFromClipboard: () => Promise<string>;
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;
}
-4
View File
@@ -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"
}
+3 -17
View File
@@ -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,
+1 -2
View File
@@ -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() {
+3 -2
View File
@@ -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
-2
View File
@@ -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",
-17
View File
@@ -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 <Fingerprint className="size-3.5" />;
case "session-logs":
return <ScrollText className="size-3.5" />;
case "snippets":
return <Play className="size-3.5" />;
case "macros":
return <Braces className="size-3.5" />;
case "history":
@@ -371,17 +365,6 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
case "split-screen":
return null;
case "snippets":
return withTabSuspense(
<PanelTabFrame>
<SnippetsPanel
terminalTabs={panelProps?.terminalTabs ?? []}
activeTabId={panelProps?.targetTerminalTabId ?? ""}
storageMode={panelProps?.storageMode ?? "local"}
/>
</PanelTabFrame>,
);
case "macros":
return withTabSuspense(
<PanelTabFrame>
+5 -3
View File
@@ -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({
</div>
))}
{activeTab === "snippets" &&
{activeTab === "user-snippets" &&
(!dataUnlocked ? (
lockedNotice
) : (
File diff suppressed because it is too large. Load diff
+4 -30
View File
@@ -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);
-10
View File
@@ -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,
+34 -17
View File
@@ -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<KeybindingDispatchContext> = {},
@@ -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<typeof vi.fn>).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();
@@ -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");
});
});
@@ -51,7 +51,7 @@ describe("ONBOARDING_STEPS", () => {
describe("onboarding step body translations", () => {
const KEYS: Record<string, string[]> = {
welcome: ["hosts", "terminal", "files"],
feature: ["files", "tunnels", "snippets"],
feature: ["files", "tunnels"],
workflow: ["palette", "split", "dock"],
security: ["credentials", "twofa", "identity", "sharing"],
done: ["settings", "rerun", "docs"],
+3 -12
View File
@@ -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",