mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: point core frontend snippets pickers at the plugin (B2)
This commit is contained in:
1 parent
e07221eaf3
commit
1eac0f54b0
22 files changed
+170
-2968
No files matched your search
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user