Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions packages/ui/e2e/sftp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
41 changes: 38 additions & 3 deletions packages/ui/src/lib/screens/SftpView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -623,8 +623,43 @@
];
}

function remoteEmptyMenuItems(currentView: NonNullable<typeof view>): 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<void> {
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<typeof view>, 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) }
];
Expand Down Expand Up @@ -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 };
}

Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/lib/screens/SnippetEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@
parameter's value as {'{{params.<name>}}'}. Whether this runs on this machine or
on a host is set per node in the automation, so the same snippet works either way.
</p>
<p class="text-[11px] text-faint">
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.
</p>

{#if error}
<p class="text-xs text-status-crit">{error}</p>
Expand Down
12 changes: 11 additions & 1 deletion packages/ui/src/lib/screens/snippetPlaceholders.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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');
});
});
6 changes: 6 additions & 0 deletions packages/ui/src/lib/screens/snippetPlaceholders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading