mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 16:15: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
+153
-2951
No files matched your search
@@ -86,9 +86,6 @@ const SshToolsPanel = lazy(() =>
|
|||||||
const CollabPanel = lazy(() =>
|
const CollabPanel = lazy(() =>
|
||||||
import("@/sidebar/CollabPanel").then((m) => ({ default: m.CollabPanel })),
|
import("@/sidebar/CollabPanel").then((m) => ({ default: m.CollabPanel })),
|
||||||
);
|
);
|
||||||
const SnippetsPanel = lazy(() =>
|
|
||||||
import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
|
|
||||||
);
|
|
||||||
const MacrosPanel = lazy(() =>
|
const MacrosPanel = lazy(() =>
|
||||||
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
||||||
);
|
);
|
||||||
@@ -1018,7 +1015,6 @@ export function AppShell({
|
|||||||
"hostTrayOnClick",
|
"hostTrayOnClick",
|
||||||
"pinAppRail",
|
"pinAppRail",
|
||||||
"expandAppRailOnHover",
|
"expandAppRailOnHover",
|
||||||
"defaultSnippetFoldersCollapsed",
|
|
||||||
"confirmSnippetExecution",
|
"confirmSnippetExecution",
|
||||||
"disableUpdateCheck",
|
"disableUpdateCheck",
|
||||||
"confirmTabClose",
|
"confirmTabClose",
|
||||||
@@ -1086,14 +1082,6 @@ export function AppShell({
|
|||||||
);
|
);
|
||||||
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
window.dispatchEvent(new Event("expandAppRailOnHoverChanged"));
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
prefs.foldersCollapsed !== null &&
|
|
||||||
prefs.foldersCollapsed !== undefined
|
|
||||||
)
|
|
||||||
localStorage.setItem(
|
|
||||||
"defaultSnippetFoldersCollapsed",
|
|
||||||
String(prefs.foldersCollapsed),
|
|
||||||
);
|
|
||||||
if (
|
if (
|
||||||
prefs.confirmSnippetExecution !== null &&
|
prefs.confirmSnippetExecution !== null &&
|
||||||
prefs.confirmSnippetExecution !== undefined
|
prefs.confirmSnippetExecution !== undefined
|
||||||
@@ -2570,18 +2558,6 @@ export function AppShell({
|
|||||||
</div>
|
</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" && (
|
{railView === "macros" && (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
<MacrosPanel
|
<MacrosPanel
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ export async function adminGetUserSnippets(
|
|||||||
targetUserId: string,
|
targetUserId: string,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/snippets", {
|
const response = await authApi.get("/plugin-api/snippets", {
|
||||||
headers: adminHeaders(targetUserId),
|
headers: adminHeaders(targetUserId),
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -209,7 +209,7 @@ export async function adminCreateUserSnippet(
|
|||||||
snippetData: Record<string, unknown>,
|
snippetData: Record<string, unknown>,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/snippets", snippetData, {
|
const response = await authApi.post("/plugin-api/snippets", snippetData, {
|
||||||
headers: adminHeaders(targetUserId),
|
headers: adminHeaders(targetUserId),
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
@@ -224,9 +224,13 @@ export async function adminUpdateUserSnippet(
|
|||||||
snippetData: Record<string, unknown>,
|
snippetData: Record<string, unknown>,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.put(`/snippets/${snippetId}`, snippetData, {
|
const response = await authApi.put(
|
||||||
|
`/plugin-api/snippets/${snippetId}`,
|
||||||
|
snippetData,
|
||||||
|
{
|
||||||
headers: adminHeaders(targetUserId),
|
headers: adminHeaders(targetUserId),
|
||||||
});
|
},
|
||||||
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "update user's snippet");
|
throw handleApiError(error, "update user's snippet");
|
||||||
@@ -238,7 +242,7 @@ export async function adminDeleteUserSnippet(
|
|||||||
snippetId: number,
|
snippetId: number,
|
||||||
): Promise<Record<string, unknown>> {
|
): Promise<Record<string, unknown>> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.delete(`/snippets/${snippetId}`, {
|
const response = await authApi.delete(`/plugin-api/snippets/${snippetId}`, {
|
||||||
headers: adminHeaders(targetUserId),
|
headers: adminHeaders(targetUserId),
|
||||||
});
|
});
|
||||||
return response.data;
|
return response.data;
|
||||||
|
|||||||
@@ -115,7 +115,6 @@ export interface UserPreferences {
|
|||||||
pinAppRail?: boolean | null;
|
pinAppRail?: boolean | null;
|
||||||
expandAppRailOnHover?: boolean | null;
|
expandAppRailOnHover?: boolean | null;
|
||||||
showPinAppRailButton?: boolean | null;
|
showPinAppRailButton?: boolean | null;
|
||||||
foldersCollapsed?: boolean | null;
|
|
||||||
confirmSnippetExecution?: boolean | null;
|
confirmSnippetExecution?: boolean | null;
|
||||||
disableUpdateCheck?: boolean | null;
|
disableUpdateCheck?: boolean | null;
|
||||||
confirmTabClose?: 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 type CredentialPermissionLevel = "use" | "manage";
|
||||||
|
|
||||||
export async function shareCredential(
|
export async function shareCredential(
|
||||||
@@ -480,48 +439,3 @@ export async function revokeCredentialAccess(
|
|||||||
throw handleApiError(error, "revoke credential access");
|
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
|
* 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.
|
* 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 {
|
export interface SnippetRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -11,54 +17,20 @@ export interface SnippetRow {
|
|||||||
|
|
||||||
export async function getSnippets(): Promise<SnippetRow[]> {
|
export async function getSnippets(): Promise<SnippetRow[]> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get("/snippets");
|
const response = await authApi.get("/plugin-api/snippets");
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw handleApiError(error, "fetch snippets");
|
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(
|
export async function executeSnippet(
|
||||||
snippetId: number,
|
snippetId: number,
|
||||||
hostId: number,
|
hostId: number,
|
||||||
inputValues?: Record<string, string>,
|
inputValues?: Record<string, string>,
|
||||||
): Promise<{ success: boolean; output: string; error?: string }> {
|
): Promise<{ success: boolean; output: string; error?: string }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post("/snippets/execute", {
|
const response = await authApi.post("/plugin-api/snippets/execute", {
|
||||||
snippetId,
|
snippetId,
|
||||||
hostId,
|
hostId,
|
||||||
...(inputValues ? { inputValues } : {}),
|
...(inputValues ? { inputValues } : {}),
|
||||||
@@ -68,130 +40,3 @@ export async function executeSnippet(
|
|||||||
throw handleApiError(error, "execute snippet");
|
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,
|
getCookie,
|
||||||
isElectron,
|
isElectron,
|
||||||
logActivity,
|
logActivity,
|
||||||
getSnippets,
|
|
||||||
deleteCommandFromHistory,
|
deleteCommandFromHistory,
|
||||||
getCommandHistory,
|
getCommandHistory,
|
||||||
getHostPassword,
|
getHostPassword,
|
||||||
} from "@/main-axios.ts";
|
} from "@/main-axios.ts";
|
||||||
|
import { invokeAction } from "@/shell/action-registry";
|
||||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||||
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
||||||
import { PassphraseDialog } from "@/ssh/dialogs/PassphraseDialog.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 terminalInputDisposableRef = useRef<{ dispose(): void } | null>(null);
|
||||||
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
|
const localEchoRef = useRef<TerminalLocalEcho | null>(null);
|
||||||
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
const customKeybindingsRef = useRef<CustomKeybinding[]>([]);
|
||||||
const cachedSnippetsRef = useRef<{ id: number; content: string }[] | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [pendingKeybindingSnippet, setPendingKeybindingSnippet] = useState<{
|
const [pendingKeybindingSnippet, setPendingKeybindingSnippet] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
content: string;
|
content: string;
|
||||||
@@ -1961,16 +1958,28 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
|
|
||||||
if (terminalConfig.startupSnippetId) {
|
if (terminalConfig.startupSnippetId) {
|
||||||
try {
|
try {
|
||||||
const snippets = await getSnippets();
|
const resolved = (await invokeAction(
|
||||||
const snippet = snippets.find(
|
"snippets.resolveForTerminal",
|
||||||
(s: { id: number }) =>
|
terminalConfig.startupSnippetId,
|
||||||
s.id === terminalConfig.startupSnippetId,
|
{
|
||||||
);
|
ip: hostConfig.ip,
|
||||||
if (snippet && ws.readyState === 1) {
|
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(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "input",
|
type: "input",
|
||||||
data: snippet.content + "\n",
|
data: resolved.content + "\n",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3141,7 +3150,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const loadKeybindings = () => {
|
const loadKeybindings = () => {
|
||||||
cachedSnippetsRef.current = null;
|
|
||||||
getUserPreferences()
|
getUserPreferences()
|
||||||
.then((prefs) => {
|
.then((prefs) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
@@ -3165,23 +3173,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!terminal) return;
|
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 => {
|
const handleCustomKey = (e: KeyboardEvent): boolean => {
|
||||||
if (e.type !== "keydown") {
|
if (e.type !== "keydown") {
|
||||||
return true;
|
return true;
|
||||||
@@ -3202,7 +3193,6 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
webSocketRef,
|
webSocketRef,
|
||||||
writeTextToClipboard,
|
writeTextToClipboard,
|
||||||
readTextFromClipboard,
|
readTextFromClipboard,
|
||||||
getSnippetById,
|
|
||||||
hostContext: {
|
hostContext: {
|
||||||
ip: hostConfig.ip,
|
ip: hostConfig.ip,
|
||||||
username: hostConfig.username,
|
username: hostConfig.username,
|
||||||
|
|||||||
@@ -2,28 +2,30 @@ import { useCallback, useState } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||||
import {
|
import { invokeAction } from "@/shell/action-registry";
|
||||||
hasSnippetInputs,
|
|
||||||
resolveSnippetContent,
|
|
||||||
type SnippetHostContext,
|
|
||||||
} from "@/lib/snippet-variables";
|
|
||||||
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
|
import { SnippetVariablesDialog } from "@/components/SnippetVariablesDialog";
|
||||||
import type { Snippet, Tab } from "@/types/ui-types";
|
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
|
* Shared "run this snippet against these terminal tabs" flow, for the few
|
||||||
* $HOST-style vars per target, prompts once for $INPUT_n placeholders when
|
* core surfaces (the command palette) that still reach for a snippet
|
||||||
* present, and gates on the confirm-before-running setting. Used by both the
|
* directly rather than through the snippets plugin's own panel. Variable
|
||||||
* Snippets sidebar panel and the command palette's snippet entries so the
|
* resolution runs through the snippets plugin's "snippets.resolveForTerminal"
|
||||||
* behavior (and the dialog) is identical everywhere a snippet gets sent to a
|
* action, and sending runs through ssh-terminal's "terminal.sendToSession",
|
||||||
* terminal.
|
* since core no longer owns snippet content or a terminal ref.
|
||||||
*/
|
*/
|
||||||
export function useSnippetRunner() {
|
export function useSnippetRunner() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { confirmWithToast } = useConfirmation();
|
const { confirmWithToast } = useConfirmation();
|
||||||
const [runningSnippet, setRunningSnippet] = useState<{
|
const [runningSnippet, setRunningSnippet] = useState<{
|
||||||
snippet: Snippet;
|
snippet: Snippet;
|
||||||
host: SnippetHostContext | null;
|
host: Tab["host"] | null;
|
||||||
onConfirm: (
|
onConfirm: (
|
||||||
resolvedContent: string,
|
resolvedContent: string,
|
||||||
inputValues: Record<string, string>,
|
inputValues: Record<string, string>,
|
||||||
@@ -49,30 +51,32 @@ export function useSnippetRunner() {
|
|||||||
[confirmWithToast, t],
|
[confirmWithToast, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
function sendResolvedToTerminal(
|
async function sendResolvedToTerminal(
|
||||||
tab: Tab,
|
tab: Tab,
|
||||||
snippet: Snippet,
|
snippet: Snippet,
|
||||||
inputValues: Record<string, string>,
|
inputValues: Record<string, string>,
|
||||||
) {
|
) {
|
||||||
const content = resolveSnippetContent(
|
const resolved = (await invokeAction(
|
||||||
snippet.content,
|
"snippets.resolveForTerminal",
|
||||||
|
snippet.id,
|
||||||
tab.host ?? null,
|
tab.host ?? null,
|
||||||
inputValues,
|
inputValues,
|
||||||
);
|
)) as ResolvedForTerminal | null | undefined;
|
||||||
if (snippet.isNote) {
|
if (!resolved || resolved.needsInputs) return;
|
||||||
tab.terminalRef?.current?.paste?.(content);
|
await invokeAction("terminal.sendToSession", tab.id, resolved.content, {
|
||||||
} else {
|
run: !resolved.isNote,
|
||||||
tab.terminalRef?.current?.sendInput?.(content + "\r");
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const runSnippet = useCallback(
|
const runSnippet = useCallback(
|
||||||
(snippet: Snippet, targets: Tab[]) => {
|
(snippet: Snippet, targets: Tab[]) => {
|
||||||
const runWithInputs = (inputValues: Record<string, string>) => {
|
const runWithInputs = (inputValues: Record<string, string>) => {
|
||||||
const doSend = () => {
|
const doSend = () => {
|
||||||
targets.forEach((tab) =>
|
void Promise.all(
|
||||||
|
targets.map((tab) =>
|
||||||
sendResolvedToTerminal(tab, snippet, inputValues),
|
sendResolvedToTerminal(tab, snippet, inputValues),
|
||||||
);
|
),
|
||||||
|
).then(() => {
|
||||||
toast.success(
|
toast.success(
|
||||||
t(
|
t(
|
||||||
snippet.isNote
|
snippet.isNote
|
||||||
@@ -81,6 +85,7 @@ export function useSnippetRunner() {
|
|||||||
{ name: snippet.name, count: targets.length },
|
{ name: snippet.name, count: targets.length },
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
if (snippet.isNote) {
|
if (snippet.isNote) {
|
||||||
doSend();
|
doSend();
|
||||||
@@ -89,7 +94,13 @@ export function useSnippetRunner() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (hasSnippetInputs(snippet.content)) {
|
void invokeAction(
|
||||||
|
"snippets.resolveForTerminal",
|
||||||
|
snippet.id,
|
||||||
|
targets[0]?.host ?? null,
|
||||||
|
).then((result) => {
|
||||||
|
const resolved = result as ResolvedForTerminal | null | undefined;
|
||||||
|
if (resolved?.needsInputs) {
|
||||||
setRunningSnippet({
|
setRunningSnippet({
|
||||||
snippet,
|
snippet,
|
||||||
host: targets[0]?.host ?? null,
|
host: targets[0]?.host ?? null,
|
||||||
@@ -98,9 +109,10 @@ export function useSnippetRunner() {
|
|||||||
runWithInputs(inputValues);
|
runWithInputs(inputValues);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
return;
|
||||||
runWithInputs({});
|
|
||||||
}
|
}
|
||||||
|
runWithInputs({});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[handleConfirmRun, t],
|
[handleConfirmRun, t],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
import type { Terminal } from "@xterm/xterm";
|
import type { Terminal } from "@xterm/xterm";
|
||||||
import type { KeybindingAction } from "@/types/keybindings";
|
import type { KeybindingAction } from "@/types/keybindings";
|
||||||
import {
|
import { invokeAction } from "@/shell/action-registry";
|
||||||
hasSnippetInputs,
|
|
||||||
resolveSnippetContent,
|
export interface SnippetHostContext {
|
||||||
type SnippetHostContext,
|
ip?: string;
|
||||||
} from "@/lib/snippet-variables";
|
username?: string;
|
||||||
|
port?: number | string;
|
||||||
|
name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface KeybindingDispatchContext {
|
export interface KeybindingDispatchContext {
|
||||||
terminal: Terminal;
|
terminal: Terminal;
|
||||||
webSocketRef: React.MutableRefObject<WebSocket | null>;
|
webSocketRef: React.MutableRefObject<WebSocket | null>;
|
||||||
writeTextToClipboard: (text: string) => Promise<boolean>;
|
writeTextToClipboard: (text: string) => Promise<boolean>;
|
||||||
readTextFromClipboard: () => Promise<string>;
|
readTextFromClipboard: () => Promise<string>;
|
||||||
getSnippetById: (id: string) => Promise<{ content: string } | undefined>;
|
/** Host context used to resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
|
||||||
/** Host context used to silently resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
|
|
||||||
hostContext?: SnippetHostContext | null;
|
hostContext?: SnippetHostContext | null;
|
||||||
/**
|
/**
|
||||||
* Called instead of sending the snippet directly when its content still has
|
* Called instead of sending the snippet directly when its content still has
|
||||||
@@ -64,21 +66,26 @@ export function dispatchKeybindingAction(
|
|||||||
}
|
}
|
||||||
case "runSnippet": {
|
case "runSnippet": {
|
||||||
if (!action.snippetId) return;
|
if (!action.snippetId) return;
|
||||||
ctx.getSnippetById(action.snippetId).then((snippet) => {
|
const snippetId = Number(action.snippetId);
|
||||||
if (!snippet) return;
|
if (!Number.isFinite(snippetId)) return;
|
||||||
if (hasSnippetInputs(snippet.content)) {
|
// 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?.({
|
ctx.onSnippetNeedsInputs?.({
|
||||||
id: action.snippetId!,
|
id: action.snippetId!,
|
||||||
content: snippet.content,
|
content: resolved.content,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const resolved = resolveSnippetContent(
|
sendRaw(resolved.content + (action.appendEnter !== false ? "\r" : ""));
|
||||||
snippet.content,
|
|
||||||
ctx.hostContext ?? null,
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
sendRaw(resolved + (action.appendEnter !== false ? "\r" : ""));
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -624,8 +624,6 @@
|
|||||||
"feature_files_desc": "Browse, upload, download and edit files over SFTP without leaving the app.",
|
"feature_files_desc": "Browse, upload, download and edit files over SFTP without leaving the app.",
|
||||||
"feature_tunnels": "Tunnels",
|
"feature_tunnels": "Tunnels",
|
||||||
"feature_tunnels_desc": "Forward ports through a server to reach services that are not exposed.",
|
"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.",
|
"featuresRailHint": "Do not need one of these? Hide any icon from the sidebar in Settings, under Navigation.",
|
||||||
"workflowTitle": "Move around faster",
|
"workflowTitle": "Move around faster",
|
||||||
"workflowIntro": "A few habits that save the most time once you have a couple of servers.",
|
"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.",
|
"multiPanelHintDockHow": "Right-click its rail icon, or use the panel icon in the tab bar.",
|
||||||
"sidebarSettings": "Sidebar Settings...",
|
"sidebarSettings": "Sidebar Settings...",
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"snippets": "Snippets",
|
|
||||||
"macros": "Macros",
|
"macros": "Macros",
|
||||||
"hostManager": "Host Manager",
|
"hostManager": "Host Manager",
|
||||||
"credentials": "Credentials",
|
"credentials": "Credentials",
|
||||||
@@ -3201,7 +3198,6 @@
|
|||||||
"pluginDisabled": "Plugin disabled",
|
"pluginDisabled": "Plugin disabled",
|
||||||
"groups": {
|
"groups": {
|
||||||
"hosts": "Hosts",
|
"hosts": "Hosts",
|
||||||
"snippets": "Snippets",
|
|
||||||
"credentials": "Credentials",
|
"credentials": "Credentials",
|
||||||
"admin": "Administration"
|
"admin": "Administration"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-17
@@ -2150,20 +2150,10 @@ export {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SNIPPETS API
|
// 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 {
|
export { getSnippets, executeSnippet } from "@/api/snippets-api";
|
||||||
getSnippets,
|
|
||||||
createSnippet,
|
|
||||||
updateSnippet,
|
|
||||||
deleteSnippet,
|
|
||||||
executeSnippet,
|
|
||||||
getSnippetFolders,
|
|
||||||
createSnippetFolder,
|
|
||||||
updateSnippetFolderMetadata,
|
|
||||||
renameSnippetFolder,
|
|
||||||
deleteSnippetFolder,
|
|
||||||
reorderSnippets,
|
|
||||||
} from "@/api/snippets-api";
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
export type {
|
export type {
|
||||||
@@ -2216,10 +2206,6 @@ export {
|
|||||||
setHostAuthOverride,
|
setHostAuthOverride,
|
||||||
getPermissionsCatalog,
|
getPermissionsCatalog,
|
||||||
getSharedHosts,
|
getSharedHosts,
|
||||||
shareSnippet,
|
|
||||||
getSnippetAccess,
|
|
||||||
revokeSnippetAccess,
|
|
||||||
getSharedSnippets,
|
|
||||||
} from "@/api/rbac-api";
|
} from "@/api/rbac-api";
|
||||||
export type {
|
export type {
|
||||||
SharePermissionLevel,
|
SharePermissionLevel,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
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";
|
import { useActionSlot } from "@/hooks/use-action-slot";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,7 +11,6 @@ import { useActionSlot } from "@/hooks/use-action-slot";
|
|||||||
const FEATURES: { icon: LucideIcon; key: string }[] = [
|
const FEATURES: { icon: LucideIcon; key: string }[] = [
|
||||||
{ icon: FolderTree, key: "files" },
|
{ icon: FolderTree, key: "files" },
|
||||||
{ icon: Plug, key: "tunnels" },
|
{ icon: Plug, key: "tunnels" },
|
||||||
{ icon: Play, key: "snippets" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function FeaturesStep() {
|
export function FeaturesStep() {
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ import { useSshAuthProviders } from "@/hooks/useSshAuthProviders";
|
|||||||
/**
|
/**
|
||||||
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
|
* Core permission groups, mirroring RESERVED_PERMISSION_PREFIXES in the SDK
|
||||||
* manifest module, which is not imported here because it pulls semver into
|
* 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
|
* Resolves a permission the way the backend's ctx.rbac does: a short name
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ const CORE_CAPTURABLE = new Set([
|
|||||||
"homepage",
|
"homepage",
|
||||||
"termix-id",
|
"termix-id",
|
||||||
"session-logs",
|
"session-logs",
|
||||||
"snippets",
|
|
||||||
"macros",
|
"macros",
|
||||||
"history",
|
"history",
|
||||||
"ssh-tools",
|
"ssh-tools",
|
||||||
@@ -39,7 +38,6 @@ const CORE_SINGLETON = new Set([
|
|||||||
"homepage",
|
"homepage",
|
||||||
"termix-id",
|
"termix-id",
|
||||||
"session-logs",
|
"session-logs",
|
||||||
"snippets",
|
|
||||||
"macros",
|
"macros",
|
||||||
"history",
|
"history",
|
||||||
"ssh-tools",
|
"ssh-tools",
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
Hammer,
|
Hammer,
|
||||||
Play,
|
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Presentation,
|
Presentation,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
@@ -91,9 +90,6 @@ const SessionLogsPanel = lazy(() =>
|
|||||||
default: m.SessionLogsPanel,
|
default: m.SessionLogsPanel,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const SnippetsPanel = lazy(() =>
|
|
||||||
import("@/sidebar/SnippetsPanel").then((m) => ({ default: m.SnippetsPanel })),
|
|
||||||
);
|
|
||||||
const MacrosPanel = lazy(() =>
|
const MacrosPanel = lazy(() =>
|
||||||
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
import("@/sidebar/MacrosPanel").then((m) => ({ default: m.MacrosPanel })),
|
||||||
);
|
);
|
||||||
@@ -197,8 +193,6 @@ export function tabIcon(type: TabType) {
|
|||||||
return <Fingerprint className="size-3.5" />;
|
return <Fingerprint className="size-3.5" />;
|
||||||
case "session-logs":
|
case "session-logs":
|
||||||
return <ScrollText className="size-3.5" />;
|
return <ScrollText className="size-3.5" />;
|
||||||
case "snippets":
|
|
||||||
return <Play className="size-3.5" />;
|
|
||||||
case "macros":
|
case "macros":
|
||||||
return <Braces className="size-3.5" />;
|
return <Braces className="size-3.5" />;
|
||||||
case "history":
|
case "history":
|
||||||
@@ -371,17 +365,6 @@ export function renderTabContent(tab: Tab, context: TabRenderContext) {
|
|||||||
case "split-screen":
|
case "split-screen":
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
case "snippets":
|
|
||||||
return withTabSuspense(
|
|
||||||
<PanelTabFrame>
|
|
||||||
<SnippetsPanel
|
|
||||||
terminalTabs={panelProps?.terminalTabs ?? []}
|
|
||||||
activeTabId={panelProps?.targetTerminalTabId ?? ""}
|
|
||||||
storageMode={panelProps?.storageMode ?? "local"}
|
|
||||||
/>
|
|
||||||
</PanelTabFrame>,
|
|
||||||
);
|
|
||||||
|
|
||||||
case "macros":
|
case "macros":
|
||||||
return withTabSuspense(
|
return withTabSuspense(
|
||||||
<PanelTabFrame>
|
<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 =
|
type ManageTabId =
|
||||||
"account" | "hosts" | "credentials" | "snippets" | "sessions" | "danger";
|
"account" | "hosts" | "credentials" | "user-snippets" | "sessions" | "danger";
|
||||||
|
|
||||||
type ManagedSnippet = {
|
type ManagedSnippet = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -253,7 +255,7 @@ export function AdminUserManagePanel({
|
|||||||
{ id: "account", label: t("admin.manageTabAccount") },
|
{ id: "account", label: t("admin.manageTabAccount") },
|
||||||
{ id: "hosts", label: t("admin.manageTabHosts") },
|
{ id: "hosts", label: t("admin.manageTabHosts") },
|
||||||
{ id: "credentials", label: t("admin.manageTabCredentials") },
|
{ 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: "sessions", label: t("admin.manageTabSessions") },
|
||||||
{ id: "danger", label: t("admin.manageTabDanger") },
|
{ id: "danger", label: t("admin.manageTabDanger") },
|
||||||
];
|
];
|
||||||
@@ -996,7 +998,7 @@ export function AdminUserManagePanel({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{activeTab === "snippets" &&
|
{activeTab === "user-snippets" &&
|
||||||
(!dataUnlocked ? (
|
(!dataUnlocked ? (
|
||||||
lockedNotice
|
lockedNotice
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -524,7 +524,6 @@ export function UserProfilePanel({
|
|||||||
pinAppRail?: boolean | null;
|
pinAppRail?: boolean | null;
|
||||||
expandAppRailOnHover?: boolean | null;
|
expandAppRailOnHover?: boolean | null;
|
||||||
showPinAppRailButton?: boolean | null;
|
showPinAppRailButton?: boolean | null;
|
||||||
foldersCollapsed?: boolean | null;
|
|
||||||
confirmSnippetExecution?: boolean | null;
|
confirmSnippetExecution?: boolean | null;
|
||||||
disableUpdateCheck?: boolean | null;
|
disableUpdateCheck?: boolean | null;
|
||||||
confirmTabClose?: boolean | null;
|
confirmTabClose?: boolean | null;
|
||||||
@@ -708,12 +707,10 @@ export function UserProfilePanel({
|
|||||||
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
|
const [showPinAppRailButton, setShowPinAppRailButton] = useState(() =>
|
||||||
readRailPreference("showPinAppRailButton"),
|
readRailPreference("showPinAppRailButton"),
|
||||||
);
|
);
|
||||||
// Read values are unused now that the Snippets settings UI lives in
|
// Read value is unused; the setter still backs the cloud-sync/reset/
|
||||||
// SnippetsPanel.tsx; the setters still back the cloud-sync/reset/snapshot
|
// snapshot machinery for this localStorage-backed pref below. The
|
||||||
// machinery for these two localStorage-backed prefs below.
|
// folder-collapse setting itself now lives in the snippets plugin's own
|
||||||
const [_foldersCollapsed, setFoldersCollapsed] = useState(
|
// user settings.
|
||||||
() => localStorage.getItem("defaultSnippetFoldersCollapsed") !== "false",
|
|
||||||
);
|
|
||||||
const [_confirmSnippetExecution, setConfirmSnippetExecution] = useState(
|
const [_confirmSnippetExecution, setConfirmSnippetExecution] = useState(
|
||||||
() => localStorage.getItem("confirmSnippetExecution") === "true",
|
() => localStorage.getItem("confirmSnippetExecution") === "true",
|
||||||
);
|
);
|
||||||
@@ -850,7 +847,6 @@ export function UserProfilePanel({
|
|||||||
"pinAppRail",
|
"pinAppRail",
|
||||||
"expandAppRailOnHover",
|
"expandAppRailOnHover",
|
||||||
"showPinAppRailButton",
|
"showPinAppRailButton",
|
||||||
"defaultSnippetFoldersCollapsed",
|
|
||||||
"snippetShowCommands",
|
"snippetShowCommands",
|
||||||
"confirmSnippetExecution",
|
"confirmSnippetExecution",
|
||||||
"disableUpdateCheck",
|
"disableUpdateCheck",
|
||||||
@@ -926,13 +922,6 @@ export function UserProfilePanel({
|
|||||||
);
|
);
|
||||||
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
|
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
|
||||||
}
|
}
|
||||||
if (prefs.foldersCollapsed != null) {
|
|
||||||
setFoldersCollapsed(prefs.foldersCollapsed);
|
|
||||||
localStorage.setItem(
|
|
||||||
"defaultSnippetFoldersCollapsed",
|
|
||||||
String(prefs.foldersCollapsed),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (prefs.confirmSnippetExecution != null) {
|
if (prefs.confirmSnippetExecution != null) {
|
||||||
setConfirmSnippetExecution(prefs.confirmSnippetExecution);
|
setConfirmSnippetExecution(prefs.confirmSnippetExecution);
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
@@ -1007,8 +996,6 @@ export function UserProfilePanel({
|
|||||||
setShowPinAppRailButton(false);
|
setShowPinAppRailButton(false);
|
||||||
localStorage.setItem("showPinAppRailButton", "false");
|
localStorage.setItem("showPinAppRailButton", "false");
|
||||||
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
|
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
|
||||||
setFoldersCollapsed(true);
|
|
||||||
localStorage.removeItem("defaultSnippetFoldersCollapsed");
|
|
||||||
setConfirmSnippetExecution(false);
|
setConfirmSnippetExecution(false);
|
||||||
localStorage.setItem("confirmSnippetExecution", "false");
|
localStorage.setItem("confirmSnippetExecution", "false");
|
||||||
setDisableUpdateCheck(false);
|
setDisableUpdateCheck(false);
|
||||||
@@ -1032,7 +1019,6 @@ export function UserProfilePanel({
|
|||||||
pinAppRail: false,
|
pinAppRail: false,
|
||||||
expandAppRailOnHover: true,
|
expandAppRailOnHover: true,
|
||||||
showPinAppRailButton: false,
|
showPinAppRailButton: false,
|
||||||
foldersCollapsed: true,
|
|
||||||
confirmSnippetExecution: false,
|
confirmSnippetExecution: false,
|
||||||
disableUpdateCheck: false,
|
disableUpdateCheck: false,
|
||||||
confirmTabClose: false,
|
confirmTabClose: false,
|
||||||
@@ -1121,18 +1107,6 @@ export function UserProfilePanel({
|
|||||||
localStorage.setItem("showPinAppRailButton", String(restoredShowPinButton));
|
localStorage.setItem("showPinAppRailButton", String(restoredShowPinButton));
|
||||||
window.dispatchEvent(new Event("showPinAppRailButtonChanged"));
|
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 =
|
const restoredConfirmSnippet =
|
||||||
restore("confirmSnippetExecution", "false") === "true";
|
restore("confirmSnippetExecution", "false") === "true";
|
||||||
setConfirmSnippetExecution(restoredConfirmSnippet);
|
setConfirmSnippetExecution(restoredConfirmSnippet);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutPanelLeft,
|
LayoutPanelLeft,
|
||||||
Network,
|
Network,
|
||||||
Play,
|
|
||||||
Plug,
|
Plug,
|
||||||
ScrollText,
|
ScrollText,
|
||||||
Server,
|
Server,
|
||||||
@@ -132,15 +131,6 @@ export const RAIL_ITEMS: RailItemDef[] = [
|
|||||||
labelKey: "nav.sftp",
|
labelKey: "nav.sftp",
|
||||||
separatorAfter: true,
|
separatorAfter: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "snippets",
|
|
||||||
icon: Play,
|
|
||||||
labelKey: "nav.snippets",
|
|
||||||
separatorAfter: true,
|
|
||||||
mobilePrimary: true,
|
|
||||||
promotable: true,
|
|
||||||
rightDockable: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "macros",
|
id: "macros",
|
||||||
icon: Braces,
|
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 { dispatchKeybindingAction } from "../../lib/keybinding-dispatch";
|
||||||
import type { Terminal } from "@xterm/xterm";
|
import type { Terminal } from "@xterm/xterm";
|
||||||
import type { KeybindingDispatchContext } from "../../lib/keybinding-dispatch";
|
import type { KeybindingDispatchContext } from "../../lib/keybinding-dispatch";
|
||||||
|
import * as actionRegistry from "../../shell/action-registry";
|
||||||
|
|
||||||
function makeContext(
|
function makeContext(
|
||||||
overrides: Partial<KeybindingDispatchContext> = {},
|
overrides: Partial<KeybindingDispatchContext> = {},
|
||||||
@@ -25,7 +26,6 @@ function makeContext(
|
|||||||
webSocketRef: { current: ws as unknown as WebSocket },
|
webSocketRef: { current: ws as unknown as WebSocket },
|
||||||
writeTextToClipboard: vi.fn().mockResolvedValue(true),
|
writeTextToClipboard: vi.fn().mockResolvedValue(true),
|
||||||
readTextFromClipboard: vi.fn().mockResolvedValue(""),
|
readTextFromClipboard: vi.fn().mockResolvedValue(""),
|
||||||
getSnippetById: vi.fn().mockResolvedValue(undefined),
|
|
||||||
sentData,
|
sentData,
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -33,6 +33,10 @@ function makeContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("dispatchKeybindingAction", () => {
|
describe("dispatchKeybindingAction", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
it("copy writes the selection to clipboard and clears it", () => {
|
it("copy writes the selection to clipboard and clears it", () => {
|
||||||
const ctx = makeContext();
|
const ctx = makeContext();
|
||||||
(ctx.terminal.getSelection as ReturnType<typeof vi.fn>).mockReturnValue(
|
(ctx.terminal.getSelection as ReturnType<typeof vi.fn>).mockReturnValue(
|
||||||
@@ -84,45 +88,58 @@ describe("dispatchKeybindingAction", () => {
|
|||||||
expect(ctx.sentData).toEqual(["ls -la\r"]);
|
expect(ctx.sentData).toEqual(["ls -la\r"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runSnippet resolves the snippet and sends its content with a trailing return", async () => {
|
it("runSnippet resolves the snippet through the snippets.resolveForTerminal action and sends its content with a trailing return", async () => {
|
||||||
const ctx = makeContext({
|
vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue({
|
||||||
getSnippetById: vi.fn().mockResolvedValue({ content: "uptime" }),
|
needsInputs: false,
|
||||||
|
content: "uptime",
|
||||||
});
|
});
|
||||||
|
const ctx = makeContext();
|
||||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
expect(actionRegistry.invokeAction).toHaveBeenCalledWith(
|
||||||
|
"snippets.resolveForTerminal",
|
||||||
|
1,
|
||||||
|
null,
|
||||||
|
);
|
||||||
expect(ctx.sentData).toEqual(["uptime\r"]);
|
expect(ctx.sentData).toEqual(["uptime\r"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("runSnippet does nothing when the snippet no longer exists", async () => {
|
it("runSnippet does nothing when the snippet no longer exists", async () => {
|
||||||
const ctx = makeContext({
|
vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue(undefined);
|
||||||
getSnippetById: vi.fn().mockResolvedValue(undefined),
|
const ctx = makeContext();
|
||||||
});
|
|
||||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(ctx.sentData).toEqual([]);
|
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({
|
const ctx = makeContext({
|
||||||
getSnippetById: vi
|
|
||||||
.fn()
|
|
||||||
.mockResolvedValue({ content: "ping $HOST -p $PORT" }),
|
|
||||||
hostContext: { ip: "10.0.0.5", username: "root", port: 22 },
|
hostContext: { ip: "10.0.0.5", username: "root", port: 22 },
|
||||||
});
|
});
|
||||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
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"]);
|
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 () => {
|
it("runSnippet defers to onSnippetNeedsInputs instead of sending when the action reports needsInputs", async () => {
|
||||||
const onSnippetNeedsInputs = vi.fn();
|
vi.spyOn(actionRegistry, "invokeAction").mockResolvedValue({
|
||||||
const ctx = makeContext({
|
needsInputs: true,
|
||||||
getSnippetById: vi.fn().mockResolvedValue({ content: "echo $INPUT_1" }),
|
content: "echo $INPUT_1",
|
||||||
onSnippetNeedsInputs,
|
|
||||||
});
|
});
|
||||||
|
const onSnippetNeedsInputs = vi.fn();
|
||||||
|
const ctx = makeContext({ onSnippetNeedsInputs });
|
||||||
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
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", () => {
|
describe("onboarding step body translations", () => {
|
||||||
const KEYS: Record<string, string[]> = {
|
const KEYS: Record<string, string[]> = {
|
||||||
welcome: ["hosts", "terminal", "files"],
|
welcome: ["hosts", "terminal", "files"],
|
||||||
feature: ["files", "tunnels", "snippets"],
|
feature: ["files", "tunnels"],
|
||||||
workflow: ["palette", "split", "dock"],
|
workflow: ["palette", "split", "dock"],
|
||||||
security: ["credentials", "twofa", "identity", "sharing"],
|
security: ["credentials", "twofa", "identity", "sharing"],
|
||||||
done: ["settings", "rerun", "docs"],
|
done: ["settings", "rerun", "docs"],
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ describe("RAIL_ITEMS", () => {
|
|||||||
"serial",
|
"serial",
|
||||||
"ssh-tools",
|
"ssh-tools",
|
||||||
"sftp",
|
"sftp",
|
||||||
"snippets",
|
|
||||||
"macros",
|
"macros",
|
||||||
"history",
|
"history",
|
||||||
"session-logs",
|
"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(
|
expect(
|
||||||
RAIL_ITEMS.filter((item) => item.mobilePrimary).map((item) => item.id),
|
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", () => {
|
it("marks the panels that can open as a tab", () => {
|
||||||
@@ -85,14 +84,7 @@ describe("RAIL_ITEMS", () => {
|
|||||||
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
|
[...RAIL_ITEMS, ...RAIL_UTILITY_ITEMS]
|
||||||
.filter((item) => item.promotable)
|
.filter((item) => item.promotable)
|
||||||
.map((item) => item.id),
|
.map((item) => item.id),
|
||||||
).toEqual([
|
).toEqual(["termix-id", "ssh-tools", "macros", "history", "session-logs"]);
|
||||||
"termix-id",
|
|
||||||
"ssh-tools",
|
|
||||||
"snippets",
|
|
||||||
"macros",
|
|
||||||
"history",
|
|
||||||
"session-logs",
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("derives promotableIds from the promotable flag", () => {
|
it("derives promotableIds from the promotable flag", () => {
|
||||||
@@ -132,7 +124,6 @@ describe("RAIL_ITEMS", () => {
|
|||||||
expect(rightDockableIds()).toEqual([
|
expect(rightDockableIds()).toEqual([
|
||||||
"connections",
|
"connections",
|
||||||
"ssh-tools",
|
"ssh-tools",
|
||||||
"snippets",
|
|
||||||
"macros",
|
"macros",
|
||||||
"history",
|
"history",
|
||||||
"session-logs",
|
"session-logs",
|
||||||
|
|||||||
Reference in new issue
Block a user