diff --git a/CHANGELOG.md b/CHANGELOG.md index fd62222..6f3121a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index ff4b51b..82d134d 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts index 40c8517..9d0ab6e 100644 --- a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts @@ -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: @@ -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'); + }); }); diff --git a/crates/omnyssh-gui/ui/package-lock.json b/crates/omnyssh-gui/ui/package-lock.json index bfd1699..c031ace 100644 --- a/crates/omnyssh-gui/ui/package-lock.json +++ b/crates/omnyssh-gui/ui/package-lock.json @@ -10,6 +10,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": { @@ -1413,6 +1414,12 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-search": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.16.0.tgz", + "integrity": "sha512-9OeuBFu0/uZJPu+9AHKY6g/w0Czyb/Ut0A5t79I4ULoU4IfU5BEpPFVGQxP4zTTMdfZEYkVIRYbHBX1xWwjeSA==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/crates/omnyssh-gui/ui/package.json b/crates/omnyssh-gui/ui/package.json index e3a4d38..91f05f9 100644 --- a/crates/omnyssh-gui/ui/package.json +++ b/crates/omnyssh-gui/ui/package.json @@ -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": { diff --git a/crates/omnyssh-gui/ui/src/lib/screens/TerminalFind.svelte b/crates/omnyssh-gui/ui/src/lib/screens/TerminalFind.svelte new file mode 100644 index 0000000..5f8360a --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/screens/TerminalFind.svelte @@ -0,0 +1,203 @@ + + + diff --git a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte index 91a5923..93809da 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte @@ -9,6 +9,7 @@ import { get } from 'svelte/store'; import type { Terminal } from '@xterm/xterm'; import type { FitAddon } from '@xterm/addon-fit'; + import type { SearchAddon } from '@xterm/addon-search'; import { Channel } from '@tauri-apps/api/core'; import { theme } from '$lib/stores/theme'; import { xtermTheme } from '$lib/theme/terminalTheme'; @@ -28,7 +29,15 @@ terminalPaste } from '$lib/ipc/commands'; import { shouldFadeTop } from './terminalFade'; - import { chunkBytes, closesEndedTab, isCopyShortcut, layoutFallback } from './terminalInput'; + import { + chunkBytes, + closesEndedTab, + isCopyShortcut, + isFindShortcut, + layoutFallback + } from './terminalInput'; + import { HIGHLIGHT_LIMIT, seedQuery } from './terminalFind'; + import TerminalFind from './TerminalFind.svelte'; import { isMac } from '$lib/platform'; import type { TerminalBytes } from '$lib/bindings'; @@ -85,6 +94,10 @@ let container: HTMLDivElement; let term: Terminal | undefined; let fitAddon: FitAddon | undefined; + let searchAddon = $state(); + let findOpen = $state(false); + let findSeed = $state(''); + let findBar: TerminalFind | undefined = $state(); let termId: number | undefined; let destroyed = false; let connected = false; @@ -114,6 +127,23 @@ if (termId != null && !ended()) void terminalResize(termId, term.cols, term.rows).catch(() => {}); } + /** Open the find bar, or take it back to the query if it is already open. A one-line + * selection becomes the query, as in most editors. */ + function openFind(): void { + const seed = seedQuery(term?.getSelection() ?? ''); + if (findOpen) { + findBar?.focus(seed); + } else { + findSeed = seed; + findOpen = true; + } + } + + function closeFind(): void { + findOpen = false; + if (keyboardFree()) term?.focus(); + } + function scheduleFit(): void { if (fitScheduled) return; fitScheduled = true; @@ -125,9 +155,10 @@ onMount(() => { void (async () => { - const [{ Terminal }, { FitAddon }] = await Promise.all([ + const [{ Terminal }, { FitAddon }, { SearchAddon }] = await Promise.all([ import('@xterm/xterm'), - import('@xterm/addon-fit') + import('@xterm/addon-fit'), + import('@xterm/addon-search') ]); if (destroyed) return; @@ -135,10 +166,16 @@ fontFamily: MONO, fontSize: 13, cursorBlink: true, - scrollback: 5000 + scrollback: 5000, + // The search addon highlights matches through registerDecoration, which xterm + // still gates as proposed API. + allowProposedApi: true }); fitAddon = new FitAddon(); term.loadAddon(fitAddon); + const search = new SearchAddon({ highlightLimit: HIGHLIGHT_LIMIT }); + term.loadAddon(search); + searchAddon = search; term.open(container); term.onScroll(syncScrolled); @@ -187,6 +224,12 @@ // The streamer chord belongs to the window (AppShell), never to the shell, where // a ^S would stop its output. if (isStreamerChord(e)) return false; + // Find works on an ended session too: its last output is what is left to read. + if (isFindShortcut(e, isMac)) { + e.preventDefault(); + openFind(); + return false; + } if (isCopyShortcut(e, isMac)) { e.preventDefault(); if (term?.hasSelection()) { @@ -263,7 +306,11 @@ void [$dialogs, $palette, $activeEntity]; requestAnimationFrame(() => { safeFit(); - if (keyboardFree()) term?.focus(); + // An open find bar keeps the keyboard it had when the tab was left. + if (keyboardFree()) { + if (findOpen) findBar?.focus(); + else term?.focus(); + } syncScrolled(); }); } @@ -280,6 +327,9 @@
+ {#if findOpen && searchAddon} + + {/if}