From a9cebc709ad7de8533192fa45ec1bc4debaf2826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Rossi?= Date: Wed, 30 Sep 2026 17:22:10 +0200 Subject: [PATCH 1/2] feat(gui): copy and paste with ctrl+c and ctrl+v in the terminal On Windows and Linux a bare Ctrl+C now copies the selection and clears it, and stays ^C when nothing is selected. Ctrl+V pastes through the webview's native paste, so bracketed paste and chunking still apply. A Terminal setting, on by default, turns both off to send ^C and ^V to the remote again. Ctrl+Shift+C/V are unchanged and macOS is untouched. --- crates/omnyssh-gui/ui/e2e/terminal.spec.ts | 46 ++++++++++-- .../ui/src/lib/screens/Settings.svelte | 33 +++++++++ .../ui/src/lib/screens/TerminalView.svelte | 41 ++++++++++- .../ui/src/lib/screens/terminalInput.test.ts | 41 +++++++++++ .../ui/src/lib/screens/terminalInput.ts | 34 +++++++-- .../src/lib/stores/terminalClipboard.test.ts | 59 +++++++++++++++ .../ui/src/lib/stores/terminalClipboard.ts | 73 +++++++++++++++++++ .../omnyssh-gui/ui/src/routes/+layout.svelte | 2 + 8 files changed, 310 insertions(+), 19 deletions(-) create mode 100644 crates/omnyssh-gui/ui/src/lib/stores/terminalClipboard.test.ts create mode 100644 crates/omnyssh-gui/ui/src/lib/stores/terminalClipboard.ts diff --git a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts index b6d43c2a..ac789907 100644 --- a/crates/omnyssh-gui/ui/e2e/terminal.spec.ts +++ b/crates/omnyssh-gui/ui/e2e/terminal.spec.ts @@ -317,14 +317,41 @@ test('Ctrl+Shift+C copies the selection and sends the shell nothing', async ({ p await expect.poll(() => copied(page)).toEqual(['omnyssh-ready>']); expect(await writes(page)).toEqual([]); - // Bare Ctrl+C stays the interrupt, selection or not. + // Ctrl+Shift+V is the webview's own paste; xterm must not turn it into ^V or a V. + await page.keyboard.press('Control+Shift+V'); + expect(await writes(page)).toEqual([]); +}); + +test('bare Ctrl+C copies a selection, then interrupts once it is cleared', async ({ page }) => { + await bootWithClipboard(page); + await selectPrompt(page); + + await page.keyboard.press('Control+C'); + await expect.poll(() => copied(page)).toEqual(['omnyssh-ready>']); + expect(await writes(page)).toEqual([]); + + // The copy cleared the selection, so the next Ctrl+C is the interrupt. await page.keyboard.press('Control+C'); await expect.poll(() => writes(page)).toEqual([[3]]); expect(await copied(page)).toEqual(['omnyssh-ready>']); - // Ctrl+Shift+V is the webview's own paste; xterm must not turn it into ^V or a V. - await page.keyboard.press('Control+Shift+V'); - expect(await writes(page)).toEqual([[3]]); + // Bare Ctrl+V is the webview's own paste too: never a ^V to the shell. + await page.keyboard.press('Control+V'); + expect(await writes(page)).not.toContainEqual([22]); +}); + +test('with the Ctrl clipboard pref off, Ctrl+C and Ctrl+V reach the shell', async ({ page }) => { + await page.addInitScript(() => + localStorage.setItem('omnyssh-terminal-ctrl-clipboard', 'false') + ); + await bootWithClipboard(page); + await selectPrompt(page); + + await page.keyboard.press('Control+C'); + await expect.poll(() => writes(page)).toEqual([[3]]); + await page.keyboard.press('Control+V'); + await expect.poll(() => writes(page)).toEqual([[3], [22]]); + expect(await copied(page)).toEqual([]); }); test('Ctrl+Shift+C with nothing selected copies nothing', async ({ page }) => { @@ -359,10 +386,15 @@ test('under a non-Latin layout Ctrl+C still interrupts and Ctrl+Shift+V still pa await press('KeyC', false, 67); await expect.poll(() => writes(page)).toEqual([[3], [3]]); + const pasted = () => + page.evaluate(() => (window as unknown as { __pasted?: number }).__pasted); await press('KeyV', true); - await expect - .poll(() => page.evaluate(() => (window as unknown as { __pasted?: number }).__pasted)) - .toBe(1); + await expect.poll(pasted).toBe(1); + expect(await writes(page)).toEqual([[3], [3]]); + + // Bare Ctrl+V pastes the same way there, rather than falling back to ^V. + await press('KeyV', false); + await expect.poll(pasted).toBe(2); expect(await writes(page)).toEqual([[3], [3]]); }); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte b/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte index bc2e198a..236aabe8 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/Settings.svelte @@ -8,6 +8,7 @@ import { Surface, Icon } from '$lib/theme'; import { theme } from '$lib/stores/theme'; import { streamerMode } from '$lib/stores/streamer'; + import { terminalCtrlClipboard } from '$lib/stores/terminalClipboard'; import { refreshInterval, REFRESH_OPTIONS } from '$lib/stores/settings'; import { traySupport, trayBehavior } from '$lib/stores/tray'; import { isMac } from '$lib/platform'; @@ -138,6 +139,38 @@ + + {#if !isMac} + +

Terminal

+
+
+

Copy and paste with Ctrl+C / Ctrl+V

+

+ Ctrl+C copies the selection, or interrupts when nothing is selected. Turn off to + send ^V to the remote. Ctrl+Shift+C / Ctrl+Shift+V always work. +

+
+ +
+
+ {/if} +

Dashboard

diff --git a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte index 975f9b17..863d4d99 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte +++ b/crates/omnyssh-gui/ui/src/lib/screens/TerminalView.svelte @@ -25,8 +25,16 @@ terminalPaste } from '$lib/ipc/commands'; import { shouldFadeTop } from './terminalFade'; - import { chunkBytes, closesEndedTab, isCopyShortcut, layoutFallback } from './terminalInput'; + import { + chunkBytes, + isBareCopy, + isBarePaste, + closesEndedTab, + isCopyShortcut, + layoutFallback + } from './terminalInput'; import { isMac } from '$lib/platform'; + import { terminalCtrlClipboard } from '$lib/stores/terminalClipboard'; import type { TerminalBytes } from '$lib/bindings'; let { session, active }: { session: Session; active: boolean } = $props(); @@ -174,12 +182,37 @@ // Copy takes Ctrl+Shift+C whether or not anything is selected, so the chord never // reaches the shell. Returning false only keeps xterm out of it; the default is // ours to stop. The write happens inside the keydown, which WebKit requires. + // With the Ctrl clipboard pref on, a bare Ctrl+C copies too, but only a selection + // (and clears it, so the next Ctrl+C interrupts); with nothing selected it stays ^C. + const copySelection = (): void => { + if (!term?.hasSelection()) return; + navigator.clipboard.writeText(term.getSelection()).catch((err) => { + lastError.set(`Copy failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }; term.attachCustomKeyEventHandler((e) => { if (isCopyShortcut(e, isMac)) { e.preventDefault(); - if (term?.hasSelection()) { - navigator.clipboard.writeText(term.getSelection()).catch((err) => { - lastError.set(`Copy failed: ${err instanceof Error ? err.message : String(err)}`); + copySelection(); + return false; + } + const ctrlClipboard = get(terminalCtrlClipboard); + if (ctrlClipboard && isBareCopy(e, isMac) && term?.hasSelection()) { + e.preventDefault(); + e.stopPropagation(); + copySelection(); + term.clearSelection(); + return false; + } + // A bare Ctrl+V pastes like Ctrl+Shift+V: xterm stays out and the webview's own + // paste lands in xterm's textarea, so bracketed paste and chunking still apply. + // WebKitGTK under a non-Latin layout (keyCode 0) runs no native paste; ask it. + if (ctrlClipboard && isBarePaste(e, isMac)) { + if (e.keyCode === 0) { + e.preventDefault(); + e.stopPropagation(); + terminalPaste().catch((err) => { + lastError.set(`Paste failed: ${err instanceof Error ? err.message : String(err)}`); }); } return false; diff --git a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts index 461925fc..001603ce 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.test.ts @@ -3,6 +3,8 @@ import { chunkBytes, closesEndedTab, INPUT_CHUNK, + isBareCopy, + isBarePaste, isCopyShortcut, layoutFallback, type KeyPress @@ -89,6 +91,45 @@ describe('closesEndedTab — Enter or Esc dismisses a tab whose session ended', }); }); +// The bare chords: the view copies on Ctrl+C only with a selection and the pref on. +const bare = (over: Partial = {}): KeyPress => press({ key: 'c', shiftKey: false, ...over }); +const bareV = (over: Partial = {}): KeyPress => + bare({ key: 'v', code: 'KeyV', keyCode: 86, ...over }); + +describe('isBareCopy / isBarePaste — plain Ctrl+C and Ctrl+V', () => { + it('matches Ctrl+C and Ctrl+V without Shift, whichever case the key reports', () => { + expect(isBareCopy(bare(), false)).toBe(true); + expect(isBareCopy(bare({ key: 'C' }), false)).toBe(true); + expect(isBarePaste(bareV(), false)).toBe(true); + expect(isBarePaste(bareV({ key: 'V' }), false)).toBe(true); + }); + + it('leaves the Shift chords to isCopyShortcut and the native paste', () => { + expect(isBareCopy(bare({ shiftKey: true }), false)).toBe(false); + expect(isBarePaste(bareV({ shiftKey: true }), false)).toBe(false); + }); + + it('does not mix up the two letters', () => { + expect(isBareCopy(bareV(), false)).toBe(false); + expect(isBarePaste(bare(), false)).toBe(false); + }); + + it('follows the physical key only under a non-Latin layout', () => { + expect(isBareCopy(bare({ key: 'с', keyCode: 0 }), false)).toBe(true); + expect(isBarePaste(bareV({ key: 'м', keyCode: 0 }), false)).toBe(true); + expect(isBareCopy(bare({ key: 'j' }), false)).toBe(false); + }); + + it('ignores extra modifiers, non-keydown events, composition and macOS', () => { + expect(isBareCopy(bare({ altKey: true }), false)).toBe(false); + expect(isBarePaste(bareV({ metaKey: true }), false)).toBe(false); + expect(isBareCopy(bare({ type: 'keyup' }), false)).toBe(false); + expect(isBarePaste(bareV({ isComposing: true }), false)).toBe(false); + expect(isBareCopy(bare(), true)).toBe(false); + expect(isBarePaste(bareV(), true)).toBe(false); + }); +}); + // WebKitGTK under a Russian layout: the key is Cyrillic, the keyCode 0, the code Latin. const cyrillic = (code: string, over: Partial = {}): KeyPress => press({ key: '\u0441', code, keyCode: 0, shiftKey: false, ...over }); diff --git a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts index 23acb4c5..9d3d3c96 100644 --- a/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts +++ b/crates/omnyssh-gui/ui/src/lib/screens/terminalInput.ts @@ -2,8 +2,8 @@ // paste would serialize as one giant array on the main thread and freeze the UI, so the // view splits it into bounded chunks and awaits each (yielding between). This is the // pure split; the view owns the ordered dispatch. The Ctrl chords xterm cannot handle -// itself — the copy shortcut, and those WebKitGTK leaves unnamed under a non-Latin -// layout — are decided here too, before xterm turns the key into input. +// itself — the copy and paste shortcuts, and those WebKitGTK leaves unnamed under a +// non-Latin layout — are decided here too, before xterm turns the key into input. /** The per-write byte cap. Small enough that one chunk's `number[]` serialization is * imperceptible, so a multi-MB paste streams without a visible stall (§9). */ @@ -30,16 +30,34 @@ function nonLatinLetter(key: string): boolean { return /^\p{L}$/u.test(key) && !/\p{Script=Latin}/u.test(key); } -/** Ctrl+Shift+C on Windows and Linux, as in GNOME Terminal and Windows Terminal: a bare - * Ctrl+C has to stay ^C. macOS needs none — Cmd+C copies there through the Edit menu. - * The webview pastes Ctrl+Shift+V natively, except where `layoutFallback` steps in. */ -export function isCopyShortcut(e: KeyPress, mac: boolean): boolean { +/** Ctrl+`letter` (with Shift exactly when `shift`) on Windows and Linux. macOS needs none + * of these — Cmd+C and Cmd+V work there through the Edit menu. */ +function ctrlChord(e: KeyPress, mac: boolean, letter: 'C' | 'V', shift: boolean): boolean { // keyCode 229 marks the keydown that starts an IME composition. if (mac || e.type !== 'keydown' || e.isComposing || e.keyCode === 229) return false; - if (e.altKey || e.metaKey || !e.ctrlKey || !e.shiftKey) return false; + if (e.altKey || e.metaKey || !e.ctrlKey || e.shiftKey !== shift) return false; // The physical key stands in only where the layout puts a non-Latin letter on it, so // a Cyrillic layout copies with the same keys while Dvorak's J there stays a J. - return e.key === 'c' || e.key === 'C' || (e.code === 'KeyC' && nonLatinLetter(e.key)); + return ( + e.key.toUpperCase() === letter || (e.code === `Key${letter}` && nonLatinLetter(e.key)) + ); +} + +/** Ctrl+Shift+C, as in GNOME Terminal and Windows Terminal: always a copy, never ^C. + * The webview pastes Ctrl+Shift+V natively, except where `layoutFallback` steps in. */ +export function isCopyShortcut(e: KeyPress, mac: boolean): boolean { + return ctrlChord(e, mac, 'C', true); +} + +/** A bare Ctrl+C: a copy only when the Ctrl clipboard pref is on and text is selected + * (the view decides both), ^C otherwise, as in Windows Terminal. */ +export function isBareCopy(e: KeyPress, mac: boolean): boolean { + return ctrlChord(e, mac, 'C', false); +} + +/** A bare Ctrl+V: a paste when the Ctrl clipboard pref is on, ^V otherwise. */ +export function isBarePaste(e: KeyPress, mac: boolean): boolean { + return ctrlChord(e, mac, 'V', false); } /** Enter or Esc, unmodified and outside an IME composition: what closes a tab whose diff --git a/crates/omnyssh-gui/ui/src/lib/stores/terminalClipboard.test.ts b/crates/omnyssh-gui/ui/src/lib/stores/terminalClipboard.test.ts new file mode 100644 index 00000000..89346902 --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/stores/terminalClipboard.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { get } from 'svelte/store'; + +// Same shape as the streamer-mode pref: a fake Tauri store, a fresh module per test to +// reset the singleton, and the localStorage mirror seeding the initial value. +const backend = { get: vi.fn(), set: vi.fn(), save: vi.fn() }; +vi.mock('@tauri-apps/plugin-store', () => ({ load: vi.fn(async () => backend) })); + +async function fresh() { + vi.resetModules(); + return (await import('./terminalClipboard')).terminalCtrlClipboard; +} + +describe('terminal Ctrl clipboard pref', () => { + beforeEach(() => { + localStorage.clear(); + backend.get.mockReset(); + backend.set.mockReset().mockResolvedValue(undefined); + backend.save.mockReset().mockResolvedValue(undefined); + }); + + it('defaults to on and mirrors a toggle to localStorage', async () => { + const pref = await fresh(); + expect(get(pref)).toBe(true); + pref.toggle(); + expect(get(pref)).toBe(false); + expect(localStorage.getItem('omnyssh-terminal-ctrl-clipboard')).toBe('false'); + }); + + it('starts off when the mirror says so', async () => { + localStorage.setItem('omnyssh-terminal-ctrl-clipboard', 'false'); + expect(get(await fresh())).toBe(false); + }); + + it('writes the canonical tauri-plugin-store on a user flip', async () => { + const pref = await fresh(); + pref.set(false); + await vi.waitFor(() => { + expect(backend.set).toHaveBeenCalledWith('terminalCtrlClipboard', false); + expect(backend.save).toHaveBeenCalled(); + }); + }); + + it('hydrate applies the stored value without clobbering a fresh user flip', async () => { + backend.get.mockResolvedValue(true); // stale persisted value + const pref = await fresh(); + pref.set(false); // user acts before hydrate resolves + await pref.hydrate(); + expect(get(pref)).toBe(false); + }); + + it('hydrate applies the stored value when the user has not acted', async () => { + backend.get.mockResolvedValue(false); + const pref = await fresh(); + await pref.hydrate(); + expect(get(pref)).toBe(false); + }); +}); diff --git a/crates/omnyssh-gui/ui/src/lib/stores/terminalClipboard.ts b/crates/omnyssh-gui/ui/src/lib/stores/terminalClipboard.ts new file mode 100644 index 00000000..0a201303 --- /dev/null +++ b/crates/omnyssh-gui/ui/src/lib/stores/terminalClipboard.ts @@ -0,0 +1,73 @@ +import { writable } from 'svelte/store'; + +// Ctrl+C / Ctrl+V clipboard in the terminal (Windows and Linux): Ctrl+C copies when text +// is selected and is ^C otherwise, Ctrl+V pastes. On by default; turning it off gives the +// remote a literal ^V again (vim visual-block, bash quoted-insert). Ctrl+Shift+C/V work +// either way. The pref persists like the other UI-chrome prefs (tauri-plugin-store + a +// localStorage mirror for first paint), matching the streamer-mode shape. +const LOCAL_KEY = 'omnyssh-terminal-ctrl-clipboard'; +const STORE_FILE = 'settings.json'; +const STORE_KEY = 'terminalCtrlClipboard'; + +function mirrored(): boolean { + try { + return localStorage.getItem(LOCAL_KEY) !== 'false'; + } catch { + return true; // localStorage unavailable: default to on. + } +} + +function mirrorLocal(on: boolean): void { + try { + localStorage.setItem(LOCAL_KEY, String(on)); + } catch { + // localStorage unavailable (hardened webview): the store copy is canonical. + } +} + +async function persistStore(on: boolean): Promise { + try { + const { load } = await import('@tauri-apps/plugin-store'); + const store = await load(STORE_FILE); + await store.set(STORE_KEY, on); + await store.save(); + } catch { + // Not under Tauri (tests, vite preview): the localStorage mirror suffices. + } +} + +function createTerminalCtrlClipboard() { + const initial = mirrored(); + const { subscribe, set: setStore } = writable(initial); + let current = initial; + let interacted = false; + + function apply(on: boolean, user: boolean): void { + current = on; + setStore(on); + mirrorLocal(on); + if (user) { + interacted = true; + void persistStore(on); + } + } + + return { + subscribe, + set: (on: boolean) => apply(on, true), + toggle: () => apply(!current, true), + /** Reconcile with the canonical tauri-plugin-store value once Tauri is reachable. */ + async hydrate(): Promise { + try { + const { load } = await import('@tauri-apps/plugin-store'); + const store = await load(STORE_FILE); + const saved = await store.get(STORE_KEY); + if (!interacted && typeof saved === 'boolean') apply(saved, false); + } catch { + // Store unreachable: keep the mirrored value. + } + } + }; +} + +export const terminalCtrlClipboard = createTerminalCtrlClipboard(); diff --git a/crates/omnyssh-gui/ui/src/routes/+layout.svelte b/crates/omnyssh-gui/ui/src/routes/+layout.svelte index 9f146fc5..4cf42520 100644 --- a/crates/omnyssh-gui/ui/src/routes/+layout.svelte +++ b/crates/omnyssh-gui/ui/src/routes/+layout.svelte @@ -6,6 +6,7 @@ import { theme } from '$lib/stores/theme'; import { sidebarCollapsed } from '$lib/stores/ui'; import { streamerMode } from '$lib/stores/streamer'; + import { terminalCtrlClipboard } from '$lib/stores/terminalClipboard'; import { refreshInterval, driveMetricsRefresh } from '$lib/stores/settings'; import { trayBehavior, driveTray } from '$lib/stores/tray'; import { lastError } from '$lib/stores/notifications'; @@ -20,6 +21,7 @@ void theme.hydrate(); void sidebarCollapsed.hydrate(); void streamerMode.hydrate(); + void terminalCtrlClipboard.hydrate(); void refreshInterval.hydrate(); void trayBehavior.hydrate(); // Force a metric refresh on the user's interval; re-arms when the interval changes. From 08b727c361ee93eaf0094b1e746bc49d4650076e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Rossi?= Date: Wed, 30 Sep 2026 17:22:10 +0200 Subject: [PATCH 2/2] docs(changelog): note ctrl+c and ctrl+v copy and paste in the terminal --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb0a2eff..7e4a6e30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ Versions follow [Semantic Versioning](https://semver.org/). - **macOS: a host on your local network that fails with "No route to host" says macOS may be blocking it.** macOS keeps an app away from devices on the local network until you allow it, and a blocked connection fails at once with "No route to host (os error 65)", while the router usually stays reachable because it is also your DNS server, so it looked like a fault on that one machine. The error now adds that macOS may be blocking the app and where to allow it: System Settings, Privacy & Security, Local Network, then reopen the app. The desktop app also now carries the explanation macOS shows when it asks for that permission. The terminal app needs the permission for the terminal it runs in; Apple's Terminal has it already. - **A terminal the server closes right after login stays open and shows why.** A Synology NAS gives a shell only to administrators: any other account logs in, gets DSM's "Permission denied, please try again." and the session ends. `ssh` shows that message, but OmnySSH closed the tab the moment the session ended, so the terminal seemed to vanish right after the password while file sessions to the same NAS worked. In both apps, a terminal tab that showed anything now stays open when the remote side ends its session, including after `exit` or a dropped connection, with "[Connection closed. Press Enter to close this tab.]" under the last output. Enter or Esc closes it, and it takes no other input. A terminal that fails before showing anything still closes, with the reason in the status bar. The desktop app could also drop the last lines a session printed just before it ended; they now always arrive. +### Features +- **Copy and paste in the desktop terminal with Ctrl+C and Ctrl+V (Windows and Linux).** Copying needed Ctrl+Shift+C or the right-click menu. Now Ctrl+C copies the selected text and clears the selection. With nothing selected it still sends the shell an interrupt (^C), as in Windows Terminal. Ctrl+V pastes, using bracketed paste when the remote program asks for it. Vim's visual-block mode and readline's quoted-insert need a literal ^V, so a Terminal setting turns both keys off and sends ^C and ^V to the shell again. Ctrl+Shift+C and Ctrl+Shift+V work either way. On macOS Cmd+C and Cmd+V already worked and are unchanged. + --- ## 1.1.4 — 2026-09-28