From e48a4b21a95a2d4aa217a8edd75e2a67043b9abb Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 1 Oct 2026 20:58:10 +0400 Subject: [PATCH 1/7] test(gui): use the current tab close label --- crates/omnyssh-gui/ui/e2e/terminal.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts index b6d43c2a..cd213a3d 100644 --- a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts @@ -130,7 +130,7 @@ test('host-first: spawn a terminal from a card, run a command, see output, then await expect(page.locator('.xterm-rows')).toContainText('RESULT-OK'); // Closing the tab tears the terminal down. - await page.getByRole('button', { name: 'Close web-1 · terminal' }).click(); + await page.getByRole('button', { name: 'Close web-1' }).click(); await expect(page.locator('.xterm')).toHaveCount(0); await expect(page.getByRole('button', { name: 'web-1 · terminal', exact: true })).toHaveCount(0); }); From a632adf78d1f68197525849baa00c33cd23c3ac8 Mon Sep 17 00:00:00 2001 From: Tim Hartmann Date: Thu, 1 Oct 2026 20:58:10 +0400 Subject: [PATCH 2/7] fix(gui): survive repeated snippet tags and shared host names --- crates/omnyssh-gui/ui/e2e/palette.spec.ts | 27 ++++++++++++++++--- crates/omnyssh-gui/ui/e2e/snippets.spec.ts | 27 ++++++++++++++++--- .../src/lib/components/CommandPalette.svelte | 3 +-- .../ui/src/lib/screens/SnippetRunner.svelte | 10 ++++--- .../ui/src/lib/screens/Snippets.svelte | 6 ++--- crates/omnyssh-gui/ui/src/lib/stores/hosts.ts | 11 ++++++++ .../ui/src/lib/stores/palette.test.ts | 6 +++++ .../omnyssh-gui/ui/src/lib/stores/palette.ts | 5 +++- 8 files changed, 80 insertions(+), 15 deletions(-) diff --git a/crates/omnyssh-gui/ui/e2e/palette.spec.ts b/crates/omnyssh-gui/ui/e2e/palette.spec.ts index 4ed84193..362fd5e9 100644 --- a/crates/omnyssh-gui/ui/e2e/palette.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/palette.spec.ts @@ -30,7 +30,7 @@ const HOSTS = [ } ]; -async function bootWithHosts(page: Page): Promise { +async function bootWithHosts(page: Page, hosts = HOSTS): Promise { await page.addInitScript((hosts) => { let cbid = 0; (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { @@ -42,10 +42,10 @@ async function bootWithHosts(page: Page): Promise { return id; } }; - }, HOSTS); + }, hosts); await page.goto('/'); // The status-bar total confirms `list_hosts` resolved into the store. - await expect(page.getByText('2 hosts')).toBeVisible(); + await expect(page.getByText(`${hosts.length} hosts`)).toBeVisible(); } test('⌘K navigator jumps to a host by opening a session', async ({ page }) => { @@ -91,3 +91,24 @@ test('a spawner opens the host-picker and spawns a session for the chosen host', await expect(row).toBeVisible(); await expect(row).toHaveAttribute('aria-current', 'true'); }); + +test('lists a shared host name once, as the host a session opens', async ({ page }) => { + const twin = { ...HOSTS[0], hostname: 'web-1b.example.com', user: 'admin' }; + await bootWithHosts(page, [...HOSTS, twin]); + + await page.getByRole('button', { name: 'Terminal' }).click(); + const picker = page.getByRole('dialog', { name: 'Pick a host' }); + const web = picker.getByRole('button', { name: /web-1/ }); + await expect(web).toHaveCount(1); + await expect(web).toContainText('deploy@web-1.example.com'); + await picker.getByRole('textbox').fill('web-1b'); + await expect(picker.getByText('No matching hosts.')).toBeVisible(); + await page.keyboard.press('Escape'); + + await page.keyboard.press('Control+k'); + const listed = page + .getByRole('dialog', { name: 'Command palette' }) + .getByRole('button', { name: /web-1/ }); + await expect(listed).toHaveCount(1); + await expect(listed).toContainText('deploy@web-1.example.com'); +}); diff --git a/crates/omnyssh-gui/ui/e2e/snippets.spec.ts b/crates/omnyssh-gui/ui/e2e/snippets.spec.ts index cd2b9317..39ccaf0f 100644 --- a/crates/omnyssh-gui/ui/e2e/snippets.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/snippets.spec.ts @@ -15,7 +15,7 @@ const SNIPPETS = [ { name: 'restart-web', command: 'systemctl restart {{service}}', scope: 'global', params: ['service'] } ]; -async function boot(page: Page, snippetsFixture = SNIPPETS): Promise { +async function boot(page: Page, snippetsFixture = SNIPPETS, hostsFixture = HOSTS): Promise { await page.addInitScript( ({ hosts, snippets }) => { let cbid = 0; @@ -74,11 +74,11 @@ async function boot(page: Page, snippetsFixture = SNIPPETS): Promise { } }; }, - { hosts: HOSTS, snippets: snippetsFixture } + { hosts: hostsFixture, snippets: snippetsFixture } ); await page.goto('/'); // The status-bar total confirms the app booted and `list_hosts` resolved. - await expect(page.getByText('2 hosts')).toBeVisible(); + await expect(page.getByText(`${hostsFixture.length} hosts`)).toBeVisible(); await page.getByRole('button', { name: 'Snippets', exact: true }).click(); // The Snippets screen loads its list on mount. await expect(page.getByRole('heading', { name: 'Snippets' })).toBeVisible(); @@ -178,3 +178,24 @@ test('renders duplicate-named snippets without crashing (core never dedups names await expect(page.getByText('git pull')).toBeVisible(); await expect(page.getByText('systemctl restart app')).toBeVisible(); }); + +test('renders a snippet with a repeated tag (nothing dedups tags)', async ({ page }) => { + await boot(page, [{ name: 'backup', command: 'pg_dump app', scope: 'global', tags: ['db', 'db'] }]); + + await expect(page.getByText('pg_dump app')).toBeVisible(); + await expect(page.getByText('db', { exact: true })).toHaveCount(2); +}); + +test('lists a shared host name once in the runner, as the host a run uses', async ({ page }) => { + const twin = { ...HOSTS[0], hostname: 'web-1b.example.com', user: 'admin' }; + await boot(page, SNIPPETS, [...HOSTS, twin]); + + await page.getByRole('button', { name: 'Run uptime' }).click(); + const runner = page.getByRole('dialog', { name: 'Run snippet' }); + const web = runner.getByRole('checkbox', { name: 'web-1' }); + await expect(web).toHaveCount(1); + await expect(web).toContainText('deploy@web-1.example.com'); + await web.click(); + await expect(web).toHaveAttribute('aria-checked', 'true'); + await expect(runner.getByText('Run on 1 of 2')).toBeVisible(); +}); diff --git a/crates/omnyssh-gui/ui/src/lib/components/CommandPalette.svelte b/crates/omnyssh-gui/ui/src/lib/components/CommandPalette.svelte index aec878d7..fe5aad46 100644 --- a/crates/omnyssh-gui/ui/src/lib/components/CommandPalette.svelte +++ b/crates/omnyssh-gui/ui/src/lib/components/CommandPalette.svelte @@ -177,8 +177,7 @@ {#if items.length === 0}
  • {emptyMessage}
  • {:else} - + {#each items as item, i (i)} {#if $palette.mode === 'navigate' && i === firstSession}
  • Sessions
  • diff --git a/crates/omnyssh-gui/ui/src/lib/screens/SnippetRunner.svelte b/crates/omnyssh-gui/ui/src/lib/screens/SnippetRunner.svelte index 5e24f995..9d55ede6 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/SnippetRunner.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/SnippetRunner.svelte @@ -6,7 +6,7 @@ import type { SnippetDto } from '$lib/bindings'; import { Button, StatusDot, Icon } from '$lib/theme'; import Modal from '$lib/components/Modal.svelte'; - import { hosts } from '$lib/stores/hosts'; + import { hosts, firstOfEachName } from '$lib/stores/hosts'; import { statuses } from '$lib/stores/statuses'; import { streamerMode, displayHostname } from '$lib/stores/streamer'; import { hostStatusDot } from '$lib/stores/palette'; @@ -39,6 +39,10 @@ ) ); + // One row per name, the first: the backend runs a name on its first host, so a + // second row would only toggle along with it. + const choices = $derived(firstOfEachName($hosts)); + /** Focus the first param input on open for a keyboard-first run. */ function autofocus(node: HTMLInputElement, active: boolean): void { if (active) node.focus(); @@ -84,13 +88,13 @@

    - Run on {selected.size} of {$hosts.length} + Run on {selected.size} of {choices.length}

    {#if $hosts.length === 0}

    No hosts configured.

    {:else}
      - {#each $hosts as host (host.name)} + {#each choices as host (host.name)} {@const checked = selected.has(host.name)}