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
+ 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 {