Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ Versions follow [Semantic Versioning](https://semver.org/).

---

## Unreleased

### Features
- **Find in the desktop terminal with ⌘F, or Ctrl+Shift+F on Windows and Linux.** There was no way to search a terminal's output, so finding an error in a long log meant scrolling the scrollback by eye. A find bar now opens over the terminal: every match in the scrollback is highlighted and counted, Enter and Shift+Enter step through them, and Aa and .* switch on matching case and regular expressions. A one-line selection becomes the query. The highlights follow new output while the bar is open, and Esc closes it and hands the keyboard back to the shell. A bare Ctrl+F still reaches the shell as ^F, and find works on a tab whose session has ended, whose last output is what is left to read.

## 1.1.5 — 2026-10-09

### Features
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ You add a server once. After that it sits on the dashboard as a card with live C
Cards for every host with CPU, RAM and disk bars, uptime, OS version, top processes, and a Docker badge showing how many containers are up. Bars turn yellow, then red, so a sick server is obvious from across the room. Group the grid by tag, sort it, and in the desktop app drag cards into your own order.

### Real terminals
Full PTY sessions in tabs. Open as many servers as you need, switch between them from the sidebar, and keep them running while you work in the dashboard.
Full PTY sessions in tabs. Open as many servers as you need, switch between them from the sidebar, and keep them running while you work in the dashboard. In the desktop app, ⌘F (Ctrl+Shift+F on Windows and Linux) searches a terminal's scrollback and highlights every match.

### Two panel SFTP
Local on the left, remote on the right. Tick the files and folders you want and move them across, or just drag them from one side to the other, or straight from your file manager onto the server. Watch the progress bar, select many at once, see when each file was last changed. Nobody remembers `scp -r` syntax anyway.
Expand Down
110 changes: 110 additions & 0 deletions crates/omnyssh-gui/ui/e2e/terminal.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,103 @@ test('under a non-Latin layout Ctrl+C still interrupts and Ctrl+Shift+V still pa
expect(await writes(page)).toEqual([[3], [3]]);
});

// The find bar searches the scrollback (⌘F on macOS, Ctrl+Shift+F elsewhere). Every
// key it takes stays out of the shell: the stub records each write, so a stray ^F or a
// typed query reaching the session would show up there.
const LOG = 'error one\r\nok\r\nerror two\r\nERROR three\r\n';
const findBox = (page: Page) => page.getByRole('textbox', { name: 'Find in terminal' });
const findCount = (page: Page) => page.getByRole('search').locator('[aria-live]');

test('Ctrl+Shift+F finds in the scrollback; Enter steps, Esc hands the keyboard back', async ({
page
}) => {
await bootWithClipboard(page);
await sendOutput(page, 1, `\r\n${LOG}`);
await expect(page.locator('.xterm-rows')).toContainText('ERROR three');
await page.locator('.xterm-helper-textarea').focus();

await page.keyboard.press('Control+Shift+F');
await expect(findBox(page)).toBeFocused();
await page.keyboard.type('error');
// Case-insensitive by default: all three lines match.
await expect(findCount(page)).toHaveText(/^\d of 3$/);
const first = await findCount(page).textContent();
await page.keyboard.press('Enter');
await expect(findCount(page)).not.toHaveText(first!);
await page.keyboard.press('Shift+Enter');
await expect(findCount(page)).toHaveText(first!);

await page.getByRole('button', { name: 'Match case' }).click();
await expect(findCount(page)).toHaveText(/^\d of 2$/);
// The toggle leaves the keyboard in the query.
await expect(findBox(page)).toBeFocused();

await page.keyboard.press('Escape');
await expect(page.getByRole('search')).toHaveCount(0);
await expect(page.locator('.xterm-helper-textarea')).toBeFocused();
// The tab is still there, and the shell got none of it.
await expect(terminalTab(page)).toBeVisible();
expect(await writes(page)).toEqual([]);
});

test('find reports no results, and an invalid regex, instead of a count', async ({ page }) => {
await bootWithClipboard(page);
await sendOutput(page, 1, `\r\n${LOG}`);
await page.locator('.xterm-helper-textarea').focus();
await page.keyboard.press('Control+Shift+F');

await page.keyboard.type('timeout');
await expect(findCount(page)).toHaveText('No results');

await findBox(page).fill('err(or');
await page.getByRole('button', { name: 'Use regular expression' }).click();
await expect(findCount(page)).toHaveText('Invalid pattern');

await findBox(page).fill('^error \\w+');
await expect(findCount(page)).toHaveText(/^\d of 3$/);
});

test('a one-line selection becomes the query', async ({ page }) => {
await bootWithClipboard(page);
await selectPrompt(page);
await page.keyboard.press('Control+Shift+F');
await expect(findBox(page)).toHaveValue('omnyssh-ready>');
await expect(findCount(page)).toHaveText('1 of 1');

// Pressing it again with the bar open takes the keyboard back to the query.
await page.locator('.xterm-helper-textarea').focus();
await page.keyboard.press('Control+Shift+F');
await expect(findBox(page)).toBeFocused();
});

test('bare Ctrl+F stays with the shell', async ({ page }) => {
await bootWithClipboard(page);
await page.locator('.xterm-helper-textarea').focus();
await page.keyboard.press('Control+F');
await expect.poll(() => writes(page)).toEqual([[6]]);
await expect(page.getByRole('search')).toHaveCount(0);
});

test('find still works once the session has ended', async ({ page }) => {
await bootWithClipboard(page);
await sendOutput(page, 1, '\r\nPermission denied, please try again.');
await sendOutput(page, 1, END_LINE);
await fireExited(page, 1, true);
await expect(terminalTab(page).locator('[role="img"]')).toHaveAttribute('aria-label', 'off');

await page.locator('.xterm-helper-textarea').focus();
await page.keyboard.press('Control+Shift+F');
await page.keyboard.type('denied');
await expect(findCount(page)).toHaveText('1 of 1');
// Enter in the bar steps through matches; it does not close the ended tab.
await page.keyboard.press('Enter');
await expect(terminalTab(page)).toBeVisible();

await page.keyboard.press('Escape');
await page.keyboard.press('Enter');
await expect(terminalTab(page)).toHaveCount(0);
});

test.describe('on macOS', () => {
test.use({
userAgent:
Expand All @@ -433,4 +530,17 @@ test.describe('on macOS', () => {
await page.keyboard.press('Control+Shift+C');
expect(await copied(page)).toEqual([]);
});

test('⌘F opens find, and Ctrl+Shift+F is left alone', async ({ page }) => {
await bootWithClipboard(page);
await page.locator('.xterm-helper-textarea').focus();

await page.keyboard.press('Control+Shift+F');
await expect(page.getByRole('search')).toHaveCount(0);

await page.keyboard.press('Meta+F');
await expect(findBox(page)).toBeFocused();
await page.keyboard.type('ready');
await expect(findCount(page)).toHaveText('1 of 1');
});
});
7 changes: 7 additions & 0 deletions crates/omnyssh-gui/ui/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/omnyssh-gui/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-store": "^2.4.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/xterm": "^6.0.0"
},
"devDependencies": {
Expand Down
203 changes: 203 additions & 0 deletions crates/omnyssh-gui/ui/src/lib/screens/TerminalFind.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
<script lang="ts">
// The find bar over a terminal tab (⌘F / Ctrl+Shift+F). Drives xterm's search addon
// over the scrollback: every match is highlighted, the current one more strongly, and
// the highlights follow new output while the bar is open. Enter and Shift+Enter step
// through matches; Esc closes the bar and hands the keyboard back to the terminal.
import { onMount, onDestroy } from 'svelte';
import type { ISearchOptions, SearchAddon } from '@xterm/addon-search';
import { theme } from '$lib/stores/theme';
import { lastError } from '$lib/stores/notifications';
import { searchColours } from '$lib/theme/terminalTheme';
import Icon from '$lib/theme/Icon.svelte';
import { isMac } from '$lib/platform';
import { findLabel, type FindResult } from './terminalFind';
import { isFindShortcut } from './terminalInput';

let {
search,
seed = '',
onclose
}: { search: SearchAddon; seed?: string; onclose: () => void } = $props();

let input: HTMLInputElement;
let query = $state('');
let caseSensitive = $state(false);
let regex = $state(false);
let result = $state<FindResult>({ kind: 'idle' });
let resultsSub: { dispose(): void } | undefined;

const shortcut = isMac ? '⌘F' : 'Ctrl+Shift+F';

// The options the highlights were drawn with. The addon records new options before
// comparing them with the last ones (addon-search 0.16), so it never notices a
// change of case, regex or colours by itself; dropping its cached query makes it
// highlight afresh.
let drawnWith = '';

function find(backwards: boolean, incremental = false): void {
if (!query) {
search.clearDecorations();
result = { kind: 'idle' };
return;
}
const key = `${caseSensitive}|${regex}|${$theme}`;
if (key !== drawnWith) {
search.clearDecorations();
drawnWith = key;
}
const options: ISearchOptions = {
caseSensitive,
regex,
incremental,
decorations: searchColours($theme)
};
try {
if (backwards) search.findPrevious(query, options);
else search.findNext(query, options);
} catch (err) {
// Drop the last good query's highlights so they do not pass for this one's.
search.clearDecorations();
if (regex && err instanceof SyntaxError) {
result = { kind: 'invalid' };
} else {
result = { kind: 'idle' };
lastError.set(`Find failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
}

// Search as the query is typed, and again when an option or the theme (the
// highlight colours) changes. Incremental keeps the current match while it still
// matches the longer query, rather than jumping to the next one.
$effect(() => {
void [query, caseSensitive, regex, $theme];
find(false, true);
});

/** Take the keyboard, optionally starting from `seed` (a one-line selection). */
export function focus(seed?: string): void {
if (seed) query = seed;
input?.focus();
input?.select();
}

function onKeydown(e: KeyboardEvent): void {
if (e.isComposing) return;
if (e.key === 'Enter') {
e.preventDefault();
find(e.shiftKey);
} else if (e.key === 'Escape') {
e.preventDefault();
e.stopPropagation();
onclose();
} else if (isFindShortcut(e, isMac)) {
e.preventDefault();
input.select();
}
}

onMount(() => {
resultsSub = search.onDidChangeResults(({ resultIndex, resultCount }) => {
result = { kind: 'matches', index: resultIndex, count: resultCount };
});
focus(seed);
});

onDestroy(() => {
resultsSub?.dispose();
try {
search.clearDecorations();
} catch {
// The terminal, and the addon with it, may already be disposed.
}
});

// Buttons keep the keyboard in the query, so Enter goes on stepping after a click.
const keepFocus = (e: MouseEvent) => e.preventDefault();
const iconButton =
'flex h-7 w-7 items-center justify-center rounded-md text-muted transition hover:bg-surface-inset ' +
'hover:text-fg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus ' +
'disabled:cursor-not-allowed disabled:opacity-40';
const toggleOn = 'bg-surface-inset text-fg';
let noMatches = $derived(
result.kind === 'invalid' || (result.kind === 'matches' && result.count === 0)
);
let canStep = $derived(result.kind === 'matches' && result.count > 0);
</script>

<div
role="search"
class="absolute right-4 z-10 flex items-center gap-1 rounded-xl border border-default bg-surface-raised py-1 pl-3 pr-1 shadow-soft"
style="top: max(var(--titlebar-h), 0.5rem);"
>
<span class="flex text-muted"><Icon name="search" size={14} /></span>
<input
bind:this={input}
bind:value={query}
onkeydown={onKeydown}
type="text"
placeholder="Find"
title="Find in terminal ({shortcut})"
aria-label="Find in terminal"
autocomplete="off"
autocapitalize="off"
spellcheck="false"
class="w-44 bg-transparent px-1 py-1 text-sm text-fg outline-none placeholder:text-faint"
/>
<span
class="min-w-[5.5rem] whitespace-nowrap pr-1 text-right text-xs tabular-nums {noMatches
? 'text-status-crit'
: 'text-muted'}"
aria-live="polite">{findLabel(result)}</span
>
<button
type="button"
class="{iconButton} font-mono text-xs {caseSensitive ? toggleOn : ''}"
title="Match case"
aria-label="Match case"
aria-pressed={caseSensitive}
onmousedown={keepFocus}
onclick={() => (caseSensitive = !caseSensitive)}>Aa</button
>
<button
type="button"
class="{iconButton} font-mono text-xs {regex ? toggleOn : ''}"
title="Use regular expression"
aria-label="Use regular expression"
aria-pressed={regex}
onmousedown={keepFocus}
onclick={() => (regex = !regex)}>.*</button
>
<button
type="button"
class={iconButton}
title="Previous match (Shift+Enter)"
aria-label="Previous match"
disabled={!canStep}
onmousedown={keepFocus}
onclick={() => find(true)}
>
<span class="flex rotate-180"><Icon name="chevron" size={14} /></span>
</button>
<button
type="button"
class={iconButton}
title="Next match (Enter)"
aria-label="Next match"
disabled={!canStep}
onmousedown={keepFocus}
onclick={() => find(false)}
>
<Icon name="chevron" size={14} />
</button>
<button
type="button"
class={iconButton}
title="Close (Esc)"
aria-label="Close find"
onmousedown={keepFocus}
onclick={onclose}
>
<Icon name="close" size={14} />
</button>
</div>
Loading