diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d21523..637d698 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Desktop terminal: tab titles, bell and progress.** A tab shows the title the remote shell or program sets (OSC 0/2, e.g. `user@host:~/dir` or vim's file name), a dot when a background tab rings the bell (the window also asks for attention when it isn't focused), and a progress bar for programs that report progress with OSC 9;4. Pop-out windows take the title as their window title and show progress on the taskbar. +- **Desktop terminal: right-click menu.** Copy, Paste, Select all, Find, Clear and Export scrollback. When the program running in the terminal uses the mouse (vim, htop, tmux with mouse on), right-click goes to it; hold Shift to open the menu. +- **Desktop terminal: confirmation before pasting several lines.** Pasting text with line breaks (keyboard, menu or middle click) shows the lines first, since each one runs as a command; single-line pastes go straight through. + +### Changed +- **Desktop terminal: middle click pastes the primary selection on Linux**, like other Linux terminals, instead of the clipboard. If the selection can't be read, it pastes the last text selected in a bssh terminal. +- **Desktop terminal: pop-out windows behave like tabs**: middle-click paste and Ctrl+wheel zoom work there too, since both now build their terminal the same way. + +### Fixed +- **Desktop terminal: Ctrl+C stops a flood of output at once.** Commands printing a lot (`cat` on a large file, `journalctl` without a pager) used to queue megabytes of output ahead of the terminal, so Ctrl+C only showed up once all of it had scrolled by, and the window's memory grew meanwhile. bssh now reads from ssh only as fast as the terminal displays, and output reaches each terminal over its own channel instead of an app-wide event. +- **Desktop terminal: full-screen programs survive pop-out, dock and detach.** Moving a session running vim, htop or less to another window, or detaching and reattaching it, now restores the screen as it was, modes included, and asks the program to redraw, instead of replaying raw output that could start mid-sequence or miss the switch to the alternate screen. +- **Desktop terminal: many open tabs keep GPU rendering.** Only visible terminals hold a WebGL context now, so opening many tabs no longer exhausts the webview's contexts and drops older tabs to slower rendering. + ## [2.13.9] - 2026-10-27 ### Added diff --git a/Cargo.lock b/Cargo.lock index 5ba43ce..fb545ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,27 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "argon2" version = "0.5.3" @@ -560,6 +581,7 @@ name = "bayesian-ssh-gui" version = "2.13.9" dependencies = [ "anyhow", + "arboard", "base64 0.22.1", "bayesian-ssh", "bssh-teams-proto", @@ -777,6 +799,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -1009,6 +1037,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "052af7878466feccda12ba7a6a67d019d712f296809559b47c12c8cc196d3a9a" +dependencies = [ + "error-code", +] + [[package]] name = "cmov" version = "0.5.4" @@ -1198,6 +1235,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-bigint" version = "0.7.9" @@ -1673,7 +1716,7 @@ checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ "bit-set", "cssparser", - "foldhash", + "foldhash 0.2.0", "html5ever", "precomputed-hash", "selectors", @@ -1916,6 +1959,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "event-listener" version = "5.4.1" @@ -1955,6 +2004,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -2023,6 +2078,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.9" @@ -2039,6 +2100,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -2302,6 +2369,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2530,6 +2607,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2546,6 +2634,15 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -2898,6 +2995,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -3407,6 +3518,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.3" @@ -3508,6 +3629,15 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -3621,6 +3751,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -3869,6 +4000,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "osakit" version = "0.3.1" @@ -4049,6 +4190,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.0", +] + [[package]] name = "phc" version = "0.6.1" @@ -4436,6 +4588,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.41.0" @@ -6301,7 +6465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -6374,6 +6538,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.54" @@ -6737,6 +6915,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -7058,6 +7247,76 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.0", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.103" @@ -7179,6 +7438,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "whoami" version = "1.6.1" @@ -7801,6 +8066,24 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c7ac3b8afde864f7a7448ac16f29b2b83350b7eccbf77fb781c3999aa6848" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix 1.1.4", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "wnaf" version = "0.14.1" @@ -7884,6 +8167,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -8174,6 +8474,21 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd2f034a4bebf216c9e4b7083603e024cf930873fd67830cfb083c9fa33129d9" +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "4.2.0" diff --git a/crates/gui/Cargo.toml b/crates/gui/Cargo.toml index 4f2e184..0811859 100644 --- a/crates/gui/Cargo.toml +++ b/crates/gui/Cargo.toml @@ -51,5 +51,10 @@ tracing = "0.1" [target.'cfg(unix)'.dependencies] libc = "0.2" +# PRIMARY selection (middle-click paste) for the terminal. `wayland-data-control` +# makes it work on Wayland sessions instead of X11 only. +[target.'cfg(target_os = "linux")'.dependencies] +arboard = { version = "3", features = ["wayland-data-control"] } + [dev-dependencies] tempfile = "3" diff --git a/crates/gui/src/commands/mod.rs b/crates/gui/src/commands/mod.rs index 3d2476c..8b38863 100644 --- a/crates/gui/src/commands/mod.rs +++ b/crates/gui/src/commands/mod.rs @@ -1,4 +1,4 @@ -use portable_pty::Child; +use portable_pty::{Child, MasterPty}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::PathBuf; @@ -73,8 +73,9 @@ pub const REPLAY_TRIM_SLACK_BYTES: usize = 64 * 1024; pub const MAIN_WINDOW_LABEL: &str = "main"; /// Label of the pop-out window that owns a session, shared with the session's -/// reader thread so live output is routed to that window only. `None` means -/// the main window owns the session. +/// reader thread so the exit event reaches that window. `None` means the main +/// window owns the session. Live output does not use this: it goes through the +/// session's [`OutputSink`]. #[derive(Clone, Default)] pub struct PopoutOwner(Arc>>); @@ -96,11 +97,6 @@ impl PopoutOwner { pub fn is_none(&self) -> bool { self.lock().is_none() } - - /// Run `f` with the current owner without cloning the label. - pub fn with(&self, f: impl FnOnce(Option<&str>) -> R) -> R { - f(self.lock().as_deref()) - } } pub struct PtySession { @@ -108,8 +104,9 @@ pub struct PtySession { /// never blocks the IPC thread on a full PTY and keeps keystroke order. pub input: Sender, pub child: Box, - /// Keeps the PTY master alive for the session's lifetime. - pub _master: Box, + /// Keeps the PTY master alive for the session's lifetime. Behind a mutex so + /// `redraw_pty` can restore the size from a background thread. + pub master: Arc>>, /// Set to true by close_pty so the reader thread suppresses the pty-exit event. pub cancelled: Arc, /// Database session id when session logging is enabled. @@ -118,6 +115,12 @@ pub struct PtySession { pub detached: Arc, pub output_buffer: Arc>, pub replay_offset: Arc>, + /// Serialises the reader thread's "append to the replay buffer + deliver + /// on the sink" step against commands that swap the sink or read the + /// replay, so a chunk is never replayed and delivered live at once. + pub output_gate: Arc>, + /// Channel of the window that currently displays this session. + pub sink: OutputSink, pub popout_window: PopoutOwner, pub recorder: Option, } @@ -142,6 +145,15 @@ pub struct ReattachSessionInfo { pub buffered_output: String, } +/// Payload of the `session-docked` event: the pop-out window is gone and the +/// main window should call `attach_pty_output` for this session. The replay is +/// not included; the main window fetches it with that call. +#[derive(Serialize, Clone, Debug)] +pub struct DockedSessionInfo { + pub session_id: String, + pub connection_name: String, +} + #[derive(Serialize, Clone, Debug)] pub struct PopoutMainOverlap { pub overlaps: bool, diff --git a/crates/gui/src/commands/pty.rs b/crates/gui/src/commands/pty.rs index c46417c..bbc8483 100644 --- a/crates/gui/src/commands/pty.rs +++ b/crates/gui/src/commands/pty.rs @@ -1,7 +1,10 @@ +use portable_pty::MasterPty; use serde::Serialize; use std::collections::hash_map::Entry; use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{mpsc, Arc, Mutex}; +use std::sync::{mpsc, Arc, Condvar, Mutex}; +use std::time::Duration; +use tauri::ipc::Channel; use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl, WebviewWindowBuilder}; use uuid::Uuid; @@ -10,16 +13,218 @@ use bayesian_ssh::database::Database; use bayesian_ssh::models::Session; use super::{ - get_db_and_config, DetachedSessionInfo, PopoutMainOverlap, PopoutOwner, PopoutSessionInfo, - PtySession, PtyState, ReattachSessionInfo, MAIN_WINDOW_LABEL, MAX_DETACHED_BUFFER_BYTES, - REPLAY_TRIM_SLACK_BYTES, + get_db_and_config, DetachedSessionInfo, DockedSessionInfo, PopoutMainOverlap, PopoutOwner, + PopoutSessionInfo, PtySession, PtyState, ReattachSessionInfo, MAIN_WINDOW_LABEL, + MAX_DETACHED_BUFFER_BYTES, REPLAY_TRIM_SLACK_BYTES, }; -/// Live output payload consumed by the main window's `pty-output` listener. -#[derive(Clone, Serialize)] -struct PtyPayload<'a> { - session_id: &'a str, - data: &'a str, +/// One batch of PTY output delivered on a session's output channel. +/// +/// `bytes` is the UTF-8 byte length of `data`: the frontend acks exactly those +/// bytes once xterm has consumed the chunk, which is what releases the reader +/// thread's backpressure budget. +#[derive(Clone, Debug, Serialize)] +pub struct PtyChunk { + pub data: String, + pub bytes: usize, +} + +/// Output the reader thread may hand to a channel before it parks and waits +/// for the frontend to catch up. +const OUTPUT_HIGH_WATER_BYTES: usize = 1024 * 1024; +/// Once acks bring the outstanding bytes down to this, the reader resumes. +const OUTPUT_LOW_WATER_BYTES: usize = 256 * 1024; +/// How often a parked reader wakes up to re-check for cancellation, so a +/// closed session can never leave it blocked forever. +const OUTPUT_WAIT_POLL: Duration = Duration::from_millis(250); + +/// Byte counter behind the output channel's flow control. +/// +/// The reader records what it handed to the frontend, `ack_pty` subtracts what +/// xterm has consumed, and the reader parks once the outstanding amount reaches +/// [`OUTPUT_HIGH_WATER_BYTES`], resuming when it is back at +/// [`OUTPUT_LOW_WATER_BYTES`]. Deliberately free of Tauri types so the +/// thresholds are unit-testable. +#[derive(Default)] +pub struct FlowControl { + state: Mutex, + wake: Condvar, +} + +#[derive(Default)] +struct FlowState { + /// Whether a sink is installed; without one nothing is counted or waited. + attached: bool, + /// Bytes sent on the sink that the frontend has not acked yet. + unacked: usize, +} + +impl FlowControl { + fn lock(&self) -> std::sync::MutexGuard<'_, FlowState> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// A sink was installed (or replaced): the new consumer has not received + /// any of the outstanding bytes, so the budget starts from zero again. + fn attach(&self) { + let mut state = self.lock(); + state.attached = true; + state.unacked = 0; + drop(state); + self.wake.notify_all(); + } + + /// The sink went away (dock/detach/close/dead channel): stop counting and + /// release a parked reader. + fn detach(&self) { + let mut state = self.lock(); + state.attached = false; + state.unacked = 0; + drop(state); + self.wake.notify_all(); + } + + /// Account for a chunk handed to the sink. Without a sink nothing is + /// counted, mirroring the fact that no wait can happen either. + fn record(&self, bytes: usize) { + let mut state = self.lock(); + if !state.attached { + return; + } + state.unacked = state.unacked.saturating_add(bytes); + } + + /// The frontend processed `bytes`: release that much budget. + fn ack(&self, bytes: usize) { + let mut state = self.lock(); + state.unacked = state.unacked.saturating_sub(bytes); + drop(state); + self.wake.notify_all(); + } + + #[cfg(test)] + fn unacked(&self) -> usize { + self.lock().unacked + } + + /// Whether the consumer is far enough behind to stop the reader: only a + /// full high-water mark triggers a pause, so a single chunk above the low + /// mark still goes through. + fn congested(&self) -> bool { + let state = self.lock(); + state.attached && state.unacked >= OUTPUT_HIGH_WATER_BYTES + } + + /// Park while the consumer is behind, re-checking [`OUTPUT_WAIT_POLL`] so a + /// cancelled session or a cleared sink never blocks the reader forever. + fn wait_while_congested(&self, stop: impl Fn() -> bool) { + if !self.congested() { + return; + } + loop { + if stop() { + return; + } + let state = self.lock(); + if !state.attached || state.unacked <= OUTPUT_LOW_WATER_BYTES { + return; + } + // Nothing panics while the lock is held, so a poisoned mutex only + // means "woken up": keep the guard and re-check the condition. + let state = self + .wake + .wait_timeout(state, OUTPUT_WAIT_POLL) + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .0; + drop(state); + } + } +} + +/// Per-session output sink: the channel currently attached (the main window or +/// one pop-out window) plus its flow-control counter. +/// +/// Cloning is cheap and shares the same sink, so the reader thread and the +/// commands always see the same channel. `None` means nobody is listening: +/// output then only lands in the replay buffer and the recorder. +#[derive(Clone, Default)] +pub struct OutputSink(Arc); + +#[derive(Default)] +struct OutputSinkInner { + channel: Mutex>>, + flow: FlowControl, +} + +impl OutputSink { + pub fn new(channel: Channel) -> Self { + let sink = Self::default(); + sink.set(channel); + sink + } + + fn lock_channel(&self) -> std::sync::MutexGuard<'_, Option>> { + self.0 + .channel + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + /// Install (or replace) the channel. + pub fn set(&self, channel: Channel) { + *self.lock_channel() = Some(channel); + self.0.flow.attach(); + } + + /// Stop delivering: the session was docked, detached, reopened in a pop-out + /// window, closed, or its channel died. Returns the channel that was + /// attached, so a caller whose handover failed can put it back. + pub fn take(&self) -> Option> { + let previous = self.lock_channel().take(); + self.0.flow.detach(); + previous + } + + /// Stop delivering and drop the channel. + pub fn clear(&self) { + let _ = self.take(); + } + + /// Hand one chunk to the attached channel. No sink means the chunk only + /// lives in the replay buffer. + fn send(&self, data: &str) { + // The channel mutex is held across the send so a concurrent `set`/ + // `clear` cannot make us drop the channel that replaced this one. + let mut channel = self.lock_channel(); + let Some(sink) = channel.as_ref() else { + return; + }; + let chunk = PtyChunk { + data: data.to_string(), + bytes: data.len(), + }; + // Count before sending: an ack for this chunk may arrive while the + // send is still in flight, and it must not be lost under the counter. + self.0.flow.record(chunk.bytes); + if sink.send(chunk).is_err() { + // The webview is gone (window closed or reloading): drop the sink + // so the reader stops accounting against a dead channel. + *channel = None; + self.0.flow.detach(); + } + } + + /// The frontend consumed `bytes`. + fn ack(&self, bytes: usize) { + self.0.flow.ack(bytes); + } + + /// Park while the frontend is behind on this sink (see [`FlowControl`]). + fn wait_while_congested(&self, stop: impl Fn() -> bool) { + self.0.flow.wait_while_congested(stop); + } } /// Exit status of a session's child, sent just before `pty-exit` so the UI can @@ -118,6 +323,49 @@ fn take_full_replay(session: &PtySession) -> String { buffer } +/// Lock the gate that serialises the reader thread's "append to the replay +/// buffer + deliver" step against commands swapping the sink or reading the +/// replay. +fn lock_output_gate(session: &PtySession) -> std::sync::MutexGuard<'_, ()> { + session + .output_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Replace the replay buffer with a frontend snapshot: the terminal the user +/// just left is now the whole history, and later output appends to it. +fn apply_snapshot(session: &PtySession, snapshot: Option) { + let Some(snapshot) = snapshot else { + return; + }; + if let Ok(mut buffer) = session.output_buffer.lock() { + *buffer = snapshot; + } + if let Ok(mut offset) = session.replay_offset.lock() { + *offset = 0; + } +} + +/// Attach a new sink and read the replay in one atomic step: a chunk is either +/// still in the buffer (and replayed) or delivered live, never both and never +/// neither. +fn switch_sink_on(session: &PtySession, output: Channel) -> String { + let _gate = lock_output_gate(session); + session.sink.set(output); + take_full_replay(session) +} + +/// Detach the current sink and (optionally) install the frontend snapshot of +/// what it had displayed, in one atomic step. Returns the detached channel so +/// a failed handover can restore it. +fn switch_sink_off(session: &PtySession, snapshot: Option) -> Option> { + let _gate = lock_output_gate(session); + let previous = session.sink.take(); + apply_snapshot(session, snapshot); + previous +} + fn seal_replay_offset(session: &PtySession) { if let (Ok(buffer), Ok(mut offset)) = (session.output_buffer.lock(), session.replay_offset.lock()) @@ -172,14 +420,14 @@ fn reap_session(session: PtySession, app: &AppHandle) { mut child, db_session_id, input, - _master, + master, recorder, .. } = session; // Release our PTY ends first so the child sees the hangup before we block // (closing the input queue stops the writer thread, which drops the writer). drop(input); - drop(_master); + drop(master); let exit_code = child .wait() .map(|status| status.exit_code() as i32) @@ -197,6 +445,7 @@ pub fn spawn_pty( state: State<'_, PtyState>, session_id: String, connection_name: String, + output: Channel, ) -> Result<(), String> { if state.lock_sessions().contains_key(&session_id) { // Overwriting the entry would drop the old PtySession without killing @@ -301,7 +550,6 @@ pub fn spawn_pty( let cancelled = Arc::new(AtomicBool::new(false)); let cancelled_clone = Arc::clone(&cancelled); let detached = Arc::new(AtomicBool::new(false)); - let detached_clone = Arc::clone(&detached); let output_buffer = Arc::new(Mutex::new(String::new())); let output_buffer_clone = Arc::clone(&output_buffer); let replay_offset = Arc::new(Mutex::new(0)); @@ -309,22 +557,30 @@ pub fn spawn_pty( let popout_window = PopoutOwner::default(); let popout_window_clone = popout_window.clone(); let recorder_clone = recorder.clone(); + // Sink installed before the reader thread starts, so the first chunk of + // output cannot be missed. + let sink = OutputSink::new(output); + let sink_clone = sink.clone(); + let output_gate = Arc::new(Mutex::new(())); + let output_gate_clone = Arc::clone(&output_gate); let session = PtySession { input, child, - _master: spawned.master, + master: Arc::new(Mutex::new(spawned.master)), cancelled, db_session_id, connection_name: connection.name.clone(), detached, output_buffer, replay_offset, + output_gate, + sink, popout_window, recorder, }; - // Store in global state — _master keeps the PTY master fd alive for the + // Store in global state — the master keeps the PTY fd alive for the // session duration. Re-check the id under the lock: commands run // concurrently, so another spawn may have claimed it meanwhile. { @@ -352,40 +608,29 @@ pub fn spawn_pty( std::thread::spawn(move || { let mut reader = reader; - // Pop-out windows listen on the per-session event with the raw string. - let popout_output_event = format!("pty-output:{session_id_clone}"); bayesian_ssh::services::pty::read_loop(&mut reader, |str_data| { - if let Some(ref rec) = recorder_clone { + if let Some(rec) = &recorder_clone { if let Ok(mut g) = rec.lock() { g.output(&str_data); } } - append_to_output_buffer(&output_buffer_clone, &replay_offset_clone, &str_data); - - if detached_clone.load(Ordering::SeqCst) { - return; + // Replay append and live delivery happen under one gate, so a + // command that swaps the sink (dock / re-attach / detach) can + // never replay a chunk that was also delivered on the channel. + { + let _gate = output_gate_clone + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + append_to_output_buffer(&output_buffer_clone, &replay_offset_clone, &str_data); + sink_clone.send(&str_data); } - // Emit each chunk once, to the window that owns the session: the - // main window consumes the shared `pty-output` event, a pop-out - // window its per-session `pty-output:{id}` event. - popout_window_clone.with(|owner| match owner { - Some(label) => { - let _ = app_handle.emit_to(label, &popout_output_event, str_data.as_str()); - } - None => { - let _ = app_handle.emit_to( - MAIN_WINDOW_LABEL, - "pty-output", - PtyPayload { - session_id: &session_id_clone, - data: &str_data, - }, - ); - } - }); + // The frontend is behind: park until it acks down to the low-water + // mark (or the session is closed / the sink is cleared). No lock + // is held here, and the wait re-checks cancellation every 250 ms. + sink_clone.wait_while_congested(|| cancelled_clone.load(Ordering::SeqCst)); }); drop(recorder_clone); @@ -475,8 +720,13 @@ pub fn resize_pty( let session = sessions .get(&session_id) .ok_or_else(|| format!("PTY session '{}' not found", session_id))?; - bayesian_ssh::services::pty::resize(session._master.as_ref(), cols, rows)?; - if let Some(ref rec) = session.recorder { + let master = session + .master + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + bayesian_ssh::services::pty::resize(master.as_ref(), cols, rows)?; + drop(master); + if let Some(rec) = &session.recorder { if let Ok(mut g) = rec.lock() { g.resize(cols, rows); } @@ -484,6 +734,52 @@ pub fn resize_pty( Ok(()) } +/// Force full-screen applications (vim, htop, less) to repaint: shrink the PTY +/// by one row, then restore the size ~60 ms later from a background thread. +/// The window-change signal makes them redraw everything, which repairs the +/// screen after a snapshot replay. Purely a display concern, so the recorder +/// is left alone. +#[tauri::command] +pub fn redraw_pty(state: State<'_, PtyState>, session_id: String) -> Result<(), String> { + let master = { + let sessions = state.lock_sessions(); + let session = sessions + .get(&session_id) + .ok_or_else(|| format!("PTY session '{session_id}' not found"))?; + Arc::clone(&session.master) + }; + + let size = { + let master = master + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + MasterPty::get_size(master.as_ref()).map_err(|e| e.to_string())? + }; + + // One row less is a real size change, so the application gets SIGWINCH and + // repaints; the original size comes back once it had time to do so. + { + let master = master + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + bayesian_ssh::services::pty::resize( + master.as_ref(), + size.cols, + size.rows.saturating_sub(1).max(1), + )?; + } + + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(60)); + let master = master + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let _ = bayesian_ssh::services::pty::resize(master.as_ref(), size.cols, size.rows); + }); + + Ok(()) +} + #[tauri::command] pub fn seal_session_ui(state: State<'_, PtyState>, session_id: String) -> Result<(), String> { let sessions = state.lock_sessions(); @@ -495,10 +791,14 @@ pub fn seal_session_ui(state: State<'_, PtyState>, session_id: String) -> Result } #[tauri::command] -pub fn detach_pty(state: State<'_, PtyState>, session_id: String) -> Result<(), String> { - let mut sessions = state.lock_sessions(); +pub fn detach_pty( + state: State<'_, PtyState>, + session_id: String, + snapshot: Option, +) -> Result<(), String> { + let sessions = state.lock_sessions(); let session = sessions - .get_mut(&session_id) + .get(&session_id) .ok_or_else(|| format!("PTY session '{}' not found", session_id))?; if session.detached.load(Ordering::SeqCst) { @@ -507,6 +807,9 @@ pub fn detach_pty(state: State<'_, PtyState>, session_id: String) -> Result<(), session.detached.store(true, Ordering::SeqCst); session.popout_window.set(None); + // Nobody listens while detached: output only accrues in the replay buffer, + // seeded with what the frontend had on screen. + let _ = switch_sink_off(session, snapshot); Ok(()) } @@ -518,6 +821,7 @@ pub fn open_terminal_window( state: State<'_, PtyState>, session_id: String, title: String, + snapshot: Option, ) -> Result<(), String> { let label = format!("terminal-{session_id}"); @@ -526,15 +830,17 @@ pub fn open_terminal_window( return Ok(()); } - { + let previous_sink = { let sessions = state.lock_sessions(); let session = sessions .get(&session_id) .ok_or_else(|| format!("PTY session '{session_id}' not found"))?; - // Route output to the new window before resuming it. + // The new window creates its own channel when it claims the session; + // until then nobody listens, so stop delivering to this window. session.popout_window.set(Some(label.clone())); session.detached.store(false, Ordering::SeqCst); - } + switch_sink_off(session, snapshot) + }; let window_title = format!("{title} — Bayesian SSH"); let built = WebviewWindowBuilder::new( @@ -551,10 +857,13 @@ pub fn open_terminal_window( if let Err(e) = built { // No window will ever claim or dock this session: release the popout - // binding so the main window can keep using it. + // binding and hand output back to the window that still displays it. if let Some(session) = state.lock_sessions().get(&session_id) { if session.popout_window.get().as_deref() == Some(label.as_str()) { session.popout_window.set(None); + if let Some(channel) = previous_sink { + session.sink.set(channel); + } } } return Err(e.to_string()); @@ -568,10 +877,11 @@ pub fn claim_popout_session( state: State<'_, PtyState>, session_id: String, window_label: String, + output: Channel, ) -> Result { - let mut sessions = state.lock_sessions(); + let sessions = state.lock_sessions(); let session = sessions - .get_mut(&session_id) + .get(&session_id) .ok_or_else(|| format!("PTY session '{session_id}' not found"))?; match session.popout_window.get() { @@ -585,7 +895,39 @@ pub fn claim_popout_session( } session.detached.store(false, Ordering::SeqCst); - let buffered_output = take_full_replay(session); + let buffered_output = switch_sink_on(session, output); + + Ok(ReattachSessionInfo { + session_id: session_id.clone(), + connection_name: session.connection_name.clone(), + buffered_output, + }) +} + +/// Re-attach the main window to a session that was just docked from its +/// pop-out window. Sessions owned by a pop-out window or detached use +/// `claim_popout_session` / `reattach_pty` instead. +#[tauri::command] +pub fn attach_pty_output( + state: State<'_, PtyState>, + session_id: String, + output: Channel, +) -> Result { + let sessions = state.lock_sessions(); + let session = sessions + .get(&session_id) + .ok_or_else(|| format!("PTY session '{session_id}' not found"))?; + + if session.popout_window.get().is_some() { + return Err(format!( + "PTY session '{session_id}' is attached to a pop-out window" + )); + } + if session.detached.load(Ordering::SeqCst) { + return Err(format!("PTY session '{session_id}' is detached")); + } + + let buffered_output = switch_sink_on(session, output); Ok(ReattachSessionInfo { session_id: session_id.clone(), @@ -594,6 +936,16 @@ pub fn claim_popout_session( }) } +/// The frontend has written (or dropped) `bytes` of output. Acks race with +/// session teardown by design, so an unknown session is not an error. +#[tauri::command] +pub fn ack_pty(state: State<'_, PtyState>, session_id: String, bytes: usize) -> Result<(), String> { + if let Some(session) = state.lock_sessions().get(&session_id) { + session.sink.ack(bytes); + } + Ok(()) +} + #[tauri::command] pub fn list_detached_sessions( state: State<'_, PtyState>, @@ -635,11 +987,12 @@ pub fn dock_popout_session( state: State<'_, PtyState>, session_id: String, window_label: String, -) -> Result { + snapshot: Option, +) -> Result<(), String> { let info = { - let mut sessions = state.lock_sessions(); + let sessions = state.lock_sessions(); let session = sessions - .get_mut(&session_id) + .get(&session_id) .ok_or_else(|| format!("PTY session '{session_id}' not found"))?; if session.popout_window.get().as_deref() != Some(window_label.as_str()) { @@ -648,22 +1001,26 @@ pub fn dock_popout_session( session.popout_window.set(None); session.detached.store(false, Ordering::SeqCst); - let buffered_output = take_full_replay(session); + // The main window re-attaches with `attach_pty_output` once it sees + // `session-docked`; until then output only accrues in the replay + // buffer, seeded with what the pop-out window had on screen. + let _ = switch_sink_off(session, snapshot); - ReattachSessionInfo { + DockedSessionInfo { session_id: session_id.clone(), connection_name: session.connection_name.clone(), - buffered_output, } }; - let _ = app.emit("session-docked", info.clone()); + // The main window owns the session again and replays it from its own + // buffer: send it the identity only. + let _ = app.emit("session-docked", info); if let Some(window) = app.get_webview_window(&window_label) { let _ = window.destroy(); } - Ok(info) + Ok(()) } #[tauri::command] @@ -725,10 +1082,11 @@ pub fn focus_terminal_window( pub fn reattach_pty( state: State<'_, PtyState>, session_id: String, + output: Channel, ) -> Result { - let mut sessions = state.lock_sessions(); + let sessions = state.lock_sessions(); let session = sessions - .get_mut(&session_id) + .get(&session_id) .ok_or_else(|| format!("PTY session '{}' not found", session_id))?; if !session.detached.load(Ordering::SeqCst) { @@ -737,7 +1095,7 @@ pub fn reattach_pty( session.popout_window.set(None); session.detached.store(false, Ordering::SeqCst); - let buffered_output = take_full_replay(session); + let buffered_output = switch_sink_on(session, output); Ok(ReattachSessionInfo { session_id: session_id.clone(), @@ -767,6 +1125,10 @@ pub fn close_pty( if let Some(mut session) = session { session.cancelled.store(true, Ordering::SeqCst); + // Release a reader parked on flow control: the flag it re-checks is + // set, and clearing the sink notifies it right away instead of after + // the next 250 ms poll. + session.sink.clear(); if close_window.unwrap_or(true) { if let Some(label) = session.popout_window.get() { @@ -800,3 +1162,167 @@ pub fn close_all_ptys(app: AppHandle, state: State<'_, PtyState>) -> Result Option { + use arboard::{Clipboard, GetExtLinux, LinuxClipboardKind}; + + let mut clipboard = Clipboard::new().ok()?; + clipboard + .get() + .clipboard(LinuxClipboardKind::Primary) + .text() + .ok() +} + +/// Only X11 and Wayland have a PRIMARY selection; elsewhere middle-click paste +/// falls back to the default clipboard in the frontend. +#[cfg(not(target_os = "linux"))] +fn primary_selection() -> Option { + None +} + +/// Read the PRIMARY selection, the X11/Wayland buffer middle-click paste uses. +/// +/// Runs off the main thread because a selection owner that does not answer can +/// make the clipboard backend block for a while. `None` when no owner answers +/// or the backend is unavailable (headless session, no display server). +#[tauri::command(async)] +pub fn read_primary_selection() -> Option { + primary_selection() +} + +#[cfg(test)] +mod tests { + use super::{FlowControl, OUTPUT_HIGH_WATER_BYTES, OUTPUT_LOW_WATER_BYTES, OUTPUT_WAIT_POLL}; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::mpsc::{channel, Receiver, RecvTimeoutError}; + use std::sync::Arc; + use std::time::Duration; + + /// Start a waiter thread and signal when `wait_while_congested` returns. + fn spawn_waiter( + flow: &Arc, + stop: impl Fn() -> bool + Send + 'static, + ) -> Receiver<()> { + let (tx, rx) = channel(); + let flow = Arc::clone(flow); + std::thread::spawn(move || { + flow.wait_while_congested(stop); + let _ = tx.send(()); + }); + rx + } + + /// Waiting longer than one poll interval without a signal proves the + /// reader is really parked. + fn assert_still_parked(rx: &Receiver<()>) { + assert_eq!( + rx.recv_timeout(OUTPUT_WAIT_POLL + Duration::from_millis(100)), + Err(RecvTimeoutError::Timeout), + "reader resumed before the frontend caught up" + ); + } + + fn assert_released(rx: &Receiver<()>) { + assert_eq!( + rx.recv_timeout(Duration::from_secs(2)), + Ok(()), + "reader stayed parked" + ); + } + + #[test] + fn reader_parks_at_high_water_and_resumes_at_low_water() { + let flow = Arc::new(FlowControl::default()); + flow.attach(); + flow.record(OUTPUT_HIGH_WATER_BYTES); + let waiter = spawn_waiter(&flow, || false); + assert_still_parked(&waiter); + + // One byte above the low-water mark is still too much. + flow.ack(OUTPUT_HIGH_WATER_BYTES - OUTPUT_LOW_WATER_BYTES - 1); + assert_eq!(flow.unacked(), OUTPUT_LOW_WATER_BYTES + 1); + assert_still_parked(&waiter); + + flow.ack(1); + assert_eq!(flow.unacked(), OUTPUT_LOW_WATER_BYTES); + assert_released(&waiter); + } + + #[test] + fn reader_only_parks_once_the_high_water_mark_is_reached() { + let flow = Arc::new(FlowControl::default()); + flow.attach(); + // One byte short of the high-water mark: deliver it and keep going. + flow.record(OUTPUT_HIGH_WATER_BYTES - 1); + let waiter = spawn_waiter(&flow, || false); + assert_released(&waiter); + + flow.record(1); + let waiter = spawn_waiter(&flow, || false); + assert_still_parked(&waiter); + flow.ack(usize::MAX); + assert_released(&waiter); + } + + #[test] + fn nothing_is_counted_or_waited_for_without_a_sink() { + let flow = Arc::new(FlowControl::default()); + flow.record(OUTPUT_HIGH_WATER_BYTES * 4); + // No sink attached: output is nobody's problem, so it is neither + // counted nor waited for. + assert_eq!(flow.unacked(), 0); + let waiter = spawn_waiter(&flow, || false); + assert_released(&waiter); + } + + #[test] + fn replacing_or_clearing_the_sink_resets_and_releases() { + for clear in [true, false] { + let flow = Arc::new(FlowControl::default()); + flow.attach(); + flow.record(OUTPUT_HIGH_WATER_BYTES); + let waiter = spawn_waiter(&flow, || false); + assert_still_parked(&waiter); + + // The budget belongs to the consumer that left. + if clear { + flow.detach(); + } else { + flow.attach(); + } + assert_eq!(flow.unacked(), 0); + assert_released(&waiter); + } + } + + #[test] + fn ack_saturates_instead_of_underflowing() { + let flow = FlowControl::default(); + flow.attach(); + flow.record(OUTPUT_LOW_WATER_BYTES); + // A late or duplicated ack must not wrap the counter into a huge value + // that parks the reader forever. + flow.ack(usize::MAX); + assert_eq!(flow.unacked(), 0); + flow.ack(64); + assert_eq!(flow.unacked(), 0); + } + + #[test] + fn cancelled_reader_leaves_the_wait() { + let flow = Arc::new(FlowControl::default()); + flow.attach(); + flow.record(OUTPUT_HIGH_WATER_BYTES); + let cancelled = Arc::new(AtomicBool::new(false)); + let flag = Arc::clone(&cancelled); + let waiter = spawn_waiter(&flow, move || flag.load(Ordering::SeqCst)); + assert_still_parked(&waiter); + + // Polled, no notify needed: close_pty may only flip the flag. + cancelled.store(true, Ordering::SeqCst); + assert_released(&waiter); + } +} diff --git a/crates/gui/src/lib.rs b/crates/gui/src/lib.rs index 7c4481b..82dc91e 100644 --- a/crates/gui/src/lib.rs +++ b/crates/gui/src/lib.rs @@ -5,16 +5,17 @@ mod updater; use bayesian_ssh::services::agent; use commands::{ - add_connection, add_key_to_agent, cancel_upload, check_popout_main_overlap, - claim_popout_session, close_all_ptys, close_pty, complete_onboarding, count_active_sessions, - create_environment, detach_pty, dock_popout_session, edit_connection, focus_terminal_window, - force_quit_app, get_active_env, get_agent_status, get_connections, get_history, get_stats, - get_workspace_info, import_ssh_config, list_detached_sessions, list_environments, - list_popout_sessions, load_desktop_settings, needs_onboarding, open_terminal_window, - pick_key_file, pick_ssh_config_file, pick_upload_files, pick_upload_folder, quit_app, - reattach_pty, remove_connection, remove_environment, resize_pty, save_desktop_settings, - save_workspace_config, seal_session_ui, set_active_env, spawn_pty, start_agent, start_upload, - write_pty, PtyState, UploadState, + ack_pty, add_connection, add_key_to_agent, attach_pty_output, cancel_upload, + check_popout_main_overlap, claim_popout_session, close_all_ptys, close_pty, + complete_onboarding, count_active_sessions, create_environment, detach_pty, + dock_popout_session, edit_connection, focus_terminal_window, force_quit_app, get_active_env, + get_agent_status, get_connections, get_history, get_stats, get_workspace_info, + import_ssh_config, list_detached_sessions, list_environments, list_popout_sessions, + load_desktop_settings, needs_onboarding, open_terminal_window, pick_key_file, + pick_ssh_config_file, pick_upload_files, pick_upload_folder, quit_app, read_primary_selection, + reattach_pty, redraw_pty, remove_connection, remove_environment, resize_pty, + save_desktop_settings, save_workspace_config, seal_session_ui, set_active_env, spawn_pty, + start_agent, start_upload, write_pty, PtyState, UploadState, }; use kerberos::{acquire_kerberos_ticket, get_kerberos_status, renew_kerberos_ticket}; use std::collections::HashMap; @@ -86,10 +87,14 @@ pub fn run() { list_popout_sessions, reattach_pty, claim_popout_session, + attach_pty_output, dock_popout_session, check_popout_main_overlap, focus_terminal_window, open_terminal_window, + ack_pty, + redraw_pty, + read_primary_selection, count_active_sessions, close_pty, close_all_ptys, diff --git a/desktop/e2e/fixtures/app.ts b/desktop/e2e/fixtures/app.ts index 83fcae9..c0602bb 100644 --- a/desktop/e2e/fixtures/app.ts +++ b/desktop/e2e/fixtures/app.ts @@ -52,6 +52,8 @@ export type MockOptions = { type MockWindow = Window & { __IPC_CALLS(cmd: string): unknown[]; __MOCK_RESET(): void; + __MOCK_PTY_OUTPUT(sessionId: string, data: string): boolean; + __MOCK_EMIT(event: string, payload: unknown): void; }; const mockWindow = () => window as unknown as MockWindow; @@ -94,6 +96,10 @@ export type AppFixture = { openPalette(): Promise; /** Switch views directly through the app store (fast path). */ setActiveTab(tab: string): Promise; + /** Send PTY output to a session via its recorded output channel. */ + ptyOutput(sessionId: string, data: string): Promise; + /** Send a mock backend event to all listeners. */ + mockEmit(event: string, payload: unknown): Promise; /** Clear the recorded IPC log. */ clearIpc(): Promise; /** Assert that no page errors / console errors were collected. */ @@ -188,8 +194,26 @@ export const test = base.extend({ if (!label) throw new Error(`unknown tab: ${tab}`); await app.nav(label); }, + async ptyOutput(sessionId, data) { + return page.evaluate( + ([sid, d]) => (window as unknown as MockWindow).__MOCK_PTY_OUTPUT(sid, d), + [sessionId, data] as const, + ); + }, + async mockEmit(event, payload) { + await page.evaluate( + ([ev, pl]) => { + const win: MockWindow = window as unknown as MockWindow; + win.__MOCK_EMIT(ev, pl); + }, + [event, payload] as const, + ); + }, async clearIpc() { - await page.evaluate(() => (window as unknown as MockWindow).__MOCK_RESET()); + await page.evaluate(() => { + const win: MockWindow = window as unknown as MockWindow; + win.__MOCK_RESET(); + }); }, async expectNoPageErrors() { const state = stateOf(); diff --git a/desktop/e2e/fixtures/tauri-mock.js b/desktop/e2e/fixtures/tauri-mock.js index fc3c16f..c362877 100644 --- a/desktop/e2e/fixtures/tauri-mock.js +++ b/desktop/e2e/fixtures/tauri-mock.js @@ -389,6 +389,10 @@ list_detached_sessions: () => window.__MOCK_DETACHED || [], list_popout_sessions: () => window.__MOCK_POPOUTS || [], claim_popout_session: (a) => ({ session_id: a && a.sessionId, connection_name: "gpu-trainer", buffered_output: "researcher@gpu-a100:~$ nvidia-smi\r\n" }), + ack_pty: () => null, + redraw_pty: () => null, + attach_pty_output: (a) => ({ session_id: a && a.sessionId, connection_name: "gpu-trainer", buffered_output: "" }), + read_primary_selection: () => null, run_batch_command: async (a) => { await new Promise((r) => setTimeout(r, window.__BATCH_DELAY || 300)); const ids = (a && (a.connectionIds || a.connection_ids)) || []; @@ -504,13 +508,90 @@ }, }; + const sessionChannels = new Map(); + + function isChannel(v) { + if (!v) return false; + if (typeof v === "string" && v.startsWith("__CHANNEL__:")) return true; + if (typeof v === "object") { + if (typeof v.toJSON === "function") { + try { + const json = v.toJSON(); + if (typeof json === "string" && json.startsWith("__CHANNEL__:")) return true; + } catch {} + } + if (typeof v.__TAURI_TO_IPC_KEY__ === "function") { + try { + const res = v.__TAURI_TO_IPC_KEY__(); + if (typeof res === "string" && res.startsWith("__CHANNEL__:")) return true; + } catch {} + } + } + return false; + } + + function getChannelId(v) { + if (!v) return null; + if (typeof v === "string" && v.startsWith("__CHANNEL__:")) { + return parseInt(v.slice("__CHANNEL__:".length), 10); + } + if (typeof v === "object") { + if (typeof v.id === "number") return v.id; + if (typeof v.toJSON === "function") { + try { + const json = v.toJSON(); + if (typeof json === "string" && json.startsWith("__CHANNEL__:")) { + return parseInt(json.slice("__CHANNEL__:".length), 10); + } + } catch {} + } + if (typeof v.__TAURI_TO_IPC_KEY__ === "function") { + try { + const res = v.__TAURI_TO_IPC_KEY__(); + if (typeof res === "string" && res.startsWith("__CHANNEL__:")) { + return parseInt(res.slice("__CHANNEL__:".length), 10); + } + } catch {} + } + } + return null; + } + + function sanitizeForLog(obj) { + if (obj === null || obj === undefined) return obj; + if (isChannel(obj)) return "__CHANNEL__"; + if (Array.isArray(obj)) return obj.map(sanitizeForLog); + if (typeof obj === "object") { + const copy = {}; + for (const [k, v] of Object.entries(obj)) { + copy[k] = sanitizeForLog(v); + } + return copy; + } + return obj; + } + window.__IPC_LOG = []; window.__IPC_CALLS = (cmd) => window.__IPC_LOG.filter((entry) => entry.cmd === cmd).map((entry) => entry.args); window.__MOCK_RESET = () => { window.__IPC_LOG.length = 0; + sessionChannels.clear(); + }; + window.__MOCK_PTY_OUTPUT = (sessionId, data) => { + const entry = sessionChannels.get(sessionId); + if (!entry) { + console.warn("[mock] no channel for session", sessionId); + return false; + } + const bytes = new TextEncoder().encode(data).length; + const rawMessage = { + index: entry.nextIndex++, + message: { data, bytes }, + }; + window.__TAURI_INTERNALS__.runCallback(entry.channelId, rawMessage); + return true; }; - // Tauri event delivery: `listen()` hands the backend a callback id, and // emitting replays the event through `runCallback`. `__MOCK_EMIT` stands in // for a Rust `emit` so tests (and the upload simulation) can push events. @@ -540,7 +621,14 @@ }, convertFileSrc: (p) => p, invoke: async (cmd, args) => { - window.__IPC_LOG.push({ cmd, args: args === undefined ? null : args }); + const sid = args && (args.sessionId || args.session_id); + if (sid && args && args.output) { + const channelId = getChannelId(args.output); + if (channelId != null) { + sessionChannels.set(sid, { channelId, nextIndex: 0 }); + } + } + window.__IPC_LOG.push({ cmd, args: args === undefined ? null : sanitizeForLog(args) }); if (cmd === "plugin:event|listen") { const event = args && args.event; const handler = args && args.handler; diff --git a/desktop/e2e/terminals.spec.ts b/desktop/e2e/terminals.spec.ts index 614e203..5d8aa3f 100644 --- a/desktop/e2e/terminals.spec.ts +++ b/desktop/e2e/terminals.spec.ts @@ -1,5 +1,11 @@ -import { test, expect } from "./fixtures/app"; - +import { test, expect, type Page, type AppFixture } from "./fixtures/app"; + +async function connect(app: AppFixture, page: Page, host: string): Promise { + await page.getByTitle(`Connect to ${host}`).click(); + await expect.poll(async () => (await app.ipcCalls("spawn_pty")).length).toBeGreaterThan(0); + const calls = (await app.ipcCalls("spawn_pty")) as Array<{ sessionId: string }>; + return calls[calls.length - 1].sessionId; +} test.describe("terminals — empty state", () => { test("lists recent hosts and clicking one spawns a pty", async ({ app, page }) => { await app.gotoApp(); @@ -19,7 +25,7 @@ test.describe("terminals — empty state", () => { await expect .poll(async () => app.ipcCalls("spawn_pty")) - .toEqual([{ sessionId: expect.any(String), connectionName: "prod-api-01" }]); + .toEqual([{ sessionId: expect.any(String), connectionName: "prod-api-01", output: "__CHANNEL__" }]); // The empty state is replaced by a tab for the new session. const tabs = page.locator('[role="tab"]'); @@ -44,7 +50,7 @@ test.describe("terminals — sessions in the tab strip", () => { await expect .poll(async () => app.ipcCalls("spawn_pty")) - .toContainEqual({ sessionId: expect.any(String), connectionName: "staging-web" }); + .toContainEqual({ sessionId: expect.any(String), connectionName: "staging-web", output: "__CHANNEL__" }); const tabs = page.locator('[role="tab"]'); await expect(tabs).toHaveCount(1); @@ -57,7 +63,7 @@ test.describe("terminals — sessions in the tab strip", () => { await expect .poll(async () => app.ipcCalls("spawn_pty")) - .toContainEqual({ sessionId: expect.any(String), connectionName: "prod-api-02" }); + .toContainEqual({ sessionId: expect.any(String), connectionName: "prod-api-02", output: "__CHANNEL__" }); await expect(tabs).toHaveCount(2); const tabIds = await tabs.evaluateAll((els) => els.map((el) => el.getAttribute("data-tab-id") ?? "")); @@ -185,7 +191,7 @@ test.describe("terminals — detached sessions", () => { await dialog.getByRole("button", { name: "Reattach" }).click(); - await expect.poll(async () => app.ipcCalls("reattach_pty")).toEqual([{ sessionId: "det-1" }]); + await expect.poll(async () => app.ipcCalls("reattach_pty")).toEqual([{ sessionId: "det-1", output: "__CHANNEL__" }]); // A successful reattach closes the manager (the session is now a tab). await expect(dialog).toBeHidden(); @@ -219,12 +225,6 @@ test.describe("terminals — detached sessions", () => { }); test.describe("terminals — connect progress and failure", () => { - async function connect(app: any, page: any, host: string) { - await page.getByTitle(`Connect to ${host}`).click(); - await expect.poll(async () => (await app.ipcCalls("spawn_pty")).length).toBeGreaterThan(0); - const calls = (await app.ipcCalls("spawn_pty")) as Array<{ sessionId: string }>; - return calls[calls.length - 1].sessionId; - } test("shows Connecting until ssh prints something, and Esc cancels", async ({ app, page }) => { await app.gotoApp(); @@ -242,7 +242,7 @@ test.describe("terminals — connect progress and failure", () => { await app.gotoApp(); await app.nav("Terminals"); const id = await connect(app, page, "prod-api-01"); - await page.evaluate((sid) => (window as any).__MOCK_EMIT("pty-output", { session_id: sid, data: "Last login\r\n" }), id); + await app.ptyOutput(id, "Last login\r\n"); await expect(page.getByRole("status").filter({ hasText: "Connecting to" })).toBeHidden(); }); @@ -250,12 +250,9 @@ test.describe("terminals — connect progress and failure", () => { await app.gotoApp(); await app.nav("Terminals"); const id = await connect(app, page, "prod-api-01"); - await page.evaluate((sid) => { - const emit = (window as any).__MOCK_EMIT; - emit("pty-output", { session_id: sid, data: "ssh: connect to host x port 22: Connection refused\r\n" }); - emit("pty-exit-status", { session_id: sid, exit_code: 255 }); - emit("pty-exit", sid); - }, id); + await app.ptyOutput(id, "ssh: connect to host x port 22: Connection refused\r\n"); + await app.mockEmit("pty-exit-status", { session_id: id, exit_code: 255 }); + await app.mockEmit("pty-exit", id); const alert = page.getByRole("alert").filter({ hasText: "refused the connection" }); await expect(alert).toBeVisible(); @@ -303,3 +300,204 @@ test.describe("terminals — New session launcher", () => { await expect(page.getByRole("option", { name: /gpu-trainer/ })).toBeVisible(); }); }); + +test.describe("terminals — channel output, signals and interactions", () => { + test("output written via the channel shows in the terminal and ack_pty is called with byte count", async ({ + app, + page, + }) => { + await app.gotoApp(); + await app.nav("Terminals"); + const id = await connect(app, page, "prod-api-01"); + const testOutput = "Hello from bssh PTY channel!\r\n"; + const expectedBytes = new TextEncoder().encode(testOutput).length; + + await app.ptyOutput(id, testOutput); + await expect.poll(async () => { + const acks = (await app.ipcCalls("ack_pty")) as Array<{ sessionId: string; bytes: number }>; + return acks.filter((a) => a.sessionId === id).reduce((sum, a) => sum + a.bytes, 0); + }).toBe(expectedBytes); + + // Connecting status disappears once output arrives + await expect(page.getByRole("status").filter({ hasText: "Connecting to" })).toBeHidden(); + + // Find in terminal matches the output text + await page.getByRole("button", { name: "Find in terminal" }).click(); + const searchInput = page.getByPlaceholder("Find in scrollback"); + await expect(searchInput).toBeVisible(); + await searchInput.fill("bssh PTY channel"); + + // Right click terminal to verify text was found and selected (Copy is enabled) + const terminal = page.locator(".xterm").first(); + await terminal.click({ button: "right" }); + const menu = page.locator('[role="menu"][aria-label="Terminal actions"]'); + await expect(menu).toBeVisible(); + const copyItem = menu.getByRole("menuitem", { name: "Copy" }); + await expect(copyItem).toBeEnabled(); + await page.keyboard.press("Escape"); + }); + + test("OSC 2 title changes the tab label", async ({ app, page }) => { + await app.gotoApp(); + await app.nav("Terminals"); + const id = await connect(app, page, "prod-api-01"); + + const tabs = page.locator('[role="tab"]'); + await expect(tabs.first()).toContainText("prod-api-01"); + + await app.ptyOutput(id, "\x1b]2;user@host:~/dir\x07"); + + await expect(tabs.first()).toContainText("user@host:~/dir"); + }); + + test("a bell on a non-active tab shows the bell badge, cleared when the tab is activated", async ({ + app, + page, + }) => { + await app.gotoApp(); + await app.nav("Terminals"); + const id1 = await connect(app, page, "prod-api-01"); + await app.ptyOutput(id1, "ready 1\r\n"); + + // Open second session + await page.getByRole("button", { name: "New session" }).click(); + await page.getByRole("option", { name: /prod-api-02/ }).click(); + await expect.poll(async () => (await app.ipcCalls("spawn_pty")).length).toBeGreaterThanOrEqual(2); + const spawns = (await app.ipcCalls("spawn_pty")) as Array<{ sessionId: string }>; + const id2 = spawns[spawns.length - 1].sessionId; + await app.ptyOutput(id2, "ready 2\r\n"); + + const tabs = page.locator('[role="tab"]'); + await expect(tabs).toHaveCount(2); + + const tab1 = tabs.filter({ hasText: "prod-api-01" }); + const tab2 = tabs.filter({ hasText: "prod-api-02" }); + await expect(tab2).toHaveAttribute("aria-selected", "true"); + await expect(tab1).toHaveAttribute("aria-selected", "false"); + + const bellBadge = tab1.locator('[aria-label="Bell"]'); + await expect(bellBadge).toHaveCount(0); + + // Send bell to inactive tab 1 + await app.ptyOutput(id1, "\x07"); + + // Bell badge appears on tab 1 + await expect(bellBadge).toBeVisible(); + + // Activating tab 1 clears the bell badge + await tab1.click(); + await expect(tab1).toHaveAttribute("aria-selected", "true"); + await expect(bellBadge).toHaveCount(0); + }); + + test("OSC 9;4 renders a progress bar on the tab, removed with state 0", async ({ app, page }) => { + await app.gotoApp(); + await app.nav("Terminals"); + const id = await connect(app, page, "prod-api-01"); + await app.ptyOutput(id, "ready\r\n"); + + const tab = page.locator('[role="tab"]').first(); + const progressBar = tab.locator(".bg-accent"); + + await expect(progressBar).toHaveCount(0); + + // Send OSC 9;4: state 1 (set), value 40 + await app.ptyOutput(id, "\x1b]9;4;1;40\x07"); + + await expect(progressBar).toBeVisible(); + await expect(progressBar).toHaveAttribute("style", /width:\s*40%/); + + // Send OSC 9;4: state 0 (remove) + await app.ptyOutput(id, "\x1b]9;4;0\x07"); + + await expect(progressBar).toHaveCount(0); + }); + + test("right-click on the terminal opens context menu with Copy disabled when nothing is selected; Escape closes it", async ({ + app, + page, + }) => { + await app.gotoApp(); + await app.nav("Terminals"); + const id = await connect(app, page, "prod-api-01"); + await app.ptyOutput(id, "prompt$ \r\n"); + + const terminal = page.locator(".xterm").first(); + await expect(terminal).toBeVisible(); + + // Right-click opens context menu + await terminal.click({ button: "right" }); + + const menu = page.locator('[role="menu"][aria-label="Terminal actions"]'); + await expect(menu).toBeVisible(); + + const copyItem = menu.getByRole("menuitem", { name: "Copy" }); + await expect(copyItem).toBeVisible(); + await expect(copyItem).toBeDisabled(); + + // Escape closes the menu + await page.keyboard.press("Escape"); + await expect(menu).toBeHidden(); + }); + + test("pasting multi-line text opens confirmation dialog; Cancel cancels, Paste sends write_pty, single-line pastes directly", async ({ + app, + page, + }) => { + await app.gotoApp(); + await app.nav("Terminals"); + const id = await connect(app, page, "prod-api-01"); + await app.ptyOutput(id, "ready\r\n"); + + const textarea = page.locator("textarea.xterm-helper-textarea").first(); + await expect(textarea).toBeAttached(); + + const dispatchPaste = async (text: string) => { + await textarea.evaluate((el, pasteText) => { + const dt = new DataTransfer(); + dt.setData("text/plain", pasteText); + const event = new ClipboardEvent("paste", { + clipboardData: dt, + bubbles: true, + cancelable: true, + }); + el.dispatchEvent(event); + }, text); + }; + + // 1. Multi-line paste cancelled + await app.clearIpc(); + await dispatchPaste("echo first line\necho second line"); + + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByText(/Paste 2 lines\?/)).toBeVisible(); + + await dialog.getByRole("button", { name: "Cancel" }).click(); + await expect(dialog).toBeHidden(); + expect(await app.ipcCalls("write_pty")).toHaveLength(0); + + // 2. Multi-line paste confirmed + await dispatchPaste("echo first line\necho second line"); + await expect(dialog).toBeVisible(); + + await dialog.getByRole("button", { name: "Paste" }).click(); + await expect(dialog).toBeHidden(); + + await expect.poll(async () => { + const writes = (await app.ipcCalls("write_pty")) as Array<{ sessionId: string; data: string }>; + const text = writes.filter((w) => w.sessionId === id).map((w) => w.data).join(""); + return text.includes("echo first line") && text.includes("echo second line"); + }).toBe(true); + + // 3. Single-line paste (no dialog, sends write_pty directly) + await app.clearIpc(); + await dispatchPaste("echo single line\n"); + await expect(dialog).toHaveCount(0); + + await expect.poll(async () => { + const writes = (await app.ipcCalls("write_pty")) as Array<{ sessionId: string; data: string }>; + return writes.filter((w) => w.sessionId === id).map((w) => w.data).join(""); + }).toContain("echo single line"); + }); +}); diff --git a/desktop/package-lock.json b/desktop/package-lock.json index d446899..808809c 100644 --- a/desktop/package-lock.json +++ b/desktop/package-lock.json @@ -17,7 +17,9 @@ "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-image": "^0.9.0", + "@xterm/addon-progress": "^0.2.0", "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", @@ -1583,12 +1585,24 @@ "integrity": "sha512-oYWA8/QAr5/Emwl1xL7WCoOqeG3IZfpzEz/OVq1j4Oi9934TQmHiyubClikRf0D/jL3JNiNuz/Lsqx0kXQ02BA==", "license": "MIT" }, + "node_modules/@xterm/addon-progress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.2.0.tgz", + "integrity": "sha512-94uxxYyv30z3+6QIqJhCgALrzZfH7z2ounuZWQvb5Lp8dA7bWZmsUZGi5V7lKsq3Fyif4hTbaxq8YoCsQRtXgg==", + "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/addon-serialize": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.14.0.tgz", + "integrity": "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA==", + "license": "MIT" + }, "node_modules/@xterm/addon-unicode11": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.9.0.tgz", diff --git a/desktop/package.json b/desktop/package.json index c0c113b..d625dd1 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -23,7 +23,9 @@ "@xterm/addon-clipboard": "^0.2.0", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-image": "^0.9.0", + "@xterm/addon-progress": "^0.2.0", "@xterm/addon-search": "^0.16.0", + "@xterm/addon-serialize": "^0.14.0", "@xterm/addon-unicode11": "^0.9.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/addon-webgl": "^0.19.0", diff --git a/desktop/src/lib/components/TerminalsView.svelte b/desktop/src/lib/components/TerminalsView.svelte index e03b440..4864f54 100644 --- a/desktop/src/lib/components/TerminalsView.svelte +++ b/desktop/src/lib/components/TerminalsView.svelte @@ -358,9 +358,13 @@ > {#each terminalState.tabs as tab (tab.id)} {@const isActive = terminalState.activeTabId === tab.id} + {@const tabLabel = tab.remoteTitle?.trim() ? tab.remoteTitle : tab.name} + {@const tooltipTitle = tab.remoteTitle?.trim() && tab.remoteTitle.trim() !== tab.name + ? `${tab.name} — ${tab.remoteTitle.trim()}` + : tab.name}
(terminalState.activeTabId = tab.id)} @@ -379,10 +383,17 @@ role="tab" tabindex="0" aria-selected={isActive} - title="{tab.name} · {statusLabel(tab.status)} — drag out to open in a window" + title="{tooltipTitle} · {statusLabel(tab.status)} — drag out to open in a window" > - {tab.name} + {tabLabel} + {#if tab.bell} + + {/if}
+ {/if} {/each} @@ -628,7 +661,7 @@ class="absolute inset-0 box-border overflow-hidden p-2" class:hidden={terminalState.activeTabId !== tab.id} role="tabpanel" - aria-label={tab.name} + aria-label={tab.remoteTitle ?? tab.name} > {#if tab.showSearch}
@@ -843,4 +876,14 @@ opacity: 0.5; cursor: grabbing; } + .tab-progress-indeterminate { + animation: loader-slide 1.2s var(--ease-out, cubic-bezier(0.16, 1, 0.3, 1)) infinite; + } + @media (prefers-reduced-motion: reduce) { + .tab-progress-indeterminate { + animation: none !important; + width: 100% !important; + opacity: 0.6; + } + } diff --git a/desktop/src/lib/components/terminal/PasteConfirm.svelte b/desktop/src/lib/components/terminal/PasteConfirm.svelte new file mode 100644 index 0000000..b52d44a --- /dev/null +++ b/desktop/src/lib/components/terminal/PasteConfirm.svelte @@ -0,0 +1,178 @@ + + + diff --git a/desktop/src/lib/components/terminal/TerminalContextMenu.svelte b/desktop/src/lib/components/terminal/TerminalContextMenu.svelte new file mode 100644 index 0000000..90cfdb3 --- /dev/null +++ b/desktop/src/lib/components/terminal/TerminalContextMenu.svelte @@ -0,0 +1,292 @@ + + + + + + + diff --git a/desktop/src/lib/stores/popout-terminal.ts b/desktop/src/lib/stores/popout-terminal.ts index 6a91d68..684e486 100644 --- a/desktop/src/lib/stores/popout-terminal.ts +++ b/desktop/src/lib/stores/popout-terminal.ts @@ -1,27 +1,25 @@ import { tick } from "svelte"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke, Channel } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { Terminal } from "@xterm/xterm"; -import { FitAddon } from "@xterm/addon-fit"; import type { DesktopSettings } from "$lib/types"; import { getCurrentXtermTheme } from "$lib/utils/theme"; import { isTerminalFocused } from "$lib/utils/terminal-focus"; import { buildTerminalOptions } from "$lib/stores/terminal.svelte"; import { - attachXtermAddons, - attachXtermIo, - attachXtermKeyHandler, - attachXtermLinuxInputFix, - attachXtermPtyResize, - createOutputCoalescer, + createXtermSession, safeWrite, syncPtySize, waitForTerminalFont, + type PtyChunk, + type XtermSessionHandle, } from "$lib/utils/terminal-xterm"; +import { snapshotTerminal } from "$lib/utils/terminal-snapshot"; +import { requestAttentionIfUnfocused, setWindowProgress } from "$lib/utils/terminal-signals"; export interface PopoutTerminalHandle { connectionName: string; + getSnapshot: () => string | null; releaseUi: () => void; shutdown: (options?: { closeWindow?: boolean }) => Promise; } @@ -37,39 +35,41 @@ export async function initPopoutTerminal( options?: { onExit?: () => void }, ): Promise { const windowLabel = getCurrentWindow().label; - const info = await invoke("claim_popout_session", { sessionId, windowLabel }); - - let term: Terminal | null = null; - let fitAddon: FitAddon | null = null; - let resizeObserver: ResizeObserver | null = null; - let unlistenOutput: UnlistenFn | null = null; - let themeObserver: MutationObserver | null = null; - let compositionGuardCleanup: (() => void) | null = null; - let outputCoalescer: ReturnType | null = null; + let session: XtermSessionHandle | null = null; let closing = false; + let replayDone = false; + let earlyChunks: Array<{ data: string; bytes: number }> = []; - const fit = () => { - if (!term || !fitAddon) return; - try { - fitAddon.fit(); - } catch { - // Container may not have dimensions yet. + const outputChannel = new Channel((chunk) => { + if (closing) { + if (chunk.bytes > 0) { + invoke("ack_pty", { sessionId, bytes: chunk.bytes }).catch(() => {}); + } + return; } - }; + if (!replayDone || !session) { + earlyChunks.push(chunk); + return; + } + session.write(chunk.data, chunk.bytes); + }); - // Throttle refits to one per animation frame (window drags fire a storm - // of resize events). - let fitRaf: number | null = null; - const scheduleFit = () => { - if (fitRaf !== null) return; - fitRaf = requestAnimationFrame(() => { - fitRaf = null; - fit(); - }); - }; + const info = await invoke("claim_popout_session", { + sessionId, + windowLabel, + output: outputChannel, + }); + + let themeObserver: MutationObserver | null = null; const container = await waitForContainer("terminal-popout-root"); if (!container) { + for (const chunk of earlyChunks) { + if (chunk.bytes > 0) { + invoke("ack_pty", { sessionId, bytes: chunk.bytes }).catch(() => {}); + } + } + earlyChunks = []; throw new Error("Terminal container not found."); } @@ -87,133 +87,136 @@ export async function initPopoutTerminal( const termOptions = buildTerminalOptions(settings); // Measure cells with the real font, not a fallback. await waitForTerminalFont(String(termOptions.fontFamily), () => { - if (!term) return; - const family = term.options.fontFamily; - term.options.fontFamily = "monospace"; - term.options.fontFamily = family; - scheduleFit(); + if (!session) return; + const family = session.term.options.fontFamily; + session.term.options.fontFamily = "monospace"; + session.term.options.fontFamily = family; + session.fitAddon.fit(); }); - term = new Terminal(termOptions as ConstructorParameters[0]); - - fitAddon = new FitAddon(); - term.loadAddon(fitAddon); - attachXtermAddons(term); - term.open(container); - compositionGuardCleanup = attachXtermLinuxInputFix(container, term); - outputCoalescer = createOutputCoalescer(term); - term.focus(); - - container.addEventListener("mousedown", () => term!.focus()); + const handleZoomDelta = (delta: 1 | -1) => { + if (!session) return; + const current = session.term.options.fontSize ?? 13; + const clamped = Math.max(8, Math.min(32, current + delta)); + session.term.options.fontSize = clamped; + session.fitAddon.fit(); + }; - // ── Register the live output listener BEFORE writing the replay buffer ── - // The backend resumes emitting `pty-output` events as soon as the session - // is claimed; any event that arrives before this listener exists is lost. - // With the listener registered first, output that arrives while the replay - // is being written lands behind it in xterm's write queue, preserving - // ordering and dropping nothing. - let unlistenExit: UnlistenFn | null = null; - unlistenOutput = await listen(`pty-output:${sessionId}`, (event) => { - const data = typeof event.payload === "string" ? event.payload : (event.payload as any)?.data; - if (data) { - outputCoalescer?.push(data); - } + session = createXtermSession(container, sessionId, { + terminalOptions: termOptions, + getConnectionName: () => info.connection_name, + onZoom: handleZoomDelta, + signals: { + onTitle: (title) => { + const displayTitle = title ? title.trim() : info.connection_name; + getCurrentWindow().setTitle(`${displayTitle} — Bayesian SSH`).catch(() => {}); + }, + onBell: () => { + void requestAttentionIfUnfocused(); + }, + onProgress: (state, value) => { + void setWindowProgress(state, value); + }, + }, }); - unlistenExit = await listen(`pty-exit:${sessionId}`, () => { - outputCoalescer?.flush(); - if (term) { - safeWrite(term, "\n\x1b[1;33mSession disconnected.\x1b[0m\r\n"); - } - options?.onExit?.(); - }); - - if (info.buffered_output) { - safeWrite(term, info.buffered_output, () => { - term?.scrollToBottom(); - }); - } else { - requestAnimationFrame(() => term?.scrollToBottom()); - } - - attachXtermIo(sessionId, term); - // `fit()` only resizes on real grid changes; the PTY hears about those. - attachXtermPtyResize(sessionId, term); - attachXtermKeyHandler(term); + // Popout is always visible; enable WebGL + session.setVisible(true); // Dynamic theme mutation observer themeObserver = new MutationObserver(() => { - if (term) { - term.options.theme = getCurrentXtermTheme(); + if (session) { + session.term.options.theme = getCurrentXtermTheme(); } }); themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); - // Ctrl + Mouse Wheel zoom - container.addEventListener("wheel", (e) => { - if (e.ctrlKey && term) { - e.preventDefault(); - const current = term.options.fontSize ?? 13; - const newSize = e.deltaY < 0 ? current + 1 : current - 1; - const clamped = Math.max(8, Math.min(32, newSize)); - term.options.fontSize = clamped; - scheduleFit(); - } - }, { passive: false }); - // Keyboard shortcut zoom — skip when xterm has focus so vim/nano keys aren't stolen const handleKeydown = (e: KeyboardEvent) => { - if (isTerminalFocused() || !term) return; - const current = term.options.fontSize ?? 13; + if (isTerminalFocused() || !session) return; if (e.ctrlKey && (e.key === "=" || e.key === "+")) { e.preventDefault(); - const clamped = Math.max(8, Math.min(32, current + 1)); - term.options.fontSize = clamped; - scheduleFit(); + handleZoomDelta(1); } else if (e.ctrlKey && e.key === "-") { e.preventDefault(); - const clamped = Math.max(8, Math.min(32, current - 1)); - term.options.fontSize = clamped; - scheduleFit(); + handleZoomDelta(-1); } else if (e.ctrlKey && e.key === "0") { e.preventDefault(); const resetSize = settings?.terminal_font_size ?? 13; - term.options.fontSize = resetSize; - scheduleFit(); + session.term.options.fontSize = resetSize; + session.fitAddon.fit(); } }; window.addEventListener("keydown", handleKeydown); - resizeObserver = new ResizeObserver(() => scheduleFit()); - resizeObserver.observe(container); + let unlistenExit: UnlistenFn | null = null; + unlistenExit = await listen(`pty-exit:${sessionId}`, () => { + void setWindowProgress(0, 0); + if (session) { + safeWrite(session.term, "\n\x1b[1;33mSession disconnected.\x1b[0m\r\n"); + } + options?.onExit?.(); + }); + + const flushEarlyChunks = () => { + replayDone = true; + if (session) { + for (const chunk of earlyChunks) { + session.write(chunk.data, chunk.bytes); + } + } else { + for (const chunk of earlyChunks) { + if (chunk.bytes > 0) { + invoke("ack_pty", { sessionId, bytes: chunk.bytes }).catch(() => {}); + } + } + } + earlyChunks = []; + }; + requestAnimationFrame(() => requestAnimationFrame(() => { - fit(); + if (!session) { + flushEarlyChunks(); + return; + } + session.fitAddon.fit(); // The PTY still has the size of the view it came from. - if (term) syncPtySize(sessionId, term); + syncPtySize(sessionId, session.term); + session.term.focus(); + + if (info.buffered_output) { + safeWrite(session.term, info.buffered_output, () => { + session?.term.scrollToBottom(); + flushEarlyChunks(); + invoke("redraw_pty", { sessionId }).catch(() => {}); + }); + } else { + requestAnimationFrame(() => { + session?.term.scrollToBottom(); + flushEarlyChunks(); + invoke("redraw_pty", { sessionId }).catch(() => {}); + }); + } }), ); const releaseUi = () => { + closing = true; + void setWindowProgress(0, 0); window.removeEventListener("keydown", handleKeydown); - compositionGuardCleanup?.(); - compositionGuardCleanup = null; themeObserver?.disconnect(); themeObserver = null; - unlistenOutput?.(); - unlistenOutput = null; unlistenExit?.(); unlistenExit = null; - resizeObserver?.disconnect(); - resizeObserver = null; - outputCoalescer?.dispose(); - outputCoalescer = null; - if (fitRaf !== null) { - cancelAnimationFrame(fitRaf); - fitRaf = null; + for (const chunk of earlyChunks) { + if (chunk.bytes > 0) { + invoke("ack_pty", { sessionId, bytes: chunk.bytes }).catch(() => {}); + } } - term?.dispose(); - term = null; + earlyChunks = []; + session?.dispose(); + session = null; }; const shutdown = async (options?: { closeWindow?: boolean }) => { @@ -232,14 +235,21 @@ export async function initPopoutTerminal( return { connectionName: info.connection_name, + getSnapshot: () => { + return session ? snapshotTerminal(session.term) : null; + }, releaseUi, shutdown, }; } -export async function dockPopoutToMain(sessionId: string): Promise { +export async function dockPopoutToMain(sessionId: string, snapshot?: string | null): Promise { const windowLabel = getCurrentWindow().label; - await invoke("dock_popout_session", { sessionId, windowLabel }); + await invoke("dock_popout_session", { + sessionId, + windowLabel, + snapshot: snapshot ?? null, + }); } async function waitForContainer(elementId: string): Promise { diff --git a/desktop/src/lib/stores/terminal.svelte.ts b/desktop/src/lib/stores/terminal.svelte.ts index 93dc79b..50c9fc5 100644 --- a/desktop/src/lib/stores/terminal.svelte.ts +++ b/desktop/src/lib/stores/terminal.svelte.ts @@ -1,6 +1,7 @@ import { tick } from "svelte"; -import { invoke } from "@tauri-apps/api/core"; +import { invoke, Channel } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { getCurrentWindow } from "@tauri-apps/api/window"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; @@ -9,20 +10,19 @@ import { ensureKerberosForConnection } from "$lib/stores/kerberos.svelte"; import { notify } from "$lib/stores/notifications.svelte"; import { getCurrentXtermTheme } from "$lib/utils/theme"; import { - attachXtermAddons, - attachXtermIo, - attachXtermKeyHandler, - attachXtermLinuxInputFix, - attachXtermMouseHandlers, - attachXtermPtyResize, - createOutputCoalescer, + createXtermSession, safeWrite, syncPtySize, - terminalShortcutId, waitForTerminalFont, - type OutputCoalescer, + type PtyChunk, + type XtermSessionHandle, } from "$lib/utils/terminal-xterm"; - +import { snapshotTerminal } from "$lib/utils/terminal-snapshot"; +import { + requestAttentionIfUnfocused, + setWindowProgress, + type ProgressState, +} from "$lib/utils/terminal-signals"; type SettingsGetter = () => DesktopSettings | undefined; let _settingsGetter: SettingsGetter | null = null; @@ -116,18 +116,20 @@ export interface TerminalTab { /** Live xterm instance (non-reactive lookup; check `ready` in templates). */ readonly term: Terminal | undefined; readonly searchAddon: SearchAddon | undefined; + remoteTitle: string | null; + bell: boolean; + progress: { state: ProgressState; value: number } | null; } /** Non-reactive per-tab runtime: xterm objects, buffers and cleanups. */ interface TabRuntime { + session?: XtermSessionHandle; term?: Terminal; fitAddon?: FitAddon; searchAddon?: SearchAddon; - coalescer?: OutputCoalescer; /** Output received while the tab is hidden or still being created. */ - pending: string[]; + pending: Array<{ data: string; bytes: number }>; pendingBytes: number; - cleanups: Array<() => void>; fitRaf: number | null; /** Cursor blink turned off while the Terminals view is hidden. */ blinkSuspended: boolean; @@ -147,6 +149,9 @@ class TabState implements TerminalTab { readonly conn: Connection | undefined; connectStartedAt = $state(Date.now()); failure = $state.raw(null); + remoteTitle = $state(null); + bell = $state(false); + progress = $state<{ state: ProgressState; value: number } | null>(null); constructor(id: string, name: string, connectionName: string, status: TerminalTabStatus, conn?: Connection) { this.id = id; @@ -250,7 +255,6 @@ let activeSessionCount = $state(0); let activeTabId = $state(null); let listenersReady = false; let listenersInFlight: Promise | null = null; -let unlistenOutput: UnlistenFn | null = null; let unlistenExit: UnlistenFn | null = null; let unlistenExitStatus: UnlistenFn | null = null; /** Exit code of a finished session; arrives just before its `pty-exit`. */ @@ -280,14 +284,12 @@ function findDetachedSession(sessionId: string): DetachedSession | undefined { const MAX_PENDING_OUTPUT_BYTES = 256 * 1024; function flushRuntime(rt: TabRuntime) { - if (!rt.term || rt.pending.length === 0) return; - const combined = rt.pending.length === 1 ? rt.pending[0] : rt.pending.join(""); + if (!rt.session || rt.pending.length === 0) return; + const chunks = rt.pending; rt.pending = []; rt.pendingBytes = 0; - if (rt.coalescer) { - rt.coalescer.push(combined); - } else { - safeWrite(rt.term, combined); + for (const chunk of chunks) { + rt.session.write(chunk.data, chunk.bytes); } } @@ -298,10 +300,35 @@ export function flushPendingTabOutput(tab: Pick) { export function setActiveTab(tabId: string | null) { if (activeTabId === tabId) return; + + if (activeTabId) { + const oldRt = runtimes.get(activeTabId); + oldRt?.session?.setVisible(false); + } + activeTabId = tabId; - if (!tabId) return; + if (!tabId) { + void setWindowProgress(0, 0); + return; + } + + const tab = findTab(tabId); + if (tab) { + tab.bell = false; + if (tab.progress) { + void setWindowProgress(tab.progress.state, tab.progress.value); + } else { + void setWindowProgress(0, 0); + } + } + const rt = runtimes.get(tabId); if (!rt) return; + + if (terminalsVisible) { + rt.session?.setVisible(true); + } + flushRuntime(rt); if (rt.term) { requestAnimationFrame(() => { @@ -337,18 +364,30 @@ function setBlinkSuspended(rt: TabRuntime, suspend: boolean) { export function setTerminalsVisible(visible: boolean) { if (terminalsVisible === visible) return; terminalsVisible = visible; - for (const rt of runtimes.values()) setBlinkSuspended(rt, !visible); + for (const rt of runtimes.values()) { + setBlinkSuspended(rt, !visible); + if (!visible) { + rt.session?.setVisible(false); + } + } if (!visible || !activeTabId) return; const rt = runtimes.get(activeTabId); - if (rt) flushRuntime(rt); + if (rt) { + rt.session?.setVisible(true); + flushRuntime(rt); + scheduleFit(activeTabId); + } } -function deliverPtyOutput(sessionId: string, data: string) { - if (!data) return; - // Cheap ownership guard: sessions shown elsewhere (pop-out windows, - // detached) have no runtime in this window. +function deliverPtyOutput(sessionId: string, data: string, bytes: number) { + if (!data && bytes <= 0) return; const rt = runtimes.get(sessionId); - if (!rt) return; + if (!rt) { + if (bytes > 0) { + invoke("ack_pty", { sessionId, bytes }).catch(() => {}); + } + return; + } // ssh's first output (banner, password prompt, remote prompt) means it is // past connecting; its text also explains a failure. @@ -358,26 +397,22 @@ function deliverPtyOutput(sessionId: string, data: string) { rt.recentOutput = (rt.recentOutput + stripTerminalControls(data)).slice(-4000); } - if (terminalsVisible && sessionId === activeTabId && rt.coalescer) { - // Anything buffered while the tab was hidden must land before new output. + if (rt.session) { if (rt.pending.length > 0) flushRuntime(rt); - rt.coalescer.push(data); + rt.session.write(data, bytes); return; } - // Background tab (or terminal still being created): buffer in memory - // without triggering xterm redraws / layout computations. - rt.pending.push(data); - rt.pendingBytes += data.length; - if (rt.pendingBytes <= MAX_PENDING_OUTPUT_BYTES) return; - if (rt.coalescer) { - // Hand a busy hidden tab's output to xterm instead of silently dropping - // scrollback (and splitting escape sequences) once the buffer is full. - flushRuntime(rt); - return; - } - while (rt.pending.length > 1 && rt.pendingBytes > MAX_PENDING_OUTPUT_BYTES) { - rt.pendingBytes -= rt.pending.shift()!.length; + // Terminal still being created: buffer in memory until session mounts. + rt.pending.push({ data, bytes }); + rt.pendingBytes += bytes; + + while (rt.pending.length > 0 && rt.pendingBytes > MAX_PENDING_OUTPUT_BYTES) { + const dropped = rt.pending.shift()!; + rt.pendingBytes -= dropped.bytes; + if (dropped.bytes > 0) { + invoke("ack_pty", { sessionId, bytes: dropped.bytes }).catch(() => {}); + } } } @@ -422,10 +457,11 @@ function scheduleFit(tabId: string) { } function createRuntime(tabId: string): TabRuntime { + const existing = runtimes.get(tabId); + if (existing) return existing; const rt: TabRuntime = { pending: [], pendingBytes: 0, - cleanups: [], fitRaf: null, blinkSuspended: false, recentOutput: "", @@ -439,16 +475,14 @@ function disposeRuntime(tabId: string) { if (!rt) return; runtimes.delete(tabId); if (rt.fitRaf !== null) cancelAnimationFrame(rt.fitRaf); - rt.coalescer?.dispose(); - for (const cleanup of rt.cleanups) { - try { - cleanup(); - } catch { - // Best effort. + for (const chunk of rt.pending) { + if (chunk.bytes > 0) { + invoke("ack_pty", { sessionId: tabId, bytes: chunk.bytes }).catch(() => {}); } } - rt.term?.dispose(); rt.pending = []; + rt.pendingBytes = 0; + rt.session?.dispose(); } /** Re-measure open terminals when the font finished loading after timeout. */ @@ -479,49 +513,55 @@ async function openTerminalInstance( return null; } - const term = new Terminal(termOptions as ConstructorParameters[0]); - const fitAddon = new FitAddon(); - term.loadAddon(fitAddon); - const { searchAddon } = attachXtermAddons(term); - - term.open(container); - const outputCoalescer = createOutputCoalescer(term); - - rt.term = term; - rt.fitAddon = fitAddon; - rt.searchAddon = searchAddon; - rt.coalescer = outputCoalescer; - rt.cleanups.push(attachXtermLinuxInputFix(container, term)); - rt.cleanups.push(attachXtermMouseHandlers(container, term)); - - attachXtermIo(tabId, term); - attachXtermPtyResize(tabId, term); - - const observer = new ResizeObserver(() => scheduleFit(tabId)); - observer.observe(container); - rt.cleanups.push(() => observer.disconnect()); - - const onMouseDown = () => term.focus(); - const onWheel = (e: WheelEvent) => { - // Ctrl / Cmd + wheel zoom. - if (!(e.ctrlKey || e.metaKey)) return; - e.preventDefault(); - updateTerminalFontSize(terminalFontSize + (e.deltaY < 0 ? 1 : -1)); - }; - container.addEventListener("mousedown", onMouseDown); - container.addEventListener("wheel", onWheel, { passive: false }); - rt.cleanups.push(() => { - container.removeEventListener("mousedown", onMouseDown); - container.removeEventListener("wheel", onWheel); - }); + const tab = findTab(tabId); - attachXtermKeyHandler( - term, - () => { + const session = createXtermSession(container, tabId, { + terminalOptions: termOptions, + getConnectionName: () => tab?.name ?? "terminal", + onOpenSearch: () => { toggleTerminalSearch(tabId); }, - runTerminalShortcut, - ); + onShortcut: runTerminalShortcut, + onZoom: (delta) => { + updateTerminalFontSize(terminalFontSize + delta); + }, + signals: { + onTitle: (title) => { + if (tab) { + tab.remoteTitle = title ? title.trim() : null; + } + }, + onBell: () => { + const isTabActive = tabId === activeTabId && terminalsVisible; + getCurrentWindow().isFocused().then((isFocused) => { + if (tab && (!isTabActive || !isFocused)) { + tab.bell = true; + } + }).catch(() => { + if (tab && (!isTabActive || !document.hasFocus())) { + tab.bell = true; + } + }); + void requestAttentionIfUnfocused(); + }, + onProgress: (state, value) => { + if (tab) { + tab.progress = state === 0 ? null : { state, value }; + if (tabId === activeTabId) { + void setWindowProgress(state, value); + } + } + }, + }, + }); + + rt.session = session; + rt.term = session.term; + rt.fitAddon = session.fitAddon; + rt.searchAddon = session.searchAddon; + + const isVisible = tabId === activeTabId && terminalsVisible; + session.setVisible(isVisible); if (!terminalsVisible) setBlinkSuspended(rt, true); @@ -529,28 +569,28 @@ async function openTerminalInstance( // output that arrived while this terminal was being created (reattach // race), then the banner. if (options?.replay) { - safeWrite(term, options.replay, () => { - term.scrollToBottom(); + safeWrite(session.term, options.replay, () => { + session.term.scrollToBottom(); + invoke("redraw_pty", { sessionId: tabId }).catch(() => {}); }); } if (rt.pending.length > 0) flushRuntime(rt); if (options?.banner && !options?.replay) { - safeWrite(term, `${options.banner}\r\n`, () => { - term.scrollToBottom(); + safeWrite(session.term, `${options.banner}\r\n`, () => { + session.term.scrollToBottom(); }); } - const tab = findTab(tabId); if (tab) tab.ready = true; requestAnimationFrame(() => { scheduleFit(tabId); - term.focus(); + session.term.focus(); }); - return term; + return session.term; } async function sealSessionUi(sessionId: string): Promise { @@ -599,6 +639,7 @@ async function mountReattachedSession(info: ReattachSessionPayload): Promise { syncPtySize(tab.id, term); term.scrollToBottom(); + invoke("redraw_pty", { sessionId: tab.id }).catch(() => {}); }); }); } @@ -671,13 +712,6 @@ export async function initTerminalListeners(onHistoryChanged?: HistoryCallback) await syncPopoutSessions(); await syncActiveSessionCount(); - unlistenOutput = await listen("pty-output", (event) => { - const payload = event.payload as { session_id?: string; sessionId?: string; data: string }; - const sessionId = payload.session_id ?? payload.sessionId; - if (!sessionId) return; - deliverPtyOutput(sessionId, payload.data); - }); - unlistenExitStatus = await listen("pty-exit-status", (event) => { const payload = event.payload as { session_id: string; exit_code: number }; exitCodes.set(payload.session_id, payload.exit_code); @@ -709,17 +743,21 @@ export async function initTerminalListeners(onHistoryChanged?: HistoryCallback) // ssh gave up while connecting: keep the tab and explain, don't close it. if (rt) { flushRuntime(rt); - rt.coalescer?.flush(); } failConnect(tab, describeConnectFailure(rt?.recentOutput ?? "")); void syncActiveSessionCount(); return; } - if (tab) tab.status = "exited"; + if (tab) { + tab.status = "exited"; + tab.progress = null; + if (tab.id === activeTabId) { + void setWindowProgress(0, 0); + } + } if (rt) { // Flush pending output first so the disconnect notice lands after it. flushRuntime(rt); - rt.coalescer?.flush(); safeWrite(rt.term, "\n\x1b[1;33mSession disconnected.\x1b[0m\r\n"); } @@ -735,13 +773,25 @@ export async function initTerminalListeners(onHistoryChanged?: HistoryCallback) }); unlistenSessionDocked = await listen("session-docked", async (event) => { - const info = event.payload as ReattachSessionPayload; - removePopoutSession(info.session_id); + const payload = event.payload as { session_id?: string; sessionId?: string; connection_name?: string; connectionName?: string }; + const sessionId = payload.session_id ?? payload.sessionId; + if (!sessionId) return; + const connectionName = payload.connection_name ?? payload.connectionName ?? "Terminal"; + removePopoutSession(sessionId); + createRuntime(sessionId); try { + const outputChannel = new Channel((chunk) => { + deliverPtyOutput(sessionId, chunk.data, chunk.bytes); + }); + const info = await invoke("attach_pty_output", { + sessionId, + output: outputChannel, + }); await mountReattachedSession(info); - notify(`"${info.connection_name}" docked — output restored`, "success"); + notify(`"${info.connection_name || connectionName}" docked — output restored`, "success"); } catch (e: unknown) { - notify(`Failed to dock "${info.connection_name}": ${String(e)}`, "error"); + disposeRuntime(sessionId); + notify(`Failed to dock "${connectionName}": ${String(e)}`, "error"); } await syncActiveSessionCount(); }); @@ -764,13 +814,11 @@ export async function teardownTerminalListeners() { if (listenersInFlight) { await listenersInFlight.catch(() => {}); } - unlistenOutput?.(); unlistenExit?.(); unlistenExitStatus?.(); unlistenSessionClosed?.(); unlistenSessionDocked?.(); unlistenHistoryUpdated?.(); - unlistenOutput = null; unlistenExit = null; unlistenExitStatus = null; unlistenSessionClosed = null; @@ -895,10 +943,17 @@ async function startSession(tab: TabState, conn: Connection): Promise { tab.failure = null; tab.status = "connecting"; tab.connectStartedAt = Date.now(); - const rt = runtimes.get(tab.id); - if (rt) rt.recentOutput = ""; + const rt = createRuntime(tab.id); + rt.recentOutput = ""; try { - await invoke("spawn_pty", { sessionId: tab.id, connectionName: conn.name }); + const outputChannel = new Channel((chunk) => { + deliverPtyOutput(tab.id, chunk.data, chunk.bytes); + }); + await invoke("spawn_pty", { + sessionId: tab.id, + connectionName: conn.name, + output: outputChannel, + }); if (!findTab(tab.id)) { // Cancelled or closed while spawning. await invoke("close_pty", { sessionId: tab.id }).catch(() => {}); @@ -1068,9 +1123,11 @@ export async function detachTab(tabId: string): Promise { const tab = findTab(tabId); if (!tab) return; + const rt = runtimes.get(tabId); + const snapshot = rt?.term ? snapshotTerminal(rt.term) : null; await sealSessionUi(tabId); try { - await invoke("detach_pty", { sessionId: tabId }); + await invoke("detach_pty", { sessionId: tabId, snapshot }); } catch (e: unknown) { // Session is already gone — don't leave a ghost tab behind. cleanupTabUi(tabId); @@ -1096,9 +1153,11 @@ export async function popOutTab(tabId: string): Promise { const tab = findTab(tabId); if (!tab) return; + const rt = runtimes.get(tabId); + const snapshot = rt?.term ? snapshotTerminal(rt.term) : null; await sealSessionUi(tabId); try { - await invoke("open_terminal_window", { sessionId: tabId, title: tab.name }); + await invoke("open_terminal_window", { sessionId: tabId, title: tab.name, snapshot }); } catch (e: unknown) { // Window could not be created — keep the tab so the session stays usable. notify(`Failed to open terminal window: ${String(e)}`, "error"); @@ -1115,7 +1174,7 @@ export async function popOutDetachedSession(sessionId: string): Promise { if (!session) return; try { - await invoke("open_terminal_window", { sessionId, title: session.name }); + await invoke("open_terminal_window", { sessionId, title: session.name, snapshot: null }); } catch (e: unknown) { notify(`Failed to open terminal window: ${String(e)}`, "error"); return; @@ -1142,14 +1201,23 @@ export async function reattachSession(sessionId: string): Promise { if (!detached) { throw new Error("Detached session not found."); } - - const info = await invoke("reattach_pty", { sessionId }); - - removeDetachedSession(sessionId); - - await mountReattachedSession(info); - notify(`"${info.connection_name}" reattached — output restored`, "success"); - await syncActiveSessionCount(); + createRuntime(sessionId); + try { + const outputChannel = new Channel((chunk) => { + deliverPtyOutput(sessionId, chunk.data, chunk.bytes); + }); + const info = await invoke("reattach_pty", { + sessionId, + output: outputChannel, + }); + removeDetachedSession(sessionId); + await mountReattachedSession(info); + notify(`"${info.connection_name}" reattached — output restored`, "success"); + await syncActiveSessionCount(); + } catch (e: unknown) { + disposeRuntime(sessionId); + throw e; + } } export async function dockPopoutSession(sessionId: string): Promise { @@ -1161,6 +1229,7 @@ export async function dockPopoutSession(sessionId: string): Promise { await invoke("dock_popout_session", { sessionId, windowLabel: popout.windowLabel, + snapshot: null, }); } diff --git a/desktop/src/lib/utils/terminal-context-menu.ts b/desktop/src/lib/utils/terminal-context-menu.ts new file mode 100644 index 0000000..bc3d3d0 --- /dev/null +++ b/desktop/src/lib/utils/terminal-context-menu.ts @@ -0,0 +1,122 @@ +import type { Terminal } from "@xterm/xterm"; +import { mount, unmount } from "svelte"; +import TerminalContextMenu from "$lib/components/terminal/TerminalContextMenu.svelte"; + +export interface TerminalMenuActions { + paste(): void; // CORE supplies: reads clipboard then pasteIntoTerminal + find(): void; // opens the search bar + exportScrollback(): void; +} + +let activeMenuClose: (() => void) | null = null; + +/** + * contextmenu listener on container. If the app enabled mouse tracking + * (term.modes.mouseTrackingMode !== "none") and shift is NOT held: do nothing besides + * preventDefault (xterm forwards the click to the app). Otherwise preventDefault and open + * TerminalContextMenu (mounted imperatively into document.body) at the pointer, clamped to + * the viewport. Items: Copy (disabled without selection; uses copyTextWithFallback from + * terminal-xterm.ts), Paste, Select all, Find, Clear (term.clear()), Export scrollback. + * Keyboard: arrows/Home/End/Enter/Escape; closes on outside click, blur, scroll, resize. + * Returns cleanup (closes an open menu). + */ +export function attachTerminalContextMenu( + container: HTMLElement, + term: Terminal, + actions: TerminalMenuActions +): () => void { + if (!container || !term) return () => {}; + + let currentMenuClose: (() => void) | null = null; + + const openMenu = (x: number, y: number) => { + // Only one menu open at a time globally + if (activeMenuClose) { + activeMenuClose(); + activeMenuClose = null; + } + + let instance: Record | null = null; + let isClosed = false; + + const closeThisMenu = (refocusTerminal = false) => { + if (isClosed) return; + isClosed = true; + + if (activeMenuClose === currentMenuClose) { + activeMenuClose = null; + } + currentMenuClose = null; + + if (instance) { + try { + unmount(instance); + } catch (err) { + console.error("Failed to unmount TerminalContextMenu", err); + } + instance = null; + } + + if (refocusTerminal) { + try { + term.focus(); + } catch { + // terminal may be disposed + } + } + }; + + const closeWithoutRefocus = () => closeThisMenu(false); + activeMenuClose = closeWithoutRefocus; + currentMenuClose = closeWithoutRefocus; + + try { + instance = mount(TerminalContextMenu, { + target: document.body, + props: { + x, + y, + term, + actions, + onClose: closeThisMenu, + }, + }); + } catch (err) { + console.error("Failed to mount TerminalContextMenu", err); + closeThisMenu(false); + } + }; + + const onContextMenu = (e: MouseEvent) => { + const trackingMode = term.modes?.mouseTrackingMode; + const isMouseTracking = Boolean(trackingMode && trackingMode !== "none"); + + // If the app enabled mouse tracking and shift is NOT held: do nothing besides preventDefault + if (isMouseTracking && !e.shiftKey) { + e.preventDefault(); + return; + } + + e.preventDefault(); + + let clientX = e.clientX; + let clientY = e.clientY; + if (clientX === 0 && clientY === 0 && container) { + const rect = container.getBoundingClientRect(); + clientX = rect.left + Math.min(rect.width / 2, 80); + clientY = rect.top + Math.min(rect.height / 2, 80); + } + + openMenu(clientX, clientY); + }; + + container.addEventListener("contextmenu", onContextMenu); + + return () => { + container.removeEventListener("contextmenu", onContextMenu); + if (currentMenuClose) { + currentMenuClose(); + currentMenuClose = null; + } + }; +} diff --git a/desktop/src/lib/utils/terminal-paste.ts b/desktop/src/lib/utils/terminal-paste.ts new file mode 100644 index 0000000..2c1b298 --- /dev/null +++ b/desktop/src/lib/utils/terminal-paste.ts @@ -0,0 +1,192 @@ +import type { Terminal } from "@xterm/xterm"; +import { invoke } from "@tauri-apps/api/core"; +import { mount, unmount } from "svelte"; +import PasteConfirm from "$lib/components/terminal/PasteConfirm.svelte"; + +const IS_LINUX = + typeof navigator !== "undefined" && + /Linux/i.test(navigator.platform || navigator.userAgent || ""); + +let lastWindowSelection = ""; +let activeConfirmDialog = false; + +function trimOneTrailingNewline(text: string): string { + if (text.endsWith("\r\n")) { + return text.slice(0, -2); + } + if (text.endsWith("\n") || text.endsWith("\r")) { + return text.slice(0, -1); + } + return text; +} + +/** Paste text into the terminal; asks confirmation first when text contains a newline/CR + * (after trimming ONE trailing newline). Resolves true if pasted. */ +export async function pasteIntoTerminal(term: Terminal, text: string): Promise { + const trimmed = trimOneTrailingNewline(text); + const isMultiLine = /\r|\n/.test(trimmed); + + if (!isMultiLine) { + try { + term.paste(text); + try { + term.focus(); + } catch { + // Terminal might have been disposed + } + return true; + } catch { + return false; + } + } + + // Multi-line paste requires confirmation. + // Only one dialog at a time: reject concurrent requests. + if (activeConfirmDialog) { + return false; + } + activeConfirmDialog = true; + + const { promise, resolve } = Promise.withResolvers(); + let settled = false; + let component: Record | null = null; + + const closeDialog = (confirmed: boolean) => { + if (settled) return; + settled = true; + activeConfirmDialog = false; + + if (component) { + try { + void unmount(component); + } catch { + // Ignore unmount error if already unmounted + } + component = null; + } + + try { + term.focus(); + } catch { + // Terminal might have been disposed + } + + if (confirmed) { + try { + term.paste(text); + resolve(true); + } catch { + resolve(false); + } + } else { + resolve(false); + } + }; + + try { + component = mount(PasteConfirm, { + target: document.body, + props: { + text, + onConfirm: () => closeDialog(true), + onCancel: () => closeDialog(false), + }, + }); + } catch { + activeConfirmDialog = false; + try { + term.focus(); + } catch { + // Terminal might have been disposed + } + resolve(false); + } + + return promise; +} + +/** Capture-phase `paste` listener on the container: preventDefault + stopPropagation, read + * clipboardData text, route through pasteIntoTerminal. Returns cleanup. */ +export function attachPasteGuard(container: HTMLElement, term: Terminal): () => void { + const onPaste = (e: ClipboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + const text = + e.clipboardData?.getData("text/plain") || + e.clipboardData?.getData("text") || + ""; + + if (text) { + void pasteIntoTerminal(term, text); + } + }; + + container.addEventListener("paste", onPaste, true); + return () => { + container.removeEventListener("paste", onPaste, true); + }; +} + +/** Middle-click (auxclick button 1) paste. Linux: invoke("read_primary_selection"); if null, + * fall back to the most recent selection made in ANY xterm of this window (track via + * term.onSelectionChange; register each term). Other OS: navigator.clipboard.readText(). + * Routes through pasteIntoTerminal. Returns cleanup (also unregisters the selection tracker). */ +export function attachMiddleClickPaste(container: HTMLElement, term: Terminal): () => void { + // Track selections made in this terminal for window-wide fallback + const selectionDisposable = term.onSelectionChange(() => { + try { + const sel = term.getSelection(); + if (sel && sel.length > 0) { + lastWindowSelection = sel; + } + } catch { + // Terminal may be disposed + } + }); + + const onAuxClick = async (e: MouseEvent) => { + if (e.button !== 1) return; + e.preventDefault(); + e.stopPropagation(); + + if (IS_LINUX) { + let primaryText: string | null = null; + try { + primaryText = await invoke("read_primary_selection"); + } catch { + primaryText = null; + } + + if (primaryText) { + void pasteIntoTerminal(term, primaryText); + return; + } + + // Fallback to the most recent selection in any registered xterm of this window + if (lastWindowSelection) { + void pasteIntoTerminal(term, lastWindowSelection); + } + } else { + try { + const clipText = await navigator.clipboard.readText(); + if (clipText) { + void pasteIntoTerminal(term, clipText); + } + } catch { + // Swallow clipboard read errors + } + } + }; + + container.addEventListener("auxclick", onAuxClick); + + return () => { + container.removeEventListener("auxclick", onAuxClick); + try { + selectionDisposable.dispose(); + } catch { + // Ignore if already disposed + } + }; +} diff --git a/desktop/src/lib/utils/terminal-signals.ts b/desktop/src/lib/utils/terminal-signals.ts new file mode 100644 index 0000000..5d7fbf4 --- /dev/null +++ b/desktop/src/lib/utils/terminal-signals.ts @@ -0,0 +1,94 @@ +import type { Terminal } from "@xterm/xterm"; +import { ProgressAddon } from "@xterm/addon-progress"; +import { getCurrentWindow, ProgressBarStatus, UserAttentionType } from "@tauri-apps/api/window"; + +export type ProgressState = 0 | 1 | 2 | 3 | 4; // OSC 9;4: 0 remove, 1 set, 2 error, 3 indeterminate, 4 paused + +export interface TerminalSignalHandlers { + onTitle(title: string): void; + onBell(): void; + onProgress(state: ProgressState, value: number): void; // value 0-100 +} + +/** Loads @xterm/addon-progress and wires term.onTitleChange / term.onBell / progress.onChange. + * Returns cleanup disposing all of them. */ +export function attachXtermSignals(term: Terminal, handlers: TerminalSignalHandlers): () => void { + const progressAddon = new ProgressAddon(); + term.loadAddon(progressAddon); + + const dTitle = term.onTitleChange((title: string) => { + handlers.onTitle(title); + }); + + const dBell = term.onBell(() => { + handlers.onBell(); + }); + + const dProgress = progressAddon.onChange((event) => { + handlers.onProgress(event.state, event.value); + }); + + return () => { + try { + dTitle.dispose(); + } catch {} + try { + dBell.dispose(); + } catch {} + try { + dProgress.dispose(); + } catch {} + try { + progressAddon.dispose(); + } catch {} + }; +} + +/** Ask the OS for attention when the current window is not focused (Tauri + * getCurrentWindow().requestUserAttention(UserAttentionType.Informational)). */ +export async function requestAttentionIfUnfocused(): Promise { + try { + const win = getCurrentWindow(); + const focused = await win.isFocused(); + if (!focused) { + await win.requestUserAttention(UserAttentionType.Informational); + } + } catch { + // Swallow errors + } +} + +/** Mirror progress onto the OS taskbar/dock via getCurrentWindow().setProgressBar. */ +export async function setWindowProgress(state: ProgressState, value: number): Promise { + try { + let status: ProgressBarStatus; + switch (state) { + case 0: + status = ProgressBarStatus.None; + break; + case 1: + status = ProgressBarStatus.Normal; + break; + case 2: + status = ProgressBarStatus.Error; + break; + case 3: + status = ProgressBarStatus.Indeterminate; + break; + case 4: + status = ProgressBarStatus.Paused; + break; + default: + status = ProgressBarStatus.None; + break; + } + + const clamped = Math.max(0, Math.min(100, Math.round(Number.isFinite(value) ? value : 0))); + await getCurrentWindow().setProgressBar({ + status, + progress: clamped, + }); + } catch { + // Swallow errors + } +} diff --git a/desktop/src/lib/utils/terminal-snapshot.ts b/desktop/src/lib/utils/terminal-snapshot.ts new file mode 100644 index 0000000..5453221 --- /dev/null +++ b/desktop/src/lib/utils/terminal-snapshot.ts @@ -0,0 +1,40 @@ +import type { Terminal } from "@xterm/xterm"; +import { SerializeAddon } from "@xterm/addon-serialize"; + +const addonMap = new WeakMap(); + +/** + * Load SerializeAddon once per terminal (cache in a WeakMap). + * Safe against double-registration and never throws. + */ +export function enableSnapshot(term: Terminal): void { + try { + if (!term || addonMap.has(term)) return; + const addon = new SerializeAddon(); + term.loadAddon(addon); + addonMap.set(term, addon); + } catch (err) { + console.error("Failed to enable terminal snapshot", err); + } +} + +/** + * Serialize the full state incl. modes and alt buffer (scrollback capped to the + * terminal's scrollback option); returns null if the addon was never enabled or on error. + * Never throws. + */ +export function snapshotTerminal(term: Terminal): string | null { + try { + if (!term) return null; + const addon = addonMap.get(term); + if (!addon) return null; + return addon.serialize({ + scrollback: term.options?.scrollback, + excludeModes: false, + excludeAltBuffer: false, + }); + } catch (err) { + console.error("Failed to snapshot terminal", err); + return null; + } +} diff --git a/desktop/src/lib/utils/terminal-xterm.ts b/desktop/src/lib/utils/terminal-xterm.ts index 58d181f..7492ffe 100644 --- a/desktop/src/lib/utils/terminal-xterm.ts +++ b/desktop/src/lib/utils/terminal-xterm.ts @@ -1,6 +1,7 @@ import { invoke } from "@tauri-apps/api/core"; import { openUrl } from "@tauri-apps/plugin-opener"; -import type { Terminal } from "@xterm/xterm"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; import { WebLinksAddon } from "@xterm/addon-web-links"; import { SearchAddon } from "@xterm/addon-search"; import { @@ -15,7 +16,16 @@ import { Unicode11Addon } from "@xterm/addon-unicode11"; import { attachOrphanCompositionEndGuard } from "$lib/utils/terminal-composition-guard"; import { TERMINAL_SHORTCUTS, type Combo } from "$lib/shortcuts"; +import { enableSnapshot } from "$lib/utils/terminal-snapshot"; +import { attachXtermSignals, type TerminalSignalHandlers } from "$lib/utils/terminal-signals"; +import { attachTerminalContextMenu } from "$lib/utils/terminal-context-menu"; +import { + attachPasteGuard, + attachMiddleClickPaste, + pasteIntoTerminal, +} from "$lib/utils/terminal-paste"; +export type PtyChunk = { data: string; bytes: number }; function writePty(sessionId: string, data: string): void { invoke("write_pty", { sessionId, data }).catch((err) => { console.error("write_pty failed:", err); @@ -150,24 +160,7 @@ export function attachXtermAddons(term: Terminal): LoadedAddons { // Non-fatal if Unicode version 11 provider is already active. } - // WebGL renderer when available; xterm's built-in DOM renderer is the - // fallback (the canvas addon is not compatible with xterm 6). - let webglAddon: WebglAddon | undefined; - try { - const addon = new WebglAddon(); - addon.onContextLoss(() => { - // A lost context would leave a blank terminal; disposing the addon - // hands rendering back to the DOM renderer. - addon.dispose(); - if (webglAddon === addon) webglAddon = undefined; - }); - term.loadAddon(addon); - webglAddon = addon; - } catch { - // WebGL unavailable — stay on the DOM renderer. - } - - return { searchAddon, webLinksAddon, clipboardAddon, unicode11Addon, imageAddon, webglAddon }; + return { searchAddon, webLinksAddon, clipboardAddon, unicode11Addon, imageAddon }; } /** Export terminal scrollback buffer as a plain text string. */ @@ -323,11 +316,13 @@ export function attachXtermKeyHandler( const pasteText = () => { const doPaste = (text: string) => { - if (text) term.paste(text); + if (text) void pasteIntoTerminal(term, text); }; - navigator.clipboard.readText().then(doPaste).catch((err) => { - console.error("Failed to read from clipboard", err); - }); + if (typeof navigator.clipboard?.readText === "function") { + navigator.clipboard.readText().then(doPaste).catch((err) => { + console.error("Failed to read from clipboard", err); + }); + } }; /** Run `action` once per key press and keep the browser from acting too. */ @@ -395,26 +390,6 @@ export function attachXtermKeyHandler( }); } -/** Attach mouse listeners for middle-click paste and smooth selection handling. */ -export function attachXtermMouseHandlers( - container: HTMLElement, - term: Terminal, -): () => void { - const handleAuxClick = (e: MouseEvent) => { - if (e.button === 1) { // Middle click paste - e.preventDefault(); - navigator.clipboard.readText().then((text) => { - if (text) term.paste(text); - }).catch(() => {}); - } - }; - - container.addEventListener("auxclick", handleAuxClick); - return () => { - container.removeEventListener("auxclick", handleAuxClick); - }; -} - /** * Fix WebKitGTK+IBUS orphan composition events that break xterm input on Linux. * Returns a cleanup function — call before term.dispose(). @@ -436,7 +411,7 @@ export function attachXtermLinuxInputFix( export interface OutputCoalescer { /** Queue PTY output; it is written to the terminal on the next animation frame. */ - push: (data: string) => void; + push: (data: string, bytes?: number) => void; /** Immediately write everything queued so far (e.g. before a scroll). */ flush: () => void; /** Stop the flush timer and drop any queued output. */ @@ -460,9 +435,10 @@ const MAX_QUEUE_BYTES = 2 * 1024 * 1024; */ export function createOutputCoalescer( term: Terminal, - options?: { onFlush?: () => void }, + options?: { onFlush?: () => void; onAck?: (bytes: number) => void }, ): OutputCoalescer { let pending = ""; + let pendingBytes = 0; let rafId: number | null = null; let disposed = false; @@ -474,18 +450,28 @@ export function createOutputCoalescer( } }; - const writeChunked = (data: string) => { - if (disposed || !isTermAlive()) return; + const writeChunked = (data: string, bytes: number) => { + if (disposed || !isTermAlive()) { + if (bytes > 0) options?.onAck?.(bytes); + return; + } if (data.length <= MAX_WRITE_CHUNK) { - term.write(data, () => options?.onFlush?.()); + term.write(data, () => { + options?.onFlush?.(); + if (bytes > 0) options?.onAck?.(bytes); + }); return; } let offset = 0; const writeNext = () => { - if (disposed || !isTermAlive()) return; + if (disposed || !isTermAlive()) { + if (bytes > 0) options?.onAck?.(bytes); + return; + } const chunk = data.slice(offset, offset + MAX_WRITE_CHUNK); if (!chunk) { options?.onFlush?.(); + if (bytes > 0) options?.onAck?.(bytes); return; } offset += MAX_WRITE_CHUNK; @@ -499,16 +485,22 @@ export function createOutputCoalescer( cancelAnimationFrame(rafId); rafId = null; } - if (disposed || !pending) return; + if (disposed || (!pending && pendingBytes === 0)) return; const data = pending; + const bytes = pendingBytes; pending = ""; - writeChunked(data); + pendingBytes = 0; + writeChunked(data, bytes); }; return { - push(data: string) { - if (disposed || !data) return; + push(data: string, bytes = 0) { + if (disposed || (!data && bytes <= 0)) { + if (disposed && bytes > 0) options?.onAck?.(bytes); + return; + } pending += data; + pendingBytes += bytes; // If queue is dangerously large, flush immediately to prevent OOM. if (pending.length >= MAX_QUEUE_BYTES) { flush(); @@ -524,7 +516,11 @@ export function createOutputCoalescer( flush, dispose() { disposed = true; + if (pendingBytes > 0) { + options?.onAck?.(pendingBytes); + } pending = ""; + pendingBytes = 0; if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; @@ -532,3 +528,202 @@ export function createOutputCoalescer( }, }; } + +export interface XtermSessionOptions { + terminalOptions?: Record; + onOpenSearch?: () => void; + onShortcut?: (id: string) => boolean; + signals?: TerminalSignalHandlers; + onZoom?: (delta: 1 | -1) => void; + getConnectionName?: () => string; +} + +export interface XtermSessionHandle { + term: Terminal; + fitAddon: FitAddon; + searchAddon: SearchAddon; + setVisible: (visible: boolean) => void; + write: (data: string, bytes: number) => void; + dispose: () => void; +} + +/** + * Shared factory for xterm sessions (main tabs and popout windows). + * Wires addons, snapshotting, signals, context menu, paste guard, middle-click paste, + * Linux input fix, shortcuts, keyboard handling, zoom, resize and IO. + */ +export function createXtermSession( + container: HTMLElement, + sessionId: string, + options?: XtermSessionOptions, +): XtermSessionHandle { + const term = new Terminal(options?.terminalOptions as ConstructorParameters[0]); + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + + const { searchAddon } = attachXtermAddons(term); + enableSnapshot(term); + + term.open(container); + + let disposed = false; + let webglAddon: WebglAddon | undefined; + let webglFailed = false; + + const setVisible = (visible: boolean) => { + if (disposed) return; + if (visible) { + if (!webglAddon && !webglFailed) { + try { + const addon = new WebglAddon(); + addon.onContextLoss(() => { + addon.dispose(); + if (webglAddon === addon) { + webglAddon = undefined; + } + webglFailed = true; + }); + term.loadAddon(addon); + webglAddon = addon; + } catch { + webglFailed = true; + } + } + } else { + if (webglAddon) { + try { + webglAddon.dispose(); + } catch {} + webglAddon = undefined; + } + } + }; + + const cleanupLinuxFix = attachXtermLinuxInputFix(container, term); + + const cleanupContextMenu = attachTerminalContextMenu(container, term, { + paste: () => { + const doPaste = (text: string) => { + if (text) void pasteIntoTerminal(term, text); + }; + if (typeof navigator.clipboard?.readText === "function") { + navigator.clipboard.readText().then(doPaste).catch(() => {}); + } + }, + find: () => { + options?.onOpenSearch?.(); + }, + exportScrollback: () => { + const name = options?.getConnectionName ? options.getConnectionName() : "terminal"; + downloadTerminalScrollback(term, name); + }, + }); + + const cleanupPasteGuard = attachPasteGuard(container, term); + const cleanupMiddleClick = attachMiddleClickPaste(container, term); + + let cleanupSignals: (() => void) | undefined; + if (options?.signals) { + cleanupSignals = attachXtermSignals(term, { + onTitle: (title) => options.signals?.onTitle(title), + onBell: () => options.signals?.onBell(), + onProgress: (state, value) => options.signals?.onProgress(state, value), + }); + } + + attachXtermKeyHandler(term, options?.onOpenSearch, options?.onShortcut); + attachXtermPtyResize(sessionId, term); + attachXtermIo(sessionId, term); + + let fitRaf: number | null = null; + const scheduleFit = () => { + if (disposed || fitRaf !== null) return; + fitRaf = requestAnimationFrame(() => { + fitRaf = null; + if ( + disposed || + !container.isConnected || + container.offsetParent === null || + container.clientWidth === 0 || + container.clientHeight === 0 + ) { + return; + } + try { + fitAddon.fit(); + } catch {} + }); + }; + + const resizeObserver = new ResizeObserver(() => scheduleFit()); + resizeObserver.observe(container); + + const onMouseDown = () => { + try { + term.focus(); + } catch {} + }; + container.addEventListener("mousedown", onMouseDown); + + const onWheel = (e: WheelEvent) => { + if ((e.ctrlKey || e.metaKey) && options?.onZoom) { + e.preventDefault(); + const delta: 1 | -1 = e.deltaY < 0 ? 1 : -1; + options.onZoom(delta); + } + }; + container.addEventListener("wheel", onWheel, { passive: false }); + + const coalescer = createOutputCoalescer(term, { + onAck: (bytes) => { + if (bytes > 0) { + invoke("ack_pty", { sessionId, bytes }).catch(() => {}); + } + }, + }); + + const write = (data: string, bytes: number) => { + if (disposed) { + if (bytes > 0) { + invoke("ack_pty", { sessionId, bytes }).catch(() => {}); + } + return; + } + coalescer.push(data, bytes); + }; + + const dispose = () => { + if (disposed) return; + disposed = true; + + setVisible(false); + coalescer.dispose(); + + container.removeEventListener("wheel", onWheel); + container.removeEventListener("mousedown", onMouseDown); + resizeObserver.disconnect(); + if (fitRaf !== null) { + cancelAnimationFrame(fitRaf); + fitRaf = null; + } + + cleanupLinuxFix(); + cleanupContextMenu(); + cleanupPasteGuard(); + cleanupMiddleClick(); + cleanupSignals?.(); + + try { + term.dispose(); + } catch {} + }; + + return { + term, + fitAddon, + searchAddon, + setVisible, + write, + dispose, + }; +} diff --git a/desktop/src/routes/terminal/[sessionId]/+page.svelte b/desktop/src/routes/terminal/[sessionId]/+page.svelte index c5f538d..9804150 100644 --- a/desktop/src/routes/terminal/[sessionId]/+page.svelte +++ b/desktop/src/routes/terminal/[sessionId]/+page.svelte @@ -58,12 +58,12 @@ // Continue docking even if seal fails. } + const snapshot = handle.getSnapshot(); handle.releaseUi(); handle = null; - try { // Rust destroys this window after docking; no local destroy needed. - await dockPopoutToMain(sessionId); + await dockPopoutToMain(sessionId, snapshot); } catch (e: unknown) { loadError = String(e); closing = false;