Ctrl+J did nothing on a thread from a connected device, since it has no folder here. Its terminals now run their shells on the host, in the thread's folder there: TerminalOpen starts one, typed keys and resizes go over as TerminalInput and TerminalResize, and the shell's output comes back as TerminalOutput, which the client's emulator parses. A shell's exit closes the terminal as a local one does; closing it, or the client disconnecting, hangs up on the shell. When the connection drops, the terminal says so, and a host too old to answer is given up on after 15 seconds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Showing 9 changed files with 672 additions and 40 deletions.
| @@ -140,7 +140,7 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 140 | 140 | URL. When the agent opens a page while the panel is closed, a light on the globe says so. |
| 141 | 141 | - A terminal drawer under the conversation, like T3 Code's (Ctrl+J, or the panel toggle in |
| 142 | 142 | the chat header): each thread has its own terminals, which start in the thread's folder |
| 143 | - (its worktree when it has one). Drag the drawer's top edge to resize it. Split a terminal | |
| 143 | + (its worktree when it has one), on the device the thread runs on. Drag the drawer's top edge to resize it. Split a terminal | |
| 144 | 144 | side by side (Ctrl+D) or stacked (Ctrl+Shift+D), up to four per group, or open another |
| 145 | 145 | (Ctrl+N while a terminal has focus); with several, a sidebar lists the groups. Close one |
| 146 | 146 | with Ctrl+W or the trash button (after a confirmation); a terminal whose shell exits |
| @@ -172,7 +172,8 @@ Gruvbox, Rosé Pine, Solarized and Dracula. | ||
| 172 | 172 | - Remote connections, like T3 Code's environments: turn on **Allow remote connections** on |
| 173 | 173 | one device, and paste its pairing code (address and key) under Settings → Connections on |
| 174 | 174 | another. The host's projects and threads appear in the sidebar tagged with its name; open, |
| 175 | - continue or start threads there and they run on the host. The first time a device connects | |
| 175 | + continue or start threads there and they run on the host. Their terminal drawer (Ctrl+J) | |
| 176 | + runs its shells on the host too, in the thread's folder there. The first time a device connects | |
| 176 | 177 | with settings that differ from yours, a dialog asks whether to give it your settings or |
| 177 | 178 | leave its own as they are. The connection is a WebSocket guarded by the key but not |
| 178 | 179 | encrypted, so use it on trusted networks or over a VPN. |
| @@ -12,10 +12,14 @@ | ||
| 12 | 12 | //! environment settings in [`ServerMessage::Settings`], which clients can |
| 13 | 13 | //! change with [`ClientMessage::UpdateSettings`]. The Add project palette |
| 14 | 14 | //! browses and adds the host's folders with [`ClientMessage::ListFolders`] |
| 15 | - //! and [`ClientMessage::AddProject`]. | |
| 15 | + //! and [`ClientMessage::AddProject`]. A client's terminal drawer runs its | |
| 16 | + //! shells on the host: [`ClientMessage::TerminalOpen`] starts one in a | |
| 17 | + //! thread's folder there, keys go over as [`ClientMessage::TerminalInput`], | |
| 18 | + //! and the shell's output comes back as [`ServerMessage::TerminalOutput`]. | |
| 16 | 19 | |
| 17 | 20 | pub mod client; |
| 18 | 21 | pub mod server; |
| 22 | + pub mod shell; | |
| 19 | 23 | |
| 20 | 24 | use std::path::PathBuf; |
| 21 | 25 | |
| @@ -182,6 +186,15 @@ pub enum ClientMessage { | ||
| 182 | 186 | ListFolders { path: String }, |
| 183 | 187 | /// Adds a project on the host, cloning or creating it as asked. |
| 184 | 188 | AddProject { source: ProjectSource }, |
| 189 | + /// Starts a shell on the host in a thread's folder (the project's, for | |
| 190 | + /// a thread not created yet). `terminal` is the client's id for it. | |
| 191 | + /// Older hosts don't answer. | |
| 192 | + TerminalOpen { terminal: u64, thread: Option<String>, project: PathBuf, columns: u16, lines: u16 }, | |
| 193 | + /// What was typed in a terminal, base64. | |
| 194 | + TerminalInput { terminal: u64, data: String }, | |
| 195 | + TerminalResize { terminal: u64, columns: u16, lines: u16 }, | |
| 196 | + /// Hangs up on a terminal's shell. | |
| 197 | + TerminalClose { terminal: u64 }, | |
| 185 | 198 | } |
| 186 | 199 | |
| 187 | 200 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| @@ -202,6 +215,10 @@ pub enum ServerMessage { | ||
| 202 | 215 | Folders { path: String, folders: Vec<Folder>, truncated: bool, error: Option<String> }, |
| 203 | 216 | /// The project a client asked for is in the host's list now. |
| 204 | 217 | ProjectAdded { path: PathBuf }, |
| 218 | + /// A terminal's shell printed something, base64. | |
| 219 | + TerminalOutput { terminal: u64, data: String }, | |
| 220 | + /// A terminal's shell exited, or with `error`, couldn't start. | |
| 221 | + TerminalExited { terminal: u64, error: Option<String> }, | |
| 205 | 222 | } |
| 206 | 223 | |
| 207 | 224 | /// A new random key: 24 bytes, hex. |
| @@ -298,6 +315,15 @@ mod tests { | ||
| 298 | 315 | }; |
| 299 | 316 | let text = serde_json::to_string(&message).unwrap(); |
| 300 | 317 | assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); |
| 318 | + let message = | |
| 319 | + ClientMessage::TerminalOpen { terminal: 3, thread: None, project: "/p".into(), columns: 80, lines: 24 }; | |
| 320 | + let text = serde_json::to_string(&message).unwrap(); | |
| 321 | + assert_eq!(serde_json::from_str::<ClientMessage>(&text).unwrap(), message); | |
| 322 | + let text = serde_json::to_string(&ServerMessage::TerminalExited { terminal: 3, error: None }).unwrap(); | |
| 323 | + assert!(matches!( | |
| 324 | + serde_json::from_str::<ServerMessage>(&text).unwrap(), | |
| 325 | + ServerMessage::TerminalExited { terminal: 3, error: None } | |
| 326 | + )); | |
| 301 | 327 | let values = crate::settings::Settings::default().environment_values(); |
| 302 | 328 | let text = serde_json::to_string(&ServerMessage::Settings { values: values.clone() }).unwrap(); |
| 303 | 329 | match serde_json::from_str::<ServerMessage>(&text).unwrap() { |
| @@ -79,10 +79,12 @@ impl RemoteServer { | ||
| 79 | 79 | } |
| 80 | 80 | |
| 81 | 81 | pub fn send(&self, conn: u64, message: &ServerMessage) { |
| 82 | - let Ok(text) = serde_json::to_string(message) else { return }; | |
| 83 | - if let Some(outbox) = self.outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&conn) { | |
| 84 | - let _ = outbox.send(text); | |
| 85 | - } | |
| 82 | + self.sender(conn).send(message); | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// Sends to one client from any thread, for as long as it's connected. | |
| 86 | + pub fn sender(&self, conn: u64) -> ConnectionSender { | |
| 87 | + ConnectionSender { conn, outboxes: self.outboxes.clone() } | |
| 86 | 88 | } |
| 87 | 89 | |
| 88 | 90 | pub fn broadcast(&self, message: &ServerMessage) { |
| @@ -105,6 +107,22 @@ impl Drop for RemoteServer { | ||
| 105 | 107 | } |
| 106 | 108 | } |
| 107 | 109 | |
| 110 | + /// A way to one client's connection that works off the UI thread. | |
| 111 | + #[derive(Clone)] | |
| 112 | + pub struct ConnectionSender { | |
| 113 | + conn: u64, | |
| 114 | + outboxes: Arc<Mutex<HashMap<u64, mpsc::Sender<String>>>>, | |
| 115 | + } | |
| 116 | + | |
| 117 | + impl ConnectionSender { | |
| 118 | + pub fn send(&self, message: &ServerMessage) { | |
| 119 | + let Ok(text) = serde_json::to_string(message) else { return }; | |
| 120 | + if let Some(outbox) = self.outboxes.lock().unwrap_or_else(|poisoned| poisoned.into_inner()).get(&self.conn) { | |
| 121 | + let _ = outbox.send(text); | |
| 122 | + } | |
| 123 | + } | |
| 124 | + } | |
| 125 | + | |
| 108 | 126 | #[allow(clippy::too_many_arguments)] |
| 109 | 127 | fn serve( |
| 110 | 128 | conn: u64, |
| @@ -0,0 +1,234 @@ | ||
| 1 | + //! The host's side of a client's terminal: a shell in a pseudo-terminal on | |
| 2 | + //! this device, whose output goes straight to the client from its own | |
| 3 | + //! thread, without waiting on the UI. The client emulates the screen. | |
| 4 | + | |
| 5 | + use std::path::Path; | |
| 6 | + | |
| 7 | + /// What a shell does, for the connection to pass on. | |
| 8 | + pub enum ShellEvent { | |
| 9 | + Output(Vec<u8>), | |
| 10 | + /// The shell exited. | |
| 11 | + Exited, | |
| 12 | + } | |
| 13 | + | |
| 14 | + #[cfg(unix)] | |
| 15 | + pub use unix::HostShell; | |
| 16 | + | |
| 17 | + #[cfg(unix)] | |
| 18 | + mod unix { | |
| 19 | + use std::fs::File; | |
| 20 | + use std::io::{self, ErrorKind, Read as _, Write as _}; | |
| 21 | + use std::os::fd::AsRawFd as _; | |
| 22 | + use std::sync::{ | |
| 23 | + Arc, | |
| 24 | + atomic::{AtomicBool, Ordering}, | |
| 25 | + mpsc, | |
| 26 | + }; | |
| 27 | + use std::thread; | |
| 28 | + | |
| 29 | + use alacritty_terminal::event::{OnResize as _, WindowSize}; | |
| 30 | + use alacritty_terminal::tty; | |
| 31 | + | |
| 32 | + use super::{Path, ShellEvent}; | |
| 33 | + | |
| 34 | + /// How long the reader waits for output before checking whether to stop. | |
| 35 | + const WAIT_MS: i32 = 100; | |
| 36 | + /// The most one output message carries. | |
| 37 | + const CHUNK: usize = 64 * 1024; | |
| 38 | + | |
| 39 | + enum Input { | |
| 40 | + Bytes(Vec<u8>), | |
| 41 | + Resize(u16, u16), | |
| 42 | + } | |
| 43 | + | |
| 44 | + /// A running shell. Dropping it hangs up on the shell. | |
| 45 | + pub struct HostShell { | |
| 46 | + input: mpsc::Sender<Input>, | |
| 47 | + stop: Arc<AtomicBool>, | |
| 48 | + } | |
| 49 | + | |
| 50 | + impl HostShell { | |
| 51 | + /// Starts the user's shell in `cwd` with a screen of `columns` by | |
| 52 | + /// `lines`. Its output and exit go to `on_event`, on another thread. | |
| 53 | + pub fn spawn( | |
| 54 | + cwd: &Path, | |
| 55 | + columns: u16, | |
| 56 | + lines: u16, | |
| 57 | + on_event: impl Fn(ShellEvent) + Send + 'static, | |
| 58 | + ) -> io::Result<Self> { | |
| 59 | + Self::spawn_with(crate::terminal::shell_options(cwd), columns, lines, on_event) | |
| 60 | + } | |
| 61 | + | |
| 62 | + pub(in crate::remote::shell) fn spawn_with( | |
| 63 | + options: tty::Options, | |
| 64 | + columns: u16, | |
| 65 | + lines: u16, | |
| 66 | + on_event: impl Fn(ShellEvent) + Send + 'static, | |
| 67 | + ) -> io::Result<Self> { | |
| 68 | + let mut pty = tty::new(&options, window_size(columns, lines), 0)?; | |
| 69 | + let reader = pty.file().try_clone()?; | |
| 70 | + let writer = pty.file().try_clone()?; | |
| 71 | + let stop = Arc::new(AtomicBool::new(false)); | |
| 72 | + let (input, inbox) = mpsc::channel::<Input>(); | |
| 73 | + { | |
| 74 | + let stop = stop.clone(); | |
| 75 | + thread::Builder::new().name("remote-shell-out".into()).spawn(move || { | |
| 76 | + read_output(&reader, &stop, &on_event); | |
| 77 | + if !stop.load(Ordering::Relaxed) { | |
| 78 | + on_event(ShellEvent::Exited); | |
| 79 | + } | |
| 80 | + })?; | |
| 81 | + } | |
| 82 | + thread::Builder::new().name("remote-shell-in".into()).spawn(move || { | |
| 83 | + // Owns the PTY: it ends, and the shell with it, once the | |
| 84 | + // shell is dropped. | |
| 85 | + while let Ok(input) = inbox.recv() { | |
| 86 | + match input { | |
| 87 | + Input::Bytes(bytes) => { | |
| 88 | + if write_all(&writer, &bytes).is_err() { | |
| 89 | + break; | |
| 90 | + } | |
| 91 | + } | |
| 92 | + Input::Resize(columns, lines) => pty.on_resize(window_size(columns, lines)), | |
| 93 | + } | |
| 94 | + } | |
| 95 | + drop(pty); | |
| 96 | + })?; | |
| 97 | + Ok(Self { input, stop }) | |
| 98 | + } | |
| 99 | + | |
| 100 | + /// Sends bytes to the shell, as if typed. | |
| 101 | + pub fn write(&self, bytes: Vec<u8>) { | |
| 102 | + if !bytes.is_empty() { | |
| 103 | + let _ = self.input.send(Input::Bytes(bytes)); | |
| 104 | + } | |
| 105 | + } | |
| 106 | + | |
| 107 | + pub fn resize(&self, columns: u16, lines: u16) { | |
| 108 | + let _ = self.input.send(Input::Resize(columns, lines)); | |
| 109 | + } | |
| 110 | + } | |
| 111 | + | |
| 112 | + impl Drop for HostShell { | |
| 113 | + fn drop(&mut self) { | |
| 114 | + // The reader stops without reporting an exit; the writer's inbox | |
| 115 | + // closes with this, and it hangs up. | |
| 116 | + self.stop.store(true, Ordering::Relaxed); | |
| 117 | + } | |
| 118 | + } | |
| 119 | + | |
| 120 | + fn window_size(columns: u16, lines: u16) -> WindowSize { | |
| 121 | + WindowSize { num_lines: lines.max(1), num_cols: columns.max(2), cell_width: 8, cell_height: 17 } | |
| 122 | + } | |
| 123 | + | |
| 124 | + /// Waits until the PTY is ready for `events`, or the wait runs out. | |
| 125 | + fn ready(file: &File, events: i16, timeout_ms: i32) -> io::Result<bool> { | |
| 126 | + let mut poll = libc::pollfd { fd: file.as_raw_fd(), events, revents: 0 }; | |
| 127 | + // SAFETY: one valid pollfd for the duration of the call. | |
| 128 | + let result = unsafe { libc::poll(&mut poll, 1, timeout_ms) }; | |
| 129 | + if result < 0 { | |
| 130 | + let err = io::Error::last_os_error(); | |
| 131 | + return if err.kind() == ErrorKind::Interrupted { Ok(false) } else { Err(err) }; | |
| 132 | + } | |
| 133 | + Ok(result > 0) | |
| 134 | + } | |
| 135 | + | |
| 136 | + /// Passes the shell's output on until it exits (the PTY then reads as | |
| 137 | + /// closed) or the shell is dropped. | |
| 138 | + fn read_output(mut file: &File, stop: &AtomicBool, on_event: &impl Fn(ShellEvent)) { | |
| 139 | + let mut buf = vec![0u8; CHUNK]; | |
| 140 | + while !stop.load(Ordering::Relaxed) { | |
| 141 | + match ready(file, libc::POLLIN, WAIT_MS) { | |
| 142 | + Ok(true) => {} | |
| 143 | + Ok(false) => continue, | |
| 144 | + Err(_) => return, | |
| 145 | + } | |
| 146 | + let mut filled = 0; | |
| 147 | + // Take what's there now in one message, not one per read. | |
| 148 | + loop { | |
| 149 | + match file.read(&mut buf[filled..]) { | |
| 150 | + Ok(0) => break, | |
| 151 | + Ok(n) => filled += n, | |
| 152 | + Err(err) if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted) => break, | |
| 153 | + // EIO: the shell is gone. | |
| 154 | + Err(_) if filled == 0 => return, | |
| 155 | + Err(_) => break, | |
| 156 | + } | |
| 157 | + if filled == buf.len() || !matches!(ready(file, libc::POLLIN, 0), Ok(true)) { | |
| 158 | + break; | |
| 159 | + } | |
| 160 | + } | |
| 161 | + if filled == 0 { | |
| 162 | + // Readable but empty: closed. | |
| 163 | + return; | |
| 164 | + } | |
| 165 | + on_event(ShellEvent::Output(buf[..filled].to_vec())); | |
| 166 | + } | |
| 167 | + } | |
| 168 | + | |
| 169 | + /// Writes everything to the non-blocking PTY, waiting while it's full. | |
| 170 | + fn write_all(mut file: &File, mut bytes: &[u8]) -> io::Result<()> { | |
| 171 | + while !bytes.is_empty() { | |
| 172 | + match file.write(bytes) { | |
| 173 | + Ok(0) => return Err(ErrorKind::WriteZero.into()), | |
| 174 | + Ok(n) => bytes = &bytes[n..], | |
| 175 | + Err(err) if err.kind() == ErrorKind::WouldBlock => { | |
| 176 | + ready(file, libc::POLLOUT, WAIT_MS)?; | |
| 177 | + } | |
| 178 | + Err(err) if err.kind() == ErrorKind::Interrupted => {} | |
| 179 | + Err(err) => return Err(err), | |
| 180 | + } | |
| 181 | + } | |
| 182 | + Ok(()) | |
| 183 | + } | |
| 184 | + } | |
| 185 | + | |
| 186 | + /// Elsewhere, clients' terminals don't start. | |
| 187 | + #[cfg(not(unix))] | |
| 188 | + pub struct HostShell; | |
| 189 | + | |
| 190 | + #[cfg(not(unix))] | |
| 191 | + impl HostShell { | |
| 192 | + pub fn spawn(_: &Path, _: u16, _: u16, _: impl Fn(ShellEvent) + Send + 'static) -> std::io::Result<Self> { | |
| 193 | + Err(std::io::Error::new(std::io::ErrorKind::Unsupported, "terminals for other devices need Linux or macOS")) | |
| 194 | + } | |
| 195 | + | |
| 196 | + pub fn write(&self, _: Vec<u8>) {} | |
| 197 | + | |
| 198 | + pub fn resize(&self, _: u16, _: u16) {} | |
| 199 | + } | |
| 200 | + | |
| 201 | + #[cfg(all(test, unix))] | |
| 202 | + mod tests { | |
| 203 | + use std::time::{Duration, Instant}; | |
| 204 | + | |
| 205 | + use super::{HostShell, ShellEvent}; | |
| 206 | + | |
| 207 | + #[test] | |
| 208 | + fn runs_a_shell_and_reports_its_exit() { | |
| 209 | + let dir = std::env::temp_dir(); | |
| 210 | + let (events, received) = std::sync::mpsc::channel(); | |
| 211 | + // A plain shell, not the user's with its startup files. | |
| 212 | + let mut options = crate::terminal::shell_options(&dir); | |
| 213 | + options.shell = Some(alacritty_terminal::tty::Shell::new("/bin/sh".into(), Vec::new())); | |
| 214 | + let shell = HostShell::spawn_with(options, 80, 24, move |event| { | |
| 215 | + let _ = events.send(event); | |
| 216 | + }) | |
| 217 | + .unwrap(); | |
| 218 | + // Quoted apart, so the echoed command line doesn't match. | |
| 219 | + shell.write(b"echo vectrum\"\"-ok; exit\n".to_vec()); | |
| 220 | + let deadline = Instant::now() + Duration::from_secs(10); | |
| 221 | + let mut output = Vec::new(); | |
| 222 | + let mut exited = false; | |
| 223 | + while Instant::now() < deadline && !exited { | |
| 224 | + match received.recv_timeout(Duration::from_millis(200)) { | |
| 225 | + Ok(ShellEvent::Output(bytes)) => output.extend(bytes), | |
| 226 | + Ok(ShellEvent::Exited) => exited = true, | |
| 227 | + Err(_) => {} | |
| 228 | + } | |
| 229 | + } | |
| 230 | + let output = String::from_utf8_lossy(&output); | |
| 231 | + assert!(output.contains("vectrum-ok"), "output: {output:?}"); | |
| 232 | + assert!(exited, "the shell's exit wasn't reported"); | |
| 233 | + } | |
| 234 | + } | |
| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | //! A shell in a pseudo-terminal, emulated by `alacritty_terminal`: the PTY, |
| 2 | 2 | //! the reader thread that parses the shell's output into a grid, and the |
| 3 | - //! bytes keys and pastes send. `ui::terminal` draws the grid. | |
| 3 | + //! bytes keys and pastes send. `ui::terminal` draws the grid. A terminal can | |
| 4 | + //! also show a shell on another device: its output arrives over the remote | |
| 5 | + //! connection and is parsed here, and what's typed goes back the same way. | |
| 4 | 6 | |
| 5 | 7 | use std::borrow::Cow; |
| 6 | 8 | use std::collections::HashMap; |
| 7 | 9 | use std::path::Path; |
| 8 | 10 | use std::sync::Arc; |
| 11 | + use std::time::Instant; | |
| 9 | 12 | |
| 10 | 13 | use alacritty_terminal::event::{Event, EventListener, WindowSize}; |
| 11 | 14 | use alacritty_terminal::event_loop::{EventLoop, EventLoopSender, Msg}; |
| @@ -13,6 +16,7 @@ use alacritty_terminal::grid::Dimensions; | ||
| 13 | 16 | use alacritty_terminal::sync::FairMutex; |
| 14 | 17 | use alacritty_terminal::term::{self, Term}; |
| 15 | 18 | use alacritty_terminal::tty; |
| 19 | + use alacritty_terminal::vte::ansi; | |
| 16 | 20 | use gpui_kit::Keystroke; |
| 17 | 21 | |
| 18 | 22 | /// How many lines a terminal keeps above the screen. |
| @@ -53,33 +57,89 @@ impl Dimensions for GridSize { | ||
| 53 | 57 | /// A running shell and its screen. |
| 54 | 58 | pub struct Terminal { |
| 55 | 59 | term: Arc<FairMutex<Term<Listener>>>, |
| 56 | - sender: EventLoopSender, | |
| 60 | + backend: Backend, | |
| 57 | 61 | size: GridSize, |
| 58 | 62 | } |
| 59 | 63 | |
| 64 | + /// Where the shell runs. | |
| 65 | + enum Backend { | |
| 66 | + /// Here, in a PTY whose reader thread feeds the screen. | |
| 67 | + Local(EventLoopSender), | |
| 68 | + /// On another device, which sends its output to [`Terminal::feed`]. | |
| 69 | + Remote { link: Box<dyn Fn(RemoteRequest)>, parser: Box<ansi::Processor> }, | |
| 70 | + } | |
| 71 | + | |
| 72 | + /// What a terminal on another device asks of the connection. | |
| 73 | + pub enum RemoteRequest { | |
| 74 | + Input(Vec<u8>), | |
| 75 | + Resize { columns: u16, lines: u16 }, | |
| 76 | + /// The terminal is gone; hang up on the shell. | |
| 77 | + Close, | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// The shell, its folder and the environment it gets. | |
| 81 | + // Windows has a field more. | |
| 82 | + #[allow(clippy::needless_update)] | |
| 83 | + pub fn shell_options(cwd: &Path) -> tty::Options { | |
| 84 | + tty::Options { | |
| 85 | + shell: None, | |
| 86 | + working_directory: Some(cwd.to_path_buf()), | |
| 87 | + drain_on_exit: false, | |
| 88 | + env: HashMap::from([ | |
| 89 | + ("TERM".to_string(), "xterm-256color".to_string()), | |
| 90 | + ("COLORTERM".to_string(), "truecolor".to_string()), | |
| 91 | + ("TERM_PROGRAM".to_string(), "vectrum-code".to_string()), | |
| 92 | + ]), | |
| 93 | + ..Default::default() | |
| 94 | + } | |
| 95 | + } | |
| 96 | + | |
| 60 | 97 | impl Terminal { |
| 61 | 98 | /// Starts the user's shell in `cwd`, with a screen of `size` cells of |
| 62 | 99 | /// `cell` pixels each. Its events arrive on the returned channel. |
| 63 | 100 | pub fn spawn(cwd: &Path, size: GridSize, cell: (u16, u16)) -> std::io::Result<(Self, smol::channel::Receiver<Event>)> { |
| 64 | 101 | let (events, receiver) = smol::channel::unbounded(); |
| 65 | 102 | let listener = Listener(events); |
| 66 | - let config = term::Config { scrolling_history: SCROLLBACK, ..Default::default() }; | |
| 67 | - let term = Arc::new(FairMutex::new(Term::new(config, &size, listener.clone()))); | |
| 68 | - let options = tty::Options { | |
| 69 | - shell: None, | |
| 70 | - working_directory: Some(cwd.to_path_buf()), | |
| 71 | - drain_on_exit: false, | |
| 72 | - env: HashMap::from([ | |
| 73 | - ("TERM".to_string(), "xterm-256color".to_string()), | |
| 74 | - ("COLORTERM".to_string(), "truecolor".to_string()), | |
| 75 | - ("TERM_PROGRAM".to_string(), "vectrum-code".to_string()), | |
| 76 | - ]), | |
| 77 | - }; | |
| 78 | - let pty = tty::new(&options, window_size(size, cell), 0)?; | |
| 103 | + let term = Arc::new(FairMutex::new(Term::new(config(), &size, listener.clone()))); | |
| 104 | + let pty = tty::new(&shell_options(cwd), window_size(size, cell), 0)?; | |
| 79 | 105 | let event_loop = EventLoop::new(term.clone(), listener, pty, false, false)?; |
| 80 | 106 | let sender = event_loop.channel(); |
| 81 | 107 | event_loop.spawn(); |
| 82 | - Ok((Self { term, sender, size }, receiver)) | |
| 108 | + Ok((Self { term, backend: Backend::Local(sender), size }, receiver)) | |
| 109 | + } | |
| 110 | + | |
| 111 | + /// A screen for a shell on another device, which `link` talks to. The | |
| 112 | + /// caller has asked for the shell, at `size`, already. | |
| 113 | + pub fn remote(size: GridSize, link: impl Fn(RemoteRequest) + 'static) -> (Self, smol::channel::Receiver<Event>) { | |
| 114 | + let (events, receiver) = smol::channel::unbounded(); | |
| 115 | + let term = Arc::new(FairMutex::new(Term::new(config(), &size, Listener(events)))); | |
| 116 | + let backend = Backend::Remote { link: Box::new(link), parser: Box::default() }; | |
| 117 | + (Self { term, backend, size }, receiver) | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// Takes output from a shell on another device. | |
| 121 | + pub fn feed(&mut self, bytes: &[u8]) { | |
| 122 | + if let Backend::Remote { parser, .. } = &mut self.backend { | |
| 123 | + parser.advance(&mut *self.term.lock(), bytes); | |
| 124 | + } | |
| 125 | + } | |
| 126 | + | |
| 127 | + /// When a synchronized update the shell started has to be shown even | |
| 128 | + /// though it hasn't ended it. | |
| 129 | + pub fn sync_deadline(&self) -> Option<Instant> { | |
| 130 | + match &self.backend { | |
| 131 | + Backend::Remote { parser, .. } => parser.sync_timeout().sync_timeout(), | |
| 132 | + Backend::Local(_) => None, | |
| 133 | + } | |
| 134 | + } | |
| 135 | + | |
| 136 | + /// Shows a synchronized update that ran past its deadline. | |
| 137 | + pub fn end_sync(&mut self) { | |
| 138 | + if let Backend::Remote { parser, .. } = &mut self.backend | |
| 139 | + && parser.sync_timeout().sync_timeout().is_some_and(|deadline| deadline <= Instant::now()) | |
| 140 | + { | |
| 141 | + parser.stop_sync(&mut *self.term.lock()); | |
| 142 | + } | |
| 83 | 143 | } |
| 84 | 144 | |
| 85 | 145 | /// Runs `f` with the screen locked. |
| @@ -90,8 +150,14 @@ impl Terminal { | ||
| 90 | 150 | /// Sends bytes to the shell, as if typed. |
| 91 | 151 | pub fn write(&self, bytes: impl Into<Cow<'static, [u8]>>) { |
| 92 | 152 | let bytes = bytes.into(); |
| 93 | - if !bytes.is_empty() { | |
| 94 | - let _ = self.sender.send(Msg::Input(bytes)); | |
| 153 | + if bytes.is_empty() { | |
| 154 | + return; | |
| 155 | + } | |
| 156 | + match &self.backend { | |
| 157 | + Backend::Local(sender) => { | |
| 158 | + let _ = sender.send(Msg::Input(bytes)); | |
| 159 | + } | |
| 160 | + Backend::Remote { link, .. } => link(RemoteRequest::Input(bytes.into_owned())), | |
| 95 | 161 | } |
| 96 | 162 | } |
| 97 | 163 | |
| @@ -112,17 +178,33 @@ impl Terminal { | ||
| 112 | 178 | } |
| 113 | 179 | self.size = size; |
| 114 | 180 | self.term.lock().resize(size); |
| 115 | - let _ = self.sender.send(Msg::Resize(window_size(size, cell))); | |
| 181 | + let window = window_size(size, cell); | |
| 182 | + match &self.backend { | |
| 183 | + Backend::Local(sender) => { | |
| 184 | + let _ = sender.send(Msg::Resize(window)); | |
| 185 | + } | |
| 186 | + Backend::Remote { link, .. } => link(RemoteRequest::Resize { columns: window.num_cols, lines: window.num_lines }), | |
| 187 | + } | |
| 116 | 188 | } |
| 117 | 189 | } |
| 118 | 190 | |
| 119 | 191 | impl Drop for Terminal { |
| 120 | - /// Stops the reader thread, which hangs up on the shell as it goes. | |
| 192 | + /// Stops the reader thread, which hangs up on the shell as it goes, or | |
| 193 | + /// asks the other device to hang up. | |
| 121 | 194 | fn drop(&mut self) { |
| 122 | - let _ = self.sender.send(Msg::Shutdown); | |
| 195 | + match &self.backend { | |
| 196 | + Backend::Local(sender) => { | |
| 197 | + let _ = sender.send(Msg::Shutdown); | |
| 198 | + } | |
| 199 | + Backend::Remote { link, .. } => link(RemoteRequest::Close), | |
| 200 | + } | |
| 123 | 201 | } |
| 124 | 202 | } |
| 125 | 203 | |
| 204 | + fn config() -> term::Config { | |
| 205 | + term::Config { scrolling_history: SCROLLBACK, ..Default::default() } | |
| 206 | + } | |
| 207 | + | |
| 126 | 208 | fn window_size(size: GridSize, (cell_width, cell_height): (u16, u16)) -> WindowSize { |
| 127 | 209 | WindowSize { |
| 128 | 210 | num_lines: size.lines.min(u16::MAX as usize) as u16, |
| @@ -8,6 +8,7 @@ use std::cell::Cell; | ||
| 8 | 8 | use std::ops::Range; |
| 9 | 9 | use std::path::Path; |
| 10 | 10 | use std::rc::Rc; |
| 11 | + use std::time::{Duration, Instant}; | |
| 11 | 12 | |
| 12 | 13 | use alacritty_terminal::event::Event; |
| 13 | 14 | use alacritty_terminal::grid::{Dimensions as _, Scroll}; |
| @@ -20,7 +21,7 @@ use gpui_kit::component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; | ||
| 20 | 21 | use gpui_kit::prelude::FluentBuilder as _; |
| 21 | 22 | use gpui_kit::*; |
| 22 | 23 | |
| 23 | - use crate::terminal::{GridSize, Terminal, key_bytes}; | |
| 24 | + use crate::terminal::{GridSize, RemoteRequest, Terminal, key_bytes}; | |
| 24 | 25 | use crate::ui::metal::{self, Palette}; |
| 25 | 26 | use crate::{TerminalCopy, TerminalPaste}; |
| 26 | 27 | |
| @@ -29,6 +30,10 @@ const LINE_HEIGHT: f32 = 17.; | ||
| 29 | 30 | /// The space between the drawer's edges and the grid. |
| 30 | 31 | const PAD_X: f32 = 10.; |
| 31 | 32 | const PAD_Y: f32 = 6.; |
| 33 | + /// The screen a shell starts with, until the drawer lays it out. | |
| 34 | + pub const INITIAL_SIZE: GridSize = GridSize { columns: 80, lines: 24 }; | |
| 35 | + /// How long a remote shell has to say something before it's given up on. | |
| 36 | + const REMOTE_ANSWER_TIMEOUT: Duration = Duration::from_secs(15); | |
| 32 | 37 | |
| 33 | 38 | /// What a terminal tells the drawer. |
| 34 | 39 | pub enum TerminalEvent { |
| @@ -54,6 +59,10 @@ pub struct TerminalView { | ||
| 54 | 59 | /// Wheel movement short of a whole line, kept for the next event. |
| 55 | 60 | scroll_remainder: f32, |
| 56 | 61 | _events: Task<()>, |
| 62 | + /// Waits out a remote shell's synchronized update. | |
| 63 | + sync_timer: Option<Task<()>>, | |
| 64 | + /// Gives up on a remote shell that never says anything. | |
| 65 | + answer_timer: Option<Task<()>>, | |
| 57 | 66 | } |
| 58 | 67 | |
| 59 | 68 | impl EventEmitter<TerminalEvent> for TerminalView {} |
| @@ -68,8 +77,43 @@ impl TerminalView { | ||
| 68 | 77 | /// Starts a shell in `cwd`. |
| 69 | 78 | pub fn new(cwd: &Path, label: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self { |
| 70 | 79 | let cell_width = cell_width(window); |
| 71 | - let size = GridSize { columns: 80, lines: 24 }; | |
| 72 | - let (terminal, error, events) = match Terminal::spawn(cwd, size, cell_pixels(cell_width)) { | |
| 80 | + let started = Terminal::spawn(cwd, INITIAL_SIZE, cell_pixels(cell_width)) | |
| 81 | + .map_err(|err| SharedString::from(format!("Couldn't start the shell: {err}"))); | |
| 82 | + Self::with_terminal(started, label.into(), cell_width, window, cx) | |
| 83 | + } | |
| 84 | + | |
| 85 | + /// A terminal whose shell runs on another device, reached through | |
| 86 | + /// `link`. The shell was asked for at [`INITIAL_SIZE`]; its output | |
| 87 | + /// arrives in [`Self::receive`]. | |
| 88 | + pub fn remote( | |
| 89 | + label: impl Into<SharedString>, | |
| 90 | + link: impl Fn(RemoteRequest) + 'static, | |
| 91 | + window: &mut Window, | |
| 92 | + cx: &mut Context<Self>, | |
| 93 | + ) -> Self { | |
| 94 | + let cell_width = cell_width(window); | |
| 95 | + let started = Ok(Terminal::remote(INITIAL_SIZE, link)); | |
| 96 | + let mut view = Self::with_terminal(started, label.into(), cell_width, window, cx); | |
| 97 | + // A shell prints its prompt at once; a device too old to start one | |
| 98 | + // never answers. | |
| 99 | + view.answer_timer = Some(cx.spawn(async move |this, cx| { | |
| 100 | + cx.background_executor().timer(REMOTE_ANSWER_TIMEOUT).await; | |
| 101 | + let _ = this.update(cx, |this, cx| { | |
| 102 | + this.answer_timer = None; | |
| 103 | + this.stop("The other device didn't start a shell. Update Vectrum Code there, then try again.", cx); | |
| 104 | + }); | |
| 105 | + })); | |
| 106 | + view | |
| 107 | + } | |
| 108 | + | |
| 109 | + fn with_terminal( | |
| 110 | + started: Result<(Terminal, smol::channel::Receiver<Event>), SharedString>, | |
| 111 | + label: SharedString, | |
| 112 | + cell_width: Pixels, | |
| 113 | + window: &mut Window, | |
| 114 | + cx: &mut Context<Self>, | |
| 115 | + ) -> Self { | |
| 116 | + let (terminal, error, events) = match started { | |
| 73 | 117 | Ok((terminal, events)) => { |
| 74 | 118 | let task = cx.spawn_in(window, async move |this, cx| { |
| 75 | 119 | while let Ok(event) = events.recv().await { |
| @@ -84,12 +128,12 @@ impl TerminalView { | ||
| 84 | 128 | }); |
| 85 | 129 | (Some(terminal), None, task) |
| 86 | 130 | } |
| 87 | - Err(err) => (None, Some(format!("Couldn't start the shell: {err}").into()), Task::ready(())), | |
| 131 | + Err(error) => (None, Some(error), Task::ready(())), | |
| 88 | 132 | }; |
| 89 | 133 | Self { |
| 90 | 134 | terminal, |
| 91 | 135 | error, |
| 92 | - label: label.into(), | |
| 136 | + label, | |
| 93 | 137 | focus_handle: cx.focus_handle(), |
| 94 | 138 | exited: false, |
| 95 | 139 | grid_bounds: Rc::default(), |
| @@ -97,9 +141,63 @@ impl TerminalView { | ||
| 97 | 141 | selecting: false, |
| 98 | 142 | scroll_remainder: 0., |
| 99 | 143 | _events: events, |
| 144 | + sync_timer: None, | |
| 145 | + answer_timer: None, | |
| 100 | 146 | } |
| 101 | 147 | } |
| 102 | 148 | |
| 149 | + /// Output from a shell on another device. | |
| 150 | + pub fn receive(&mut self, bytes: &[u8], cx: &mut Context<Self>) { | |
| 151 | + let Some(terminal) = self.terminal.as_mut() else { return }; | |
| 152 | + self.answer_timer = None; | |
| 153 | + terminal.feed(bytes); | |
| 154 | + self.arm_sync_timer(cx); | |
| 155 | + cx.notify(); | |
| 156 | + } | |
| 157 | + | |
| 158 | + /// A synchronized update the remote shell doesn't end in time shows | |
| 159 | + /// anyway, as a local one's does. | |
| 160 | + fn arm_sync_timer(&mut self, cx: &mut Context<Self>) { | |
| 161 | + if self.sync_timer.is_some() { | |
| 162 | + return; | |
| 163 | + } | |
| 164 | + let Some(deadline) = self.terminal.as_ref().and_then(Terminal::sync_deadline) else { return }; | |
| 165 | + self.sync_timer = Some(cx.spawn(async move |this, cx| { | |
| 166 | + cx.background_executor().timer(deadline.saturating_duration_since(Instant::now())).await; | |
| 167 | + let _ = this.update(cx, |this, cx| { | |
| 168 | + this.sync_timer = None; | |
| 169 | + if let Some(terminal) = this.terminal.as_mut() { | |
| 170 | + terminal.end_sync(); | |
| 171 | + } | |
| 172 | + // It may have been extended meanwhile. | |
| 173 | + this.arm_sync_timer(cx); | |
| 174 | + cx.notify(); | |
| 175 | + }); | |
| 176 | + })); | |
| 177 | + } | |
| 178 | + | |
| 179 | + /// The shell on another device exited; without an `error`, the terminal | |
| 180 | + /// closes as a local one would. | |
| 181 | + pub fn remote_exited(&mut self, error: Option<String>, cx: &mut Context<Self>) { | |
| 182 | + match error { | |
| 183 | + Some(error) => self.stop(error, cx), | |
| 184 | + None if !self.exited => { | |
| 185 | + self.exited = true; | |
| 186 | + cx.emit(TerminalEvent::Exited); | |
| 187 | + } | |
| 188 | + None => {} | |
| 189 | + } | |
| 190 | + } | |
| 191 | + | |
| 192 | + /// Stops showing the shell, saying why. | |
| 193 | + pub fn stop(&mut self, error: impl Into<SharedString>, cx: &mut Context<Self>) { | |
| 194 | + self.terminal = None; | |
| 195 | + self.sync_timer = None; | |
| 196 | + self.answer_timer = None; | |
| 197 | + self.error = Some(error.into()); | |
| 198 | + cx.notify(); | |
| 199 | + } | |
| 200 | + | |
| 103 | 201 | fn handle_events(&mut self, events: Vec<Event>, cx: &mut Context<Self>) { |
| 104 | 202 | let Some(terminal) = self.terminal.as_ref() else { return }; |
| 105 | 203 | for event in events { |
| @@ -265,7 +265,7 @@ impl Workspace { | ||
| 265 | 265 | .on_click(cx.listener(|this, _, window, cx| this.toggle_terminal(window, cx))) |
| 266 | 266 | .into_any_element() |
| 267 | 267 | } else { |
| 268 | - // A thread on another device has no folder here to start in. | |
| 268 | + // Its folder is gone, or its device isn't connected. | |
| 269 | 269 | div() |
| 270 | 270 | .id("terminal-drawer-unavailable") |
| 271 | 271 | .tooltip(|window, cx| Tooltip::new("Terminal drawer is unavailable").build(window, cx)) |
| @@ -7,9 +7,11 @@ use std::{ | ||
| 7 | 7 | hash::{Hash as _, Hasher as _}, |
| 8 | 8 | path::PathBuf, |
| 9 | 9 | rc::Rc, |
| 10 | + sync::atomic::{AtomicU64, Ordering}, | |
| 10 | 11 | time::{Duration, SystemTime}, |
| 11 | 12 | }; |
| 12 | 13 | |
| 14 | + use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; | |
| 13 | 15 | use gpui_kit::component::{WindowExt as _, notification::Notification}; |
| 14 | 16 | use gpui_kit::*; |
| 15 | 17 | |
| @@ -21,14 +23,17 @@ use crate::remote::{ | ||
| 21 | 23 | client::{ClientEvent, ConnectionStatus, RemoteClient}, |
| 22 | 24 | new_key, |
| 23 | 25 | server::{Peer, RemoteServer, ServerEvent}, |
| 26 | + shell::{HostShell, ShellEvent}, | |
| 24 | 27 | }; |
| 25 | 28 | use crate::session::{ |
| 26 | 29 | NewSession, PermissionDecision, Reasoning, RemoteLink, Session, from_millis, store_uploads, |
| 27 | 30 | }; |
| 28 | 31 | use crate::projects; |
| 29 | 32 | use crate::settings::{self, RemoteHost, Settings, SettingsPatch, WorkspaceMode}; |
| 33 | + use crate::terminal::RemoteRequest; | |
| 30 | 34 | use crate::ui::folder_browser; |
| 31 | 35 | use crate::ui::metal::MetalButton; |
| 36 | + use crate::ui::terminal::TerminalView; | |
| 32 | 37 | |
| 33 | 38 | /// How often changed threads are sent to subscribed clients. |
| 34 | 39 | pub(super) const FLUSH: Duration = Duration::from_millis(150); |
| @@ -61,6 +66,11 @@ pub(super) struct RemoteState { | ||
| 61 | 66 | /// The environment settings clients were last sent. |
| 62 | 67 | last_settings: String, |
| 63 | 68 | pub(super) environments: Vec<Environment>, |
| 69 | + /// Shells clients run here, by connection and the client's id for them. | |
| 70 | + shells: HashMap<(u64, u64), HostShell>, | |
| 71 | + /// This device's terminals whose shells run on another device, by the | |
| 72 | + /// connection's id and the terminal's id there. | |
| 73 | + pub(super) remote_terminals: HashMap<(String, u64), WeakEntity<TerminalView>>, | |
| 64 | 74 | _server_events: Option<Task<()>>, |
| 65 | 75 | } |
| 66 | 76 | |
| @@ -104,6 +114,7 @@ impl Workspace { | ||
| 104 | 114 | self.remote._server_events = None; |
| 105 | 115 | self.remote.peers.clear(); |
| 106 | 116 | self.remote.subscriptions.clear(); |
| 117 | + self.remote.shells.clear(); | |
| 107 | 118 | self.remote.server_error = None; |
| 108 | 119 | self.remote.server_config = wanted.clone(); |
| 109 | 120 | if let Some((port, key)) = wanted { |
| @@ -167,6 +178,7 @@ impl Workspace { | ||
| 167 | 178 | |
| 168 | 179 | /// Disconnects from a device and takes its projects and threads away. |
| 169 | 180 | fn drop_environment(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) { |
| 181 | + self.remote.remote_terminals.retain(|(env, _), _| env != id); | |
| 170 | 182 | self.remote.environments.retain(|env| env.host.id != id); |
| 171 | 183 | let removed: Vec<Entity<Session>> = self |
| 172 | 184 | .projects |
| @@ -352,6 +364,7 @@ impl Workspace { | ||
| 352 | 364 | } |
| 353 | 365 | self.remote.peers.retain(|peer| peer.conn != conn); |
| 354 | 366 | self.remote.subscriptions.remove(&conn); |
| 367 | + self.remote.shells.retain(|(owner, _), _| *owner != conn); | |
| 355 | 368 | cx.notify(); |
| 356 | 369 | } |
| 357 | 370 | |
| @@ -418,6 +431,7 @@ impl Workspace { | ||
| 418 | 431 | ServerEvent::Disconnected { conn } => { |
| 419 | 432 | self.remote.peers.retain(|peer| peer.conn != conn); |
| 420 | 433 | self.remote.subscriptions.remove(&conn); |
| 434 | + self.remote.shells.retain(|(owner, _), _| *owner != conn); | |
| 421 | 435 | cx.notify(); |
| 422 | 436 | } |
| 423 | 437 | ServerEvent::Message { conn, message } => self.on_client_message(conn, message, window, cx), |
| @@ -524,6 +538,60 @@ impl Workspace { | ||
| 524 | 538 | }) |
| 525 | 539 | .detach(); |
| 526 | 540 | } |
| 541 | + ClientMessage::TerminalOpen { terminal, thread, project, columns, lines } => { | |
| 542 | + self.open_host_shell(conn, terminal, thread, project, columns, lines, cx) | |
| 543 | + } | |
| 544 | + ClientMessage::TerminalInput { terminal, data } => { | |
| 545 | + if let (Some(shell), Ok(bytes)) = (self.remote.shells.get(&(conn, terminal)), BASE64.decode(data)) { | |
| 546 | + shell.write(bytes); | |
| 547 | + } | |
| 548 | + } | |
| 549 | + ClientMessage::TerminalResize { terminal, columns, lines } => { | |
| 550 | + if let Some(shell) = self.remote.shells.get(&(conn, terminal)) { | |
| 551 | + shell.resize(columns, lines); | |
| 552 | + } | |
| 553 | + } | |
| 554 | + ClientMessage::TerminalClose { terminal } => { | |
| 555 | + self.remote.shells.remove(&(conn, terminal)); | |
| 556 | + } | |
| 557 | + } | |
| 558 | + } | |
| 559 | + | |
| 560 | + /// Starts a shell for a client's terminal, in the thread's folder here. | |
| 561 | + #[allow(clippy::too_many_arguments)] | |
| 562 | + fn open_host_shell( | |
| 563 | + &mut self, | |
| 564 | + conn: u64, | |
| 565 | + terminal: u64, | |
| 566 | + thread: Option<String>, | |
| 567 | + project: PathBuf, | |
| 568 | + columns: u16, | |
| 569 | + lines: u16, | |
| 570 | + cx: &mut Context<Self>, | |
| 571 | + ) { | |
| 572 | + let Some(server) = &self.remote.server else { return }; | |
| 573 | + let sender = server.sender(conn); | |
| 574 | + let exited = |error: String| ServerMessage::TerminalExited { terminal, error: Some(error) }; | |
| 575 | + let is_project = self.projects.iter().any(|candidate| candidate.environment.is_none() && candidate.path == project); | |
| 576 | + let cwd = match thread.as_deref().map(|id| self.local_thread(id, cx)) { | |
| 577 | + Some(Some((_, session))) => session.read(cx).cwd.clone(), | |
| 578 | + Some(None) => return sender.send(&exited("That thread is gone.".into())), | |
| 579 | + None if is_project => project, | |
| 580 | + None => return sender.send(&exited(format!("{} isn't a project here.", project.display()))), | |
| 581 | + }; | |
| 582 | + if !cwd.is_dir() { | |
| 583 | + return sender.send(&exited(format!("{} isn't there anymore.", cwd.display()))); | |
| 584 | + } | |
| 585 | + let output = sender.clone(); | |
| 586 | + let started = HostShell::spawn(&cwd, columns, lines, move |event| match event { | |
| 587 | + ShellEvent::Output(bytes) => output.send(&ServerMessage::TerminalOutput { terminal, data: BASE64.encode(bytes) }), | |
| 588 | + ShellEvent::Exited => output.send(&ServerMessage::TerminalExited { terminal, error: None }), | |
| 589 | + }); | |
| 590 | + match started { | |
| 591 | + Ok(shell) => { | |
| 592 | + self.remote.shells.insert((conn, terminal), shell); | |
| 593 | + } | |
| 594 | + Err(err) => sender.send(&exited(format!("Couldn't start the shell: {err}"))), | |
| 527 | 595 | } |
| 528 | 596 | } |
| 529 | 597 | |
| @@ -642,6 +710,10 @@ impl Workspace { | ||
| 642 | 710 | match event { |
| 643 | 711 | ClientEvent::Status(status) => { |
| 644 | 712 | let connected = matches!(status, ConnectionStatus::Connected(_)); |
| 713 | + if !connected { | |
| 714 | + // A shell there ends with the connection. | |
| 715 | + self.stop_remote_terminals(env, "The connection to the other device dropped; this terminal's shell ended.", cx); | |
| 716 | + } | |
| 645 | 717 | if let Some(environment) = self.remote.environments.iter_mut().find(|candidate| candidate.host.id == env) { |
| 646 | 718 | environment.status = status; |
| 647 | 719 | } |
| @@ -700,6 +772,80 @@ impl Workspace { | ||
| 700 | 772 | self.new_thread(ix, window, cx); |
| 701 | 773 | } |
| 702 | 774 | ClientEvent::Message(ServerMessage::Welcome { .. } | ServerMessage::Denied { .. }) => {} |
| 775 | + ClientEvent::Message(ServerMessage::TerminalOutput { terminal, data }) => { | |
| 776 | + let view = self.remote.remote_terminals.get(&(env.to_string(), terminal)).and_then(WeakEntity::upgrade); | |
| 777 | + match (view, BASE64.decode(data)) { | |
| 778 | + (Some(view), Ok(bytes)) => view.update(cx, |view, cx| view.receive(&bytes, cx)), | |
| 779 | + (None, _) => { | |
| 780 | + // A terminal closed here whose shell still talks. | |
| 781 | + self.remote.remote_terminals.remove(&(env.to_string(), terminal)); | |
| 782 | + self.send_to_environment(env, &ClientMessage::TerminalClose { terminal }); | |
| 783 | + } | |
| 784 | + _ => {} | |
| 785 | + } | |
| 786 | + } | |
| 787 | + ClientEvent::Message(ServerMessage::TerminalExited { terminal, error }) => { | |
| 788 | + if let Some(view) = self.remote.remote_terminals.remove(&(env.to_string(), terminal)).and_then(|view| view.upgrade()) { | |
| 789 | + view.update(cx, |view, cx| view.remote_exited(error, cx)); | |
| 790 | + } | |
| 791 | + } | |
| 792 | + } | |
| 793 | + } | |
| 794 | + | |
| 795 | + fn send_to_environment(&self, env: &str, message: &ClientMessage) { | |
| 796 | + if let Some(environment) = self.remote.environments.iter().find(|candidate| candidate.host.id == env) { | |
| 797 | + environment.client.send(message); | |
| 798 | + } | |
| 799 | + } | |
| 800 | + | |
| 801 | + /// A terminal for a thread on another device, whose shell starts there | |
| 802 | + /// in the thread's folder. `None` while the device isn't connected. | |
| 803 | + pub(super) fn open_remote_terminal( | |
| 804 | + &mut self, | |
| 805 | + session: &Entity<Session>, | |
| 806 | + label: String, | |
| 807 | + window: &mut Window, | |
| 808 | + cx: &mut Context<Self>, | |
| 809 | + ) -> Option<Entity<TerminalView>> { | |
| 810 | + static NEXT_TERMINAL: AtomicU64 = AtomicU64::new(1); | |
| 811 | + let (env, thread, project) = { | |
| 812 | + let session = session.read(cx); | |
| 813 | + let remote = session.remote.as_ref()?; | |
| 814 | + (remote.env.clone(), remote.thread.clone(), session.project.clone()) | |
| 815 | + }; | |
| 816 | + let environment = self.remote.environments.iter().find(|candidate| candidate.host.id == env)?; | |
| 817 | + if !matches!(environment.status, ConnectionStatus::Connected(_)) { | |
| 818 | + return None; | |
| 819 | + } | |
| 820 | + let client = environment.client.clone(); | |
| 821 | + let terminal = NEXT_TERMINAL.fetch_add(1, Ordering::Relaxed); | |
| 822 | + let size = crate::ui::terminal::INITIAL_SIZE; | |
| 823 | + client.send(&ClientMessage::TerminalOpen { | |
| 824 | + terminal, | |
| 825 | + thread, | |
| 826 | + project, | |
| 827 | + columns: size.columns as u16, | |
| 828 | + lines: size.lines as u16, | |
| 829 | + }); | |
| 830 | + let link = move |request: RemoteRequest| { | |
| 831 | + client.send(&match request { | |
| 832 | + RemoteRequest::Input(bytes) => ClientMessage::TerminalInput { terminal, data: BASE64.encode(bytes) }, | |
| 833 | + RemoteRequest::Resize { columns, lines } => ClientMessage::TerminalResize { terminal, columns, lines }, | |
| 834 | + RemoteRequest::Close => ClientMessage::TerminalClose { terminal }, | |
| 835 | + }); | |
| 836 | + }; | |
| 837 | + let view = cx.new(|cx| TerminalView::remote(label, link, window, cx)); | |
| 838 | + self.remote.remote_terminals.insert((env, terminal), view.downgrade()); | |
| 839 | + Some(view) | |
| 840 | + } | |
| 841 | + | |
| 842 | + /// Stops a device's terminals, whose shells are out of reach now. | |
| 843 | + fn stop_remote_terminals(&mut self, env: &str, reason: &str, cx: &mut Context<Self>) { | |
| 844 | + let ids: Vec<(String, u64)> = self.remote.remote_terminals.keys().filter(|(owner, _)| owner == env).cloned().collect(); | |
| 845 | + for id in ids { | |
| 846 | + if let Some(view) = self.remote.remote_terminals.remove(&id).and_then(|view| view.upgrade()) { | |
| 847 | + view.update(cx, |view, cx| view.stop(reason.to_string(), cx)); | |
| 848 | + } | |
| 703 | 849 | } |
| 704 | 850 | } |
| 705 | 851 | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | //! T3 Code's terminal drawer (ThreadTerminalDrawer): shells under the |
| 2 | 2 | //! conversation, per thread, started in the thread's folder (its worktree |
| 3 | - //! when it has one). Ctrl+J shows and hides it; its top edge drags. One | |
| 3 | + //! when it has one), on the device the thread runs on. Ctrl+J shows and | |
| 4 | + //! hides it; its top edge drags. One | |
| 4 | 5 | //! terminal fills it; splits lay a group out side by side or stacked; with |
| 5 | 6 | //! more than one terminal a sidebar lists the groups. A terminal whose shell |
| 6 | 7 | //! exits closes, and closing the last one closes the drawer. |
| @@ -15,6 +16,7 @@ use gpui_kit::*; | ||
| 15 | 16 | |
| 16 | 17 | use super::surfaces::{Surface, TERMINAL_PANEL_WIDTH}; |
| 17 | 18 | use super::{Workspace, actions}; |
| 19 | + use crate::remote::client::ConnectionStatus; | |
| 18 | 20 | use crate::ui::icons::Lucide; |
| 19 | 21 | use crate::ui::metal::{self, MetalButton, Palette}; |
| 20 | 22 | use crate::ui::terminal::{TerminalEvent, TerminalView}; |
| @@ -149,7 +151,7 @@ impl TerminalDrawer { | ||
| 149 | 151 | |
| 150 | 152 | impl Workspace { |
| 151 | 153 | /// Where the open thread's terminals start: its folder on this device. |
| 152 | - /// `None` for threads on other devices. | |
| 154 | + /// `None` for threads on other devices, whose shells start there. | |
| 153 | 155 | fn terminal_cwd(&self, cx: &App) -> Option<PathBuf> { |
| 154 | 156 | let session = self.active.as_ref()?.read(cx); |
| 155 | 157 | let project = self.active_project()?; |
| @@ -159,9 +161,20 @@ impl Workspace { | ||
| 159 | 161 | Some(session.cwd.clone()).filter(|cwd| cwd.is_dir()) |
| 160 | 162 | } |
| 161 | 163 | |
| 164 | + /// The open thread runs on another device that's connected now, so its | |
| 165 | + /// terminals' shells can start there. | |
| 166 | + fn remote_terminal_available(&self, cx: &App) -> bool { | |
| 167 | + let Some(session) = self.active.as_ref() else { return false }; | |
| 168 | + let Some(env) = session.read(cx).remote.as_ref().map(|remote| remote.env.clone()) else { return false }; | |
| 169 | + self.remote | |
| 170 | + .environments | |
| 171 | + .iter() | |
| 172 | + .any(|candidate| candidate.host.id == env && matches!(candidate.status, ConnectionStatus::Connected(_))) | |
| 173 | + } | |
| 174 | + | |
| 162 | 175 | /// Whether the open thread can have terminals. |
| 163 | 176 | pub(super) fn terminal_available(&self, cx: &App) -> bool { |
| 164 | - self.terminal_cwd(cx).is_some() | |
| 177 | + self.terminal_cwd(cx).is_some() || self.remote_terminal_available(cx) | |
| 165 | 178 | } |
| 166 | 179 | |
| 167 | 180 | /// Whether the open thread's drawer shows. |
| @@ -240,10 +253,24 @@ impl Workspace { | ||
| 240 | 253 | self.start_terminal(session, id, window, cx); |
| 241 | 254 | } |
| 242 | 255 | |
| 243 | - /// Starts terminal `id`'s shell and focuses it. | |
| 256 | + /// Starts terminal `id`'s shell, here or on the thread's device, and | |
| 257 | + /// focuses it. | |
| 244 | 258 | fn start_terminal(&mut self, session: EntityId, id: u32, window: &mut Window, cx: &mut Context<Self>) { |
| 245 | - let Some(cwd) = self.terminal_cwd(cx) else { return }; | |
| 246 | - let view = cx.new(|cx| TerminalView::new(&cwd, label(id), window, cx)); | |
| 259 | + let view = match self.terminal_cwd(cx) { | |
| 260 | + Some(cwd) => Some(cx.new(|cx| TerminalView::new(&cwd, label(id), window, cx))), | |
| 261 | + None => self | |
| 262 | + .active | |
| 263 | + .clone() | |
| 264 | + .filter(|active| active.entity_id() == session) | |
| 265 | + .and_then(|active| self.open_remote_terminal(&active, label(id), window, cx)), | |
| 266 | + }; | |
| 267 | + let Some(view) = view else { | |
| 268 | + // Nothing to show in the place the layout made for it. | |
| 269 | + if let Some(drawer) = self.terminals.get_mut(&session) { | |
| 270 | + drawer.layout.close(id); | |
| 271 | + } | |
| 272 | + return; | |
| 273 | + }; | |
| 247 | 274 | let subscription = cx.subscribe_in(&view, window, move |this, _, event: &TerminalEvent, window, cx| match event { |
| 248 | 275 | TerminalEvent::Exited => this.close_terminal(session, id, false, window, cx), |
| 249 | 276 | TerminalEvent::AddToChat(text) => { |