diff --git a/packages/ui/e2e/sftp.spec.ts b/packages/ui/e2e/sftp.spec.ts index 812c082..5156d6c 100644 --- a/packages/ui/e2e/sftp.spec.ts +++ b/packages/ui/e2e/sftp.spec.ts @@ -636,6 +636,28 @@ test('a snippet without the file placeholder still runs, in the current director expect(commands.some((c) => c.startsWith('cd '))).toBe(true); }); +test('"Run snippet here" on empty space offers the snippets without a file and runs one in this folder', async ({ page }) => { + await boot(page); + await page.getByTitle('files on web-1').click(); + const remotePane = page.getByRole('region', { name: 'web-1', exact: true }); + await expect(remotePane.getByText('config.yml')).toBeVisible(); + + const region = page.getByRole('region', { name: 'web-1 file list' }); + const box = await region.boundingBox(); + await region.click({ button: 'right', position: { x: 10, y: (box?.height ?? 200) - 10 } }); + await page.getByRole('menu').getByRole('menuitem', { name: 'Run snippet here…' }).click(); + + // Only what doesn't need a file: "extract" wants {{file}}, so it isn't offered here. + const snippetMenu = page.getByRole('menu'); + await expect(snippetMenu.getByRole('menuitem', { name: 'disk free' })).toBeVisible(); + await expect(snippetMenu.getByRole('menuitem', { name: /extract/ })).toHaveCount(0); + await snippetMenu.getByRole('menuitem', { name: 'disk free' }).click(); + + await expect + .poll(() => page.evaluate(() => (window as unknown as { __terminalCommands: string[] }).__terminalCommands)) + .toContain("cd '/' && df -h"); +}); + test('each entry gets its file-type icon, and an unknown type falls back', async ({ page }) => { const failedIconRequests: string[] = []; page.on('response', (r) => { diff --git a/packages/ui/src/lib/screens/SftpView.svelte b/packages/ui/src/lib/screens/SftpView.svelte index 21dcbef..d7964cf 100644 --- a/packages/ui/src/lib/screens/SftpView.svelte +++ b/packages/ui/src/lib/screens/SftpView.svelte @@ -47,7 +47,7 @@ sftpDefaultPath } from '$lib/ipc/commands'; import { isOnePasswordReference } from './onePasswordRef'; - import { fillFilePlaceholder, usesFilePlaceholder } from './snippetPlaceholders'; + import { commandInFolder, fillFilePlaceholder, usesFilePlaceholder } from './snippetPlaceholders'; let { session, active }: { session: Session; active: boolean } = $props(); @@ -623,8 +623,43 @@ ]; } - function remoteEmptyMenuItems(currentView: NonNullable): ContextMenuItem[] { + /** Offers the snippets that don't take a file (`docker system prune`, `git pull`) for + * the folder being browsed, and runs the chosen one in the drawer terminal there — + * the same way as a file snippet, just with a `cd` into the folder first. */ + async function openFolderSnippetPicker(folder: string, x: number, y: number): Promise { + let snippets: SnippetDto[]; + try { + snippets = (await listSnippets()).filter((s) => !usesFilePlaceholder(s.command)); + } catch (err) { + lastError.set(errMsg(err)); + return; + } + contextMenu = { + side: 'remote', + x, + y, + items: + snippets.length === 0 + ? [{ label: 'No snippets without {{file}} saved yet', onSelect: () => {}, disabled: true }] + : snippets.map((snippet) => ({ + label: snippet.name, + icon: 'play' as const, + onSelect: () => { + const command = commandInFolder(snippet.command, folder); + showTerminal = true; + void tick().then(() => terminalDrawer?.runCommand(command)); + } + })) + }; + } + + function remoteEmptyMenuItems(currentView: NonNullable, event: MouseEvent): ContextMenuItem[] { return [ + { + label: 'Run snippet here…', + icon: 'play', + onSelect: () => void openFolderSnippetPicker(currentView.remote.path, event.clientX, event.clientY) + }, { label: 'New folder', icon: 'plus', onSelect: () => openPrompt('mkdir') }, { label: 'Refresh', icon: 'refresh', onSelect: () => refreshRemote(currentView.remote.path) } ]; @@ -727,7 +762,7 @@ function openEmptyContextMenu(side: PaneSide, event: MouseEvent): void { if (!view) return; - const items = side === 'remote' ? remoteEmptyMenuItems(view) : localEmptyMenuItems(view); + const items = side === 'remote' ? remoteEmptyMenuItems(view, event) : localEmptyMenuItems(view); contextMenu = { side, x: event.clientX, y: event.clientY, items }; } diff --git a/packages/ui/src/lib/screens/SnippetEditor.svelte b/packages/ui/src/lib/screens/SnippetEditor.svelte index 9c7c10d..e9af7b7 100644 --- a/packages/ui/src/lib/screens/SnippetEditor.svelte +++ b/packages/ui/src/lib/screens/SnippetEditor.svelte @@ -99,6 +99,11 @@ parameter's value as {'{{params.}}'}. Whether this runs on this machine or on a host is set per node in the automation, so the same snippet works either way.

+

+ In the SFTP browser: right-click a file → Run snippet with this file, and {'{{file}}'} becomes its path + (quoted for you). Snippets without {'{{file}}'} are also under Run snippet here… on a folder's empty + space, and run in that folder. +

{#if error}

{error}

diff --git a/packages/ui/src/lib/screens/snippetPlaceholders.test.ts b/packages/ui/src/lib/screens/snippetPlaceholders.test.ts index 5b8113b..8b2384c 100644 --- a/packages/ui/src/lib/screens/snippetPlaceholders.test.ts +++ b/packages/ui/src/lib/screens/snippetPlaceholders.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { fillFilePlaceholder, usesFilePlaceholder, FILE_PLACEHOLDER } from './snippetPlaceholders'; +import { commandInFolder, fillFilePlaceholder, usesFilePlaceholder, FILE_PLACEHOLDER } from './snippetPlaceholders'; describe('fillFilePlaceholder', () => { it('substitutes the path, shell-quoted', () => { @@ -29,3 +29,13 @@ describe('usesFilePlaceholder', () => { expect(usesFilePlaceholder('tar -xf archive.tar')).toBe(false); }); }); + +describe('commandInFolder', () => { + it('runs the command in the folder being browsed, quoted', () => { + expect(commandInFolder('docker system prune -f', '/srv/my app')).toBe("cd '/srv/my app' && docker system prune -f"); + }); + + it('runs it as it is without a folder', () => { + expect(commandInFolder('uptime', '')).toBe('uptime'); + }); +}); diff --git a/packages/ui/src/lib/screens/snippetPlaceholders.ts b/packages/ui/src/lib/screens/snippetPlaceholders.ts index a2d634a..56498c0 100644 --- a/packages/ui/src/lib/screens/snippetPlaceholders.ts +++ b/packages/ui/src/lib/screens/snippetPlaceholders.ts @@ -17,6 +17,12 @@ export function fillFilePlaceholder(command: string, path: string): string { return command.split(FILE_PLACEHOLDER).join(shellQuote(path)); } +/** A snippet run from a folder rather than a file (`docker system prune`, `git pull`): + * it runs in the folder being browsed, so "here" means what the user is looking at. */ +export function commandInFolder(command: string, folder: string): string { + return folder ? `cd ${shellQuote(folder)} && ${command}` : command; +} + /** Whether this snippet actually wants a file — drives the wording of the menu entry, * so the user can tell which snippets will use what they right-clicked. */ export function usesFilePlaceholder(command: string): boolean {