From 63c20c3966dae39744f17ccad5038660784191ce Mon Sep 17 00:00:00 2001
From: Sniphs <56806984+Sniphs98@users.noreply.github.com>
Date: Sat, 26 Sep 2026 15:10:46 +0200
Subject: [PATCH] feat(sftp): run snippets from a folder, not just on a file
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Right-clicking empty space in the remote pane now offers "Run snippet here…":
the snippets without {{file}} (docker system prune, git pull), run in the
drawer terminal after a cd into the folder being browsed.
The snippet editor now says how {{file}} and "Run snippet here" work, which
it didn't mention before.
Co-Authored-By: Claude Opus 5.5
---
packages/ui/e2e/sftp.spec.ts | 22 ++++++++++
packages/ui/src/lib/screens/SftpView.svelte | 41 +++++++++++++++++--
.../ui/src/lib/screens/SnippetEditor.svelte | 5 +++
.../lib/screens/snippetPlaceholders.test.ts | 12 +++++-
.../ui/src/lib/screens/snippetPlaceholders.ts | 6 +++
5 files changed, 82 insertions(+), 4 deletions(-)
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 a72612c..a4c2998 100644
--- a/packages/ui/src/lib/screens/SftpView.svelte
+++ b/packages/ui/src/lib/screens/SftpView.svelte
@@ -45,7 +45,7 @@
reloadHosts,
saveHost
} from '$lib/ipc/commands';
- import { fillFilePlaceholder, usesFilePlaceholder } from './snippetPlaceholders';
+ import { commandInFolder, fillFilePlaceholder, usesFilePlaceholder } from './snippetPlaceholders';
let { session, active }: { session: Session; active: boolean } = $props();
@@ -610,8 +610,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) }
];
@@ -713,7 +748,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 {