diff options
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/handoff.rs | 58 | ||||
| -rw-r--r-- | src/server/headless.rs | 447 |
2 files changed, 485 insertions, 20 deletions
diff --git a/src/server/handoff.rs b/src/server/handoff.rs index d17bfa91..2cd17144 100644 --- a/src/server/handoff.rs +++ b/src/server/handoff.rs @@ -39,6 +39,11 @@ pub(crate) struct HandoffManifest { pub expected_protocol: Option<u32>, pub snapshot: crate::persist::SessionSnapshot, pub panes: Vec<crate::handoff_runtime::HandoffRuntimeState>, + /// An outer window title set over the API outlives the server that took the + /// call, so a handoff carries it rather than falling back to the config. + /// Absent from manifests written before this field existed. + #[serde(default)] + pub api_window_title: Option<String>, } #[cfg(unix)] @@ -304,6 +309,7 @@ pub(crate) fn manifest_for( panes: Vec<crate::handoff_runtime::HandoffRuntimeState>, expected_protocol: Option<u32>, expected_version: Option<String>, + api_window_title: Option<String>, ) -> HandoffManifest { HandoffManifest { version: HANDOFF_VERSION, @@ -313,6 +319,7 @@ pub(crate) fn manifest_for( expected_protocol, snapshot, panes, + api_window_title, } } @@ -464,3 +471,54 @@ fn recv_fds(stream: &UnixStream, expected: usize) -> io::Result<Vec<RawFd>> { pub(crate) fn log_import_result(panes: usize) { info!(panes, "handoff import ready"); } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + fn empty_snapshot() -> crate::persist::SessionSnapshot { + crate::persist::SessionSnapshot { + version: 0, + workspaces: Vec::new(), + active: None, + selected: 0, + sidebar_width: None, + sidebar_section_split: None, + collapsed_space_keys: Default::default(), + } + } + + #[test] + fn a_handoff_carries_an_api_set_window_title() { + let manifest = manifest_for( + empty_snapshot(), + Vec::new(), + None, + None, + Some("deploying".to_string()), + ); + + assert_eq!(manifest.api_window_title.as_deref(), Some("deploying")); + } + + #[test] + fn a_manifest_written_before_the_title_field_still_loads() { + let manifest = manifest_for( + empty_snapshot(), + Vec::new(), + None, + None, + Some("deploying".to_string()), + ); + let mut value = serde_json::to_value(&manifest).expect("manifest should serialize"); + value + .as_object_mut() + .expect("manifest should be a json object") + .remove("api_window_title"); + + let older: HandoffManifest = + serde_json::from_value(value).expect("an older manifest should still load"); + + assert!(older.api_window_title.is_none()); + } +} diff --git a/src/server/headless.rs b/src/server/headless.rs index 0632861c..3425225c 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -303,6 +303,14 @@ pub struct HeadlessServer { next_client_id: u64, /// The client currently driving the shared pane runtime size, theme, and input keybindings. foreground_client_id: Option<u64>, + /// Outer window title last pushed, paired with the client that received it. + /// Keying on the client means a newly attached terminal is written to even + /// when the title itself has not changed, without every code path that + /// changes the foreground client having to remember to invalidate this. + sent_window_title: Option<(u64, Option<String>)>, + /// Window title set through `client.window_title.set`. While present it wins + /// over the configured `ui.window_title` until the API clears it again. + api_window_title: Option<String>, /// Server-owned keybindings, restored when foreground clients use server mode. server_keybindings: crate::config::LiveKeybindConfig, /// Full server config warning shown to clients that use server keybindings. @@ -503,6 +511,8 @@ impl HeadlessServer { #[cfg(unix)] next_client_id: 1, foreground_client_id: None, + sent_window_title: None, + api_window_title: None, server_keybindings, server_config_diagnostic, server_config_diagnostic_without_keybindings, @@ -572,13 +582,6 @@ impl HeadlessServer { needs_render = true; crate::render_prof::event("render.request.signal"); } - let terminal_title_changed = self.app.sync_terminal_titles(); - if terminal_title_changed && self.app.terminal_title_sidebar_configured() { - needs_render = true; - needs_full_render = true; - crate::render_prof::event("full_render_cause.terminal_title"); - } - // 2. Drain a bounded internal-event batch. API handlers perform an // exhaustive forwarding-aware drain before reading pane/runtime state. if self.drain_internal_events_with_forwarding() { @@ -710,6 +713,22 @@ impl HeadlessServer { needs_full_render = true; crate::render_prof::event("full_render_cause.generic_dirty"); } + let (sidebar_title_changed, outer_title_synced) = + self.sync_terminal_title_sources(&render_request.terminal_title_sources); + if sidebar_title_changed { + needs_full_render = true; + crate::render_prof::event("full_render_cause.terminal_title_sidebar"); + } + if needs_full_render && !outer_title_synced { + self.sync_window_title(); + } + if !needs_full_render && !needs_graphics_render && !pty_dirty { + // A synchronized-output OSC title can be the only pending work. + // Its deferred PTY repaint has its own signal; do not manufacture + // a full UI render for this client-local side effect. + needs_render = false; + continue; + } if needs_full_render { crate::render_prof::event("retained_gate.needs_full_render"); } else if !pty_dirty { @@ -1278,6 +1297,7 @@ impl HeadlessServer { panes, params.expected_protocol, params.expected_version, + self.api_window_title.clone(), ); let mut import_child = match crate::server::handoff::spawn_handoff_import( import_exe.as_deref(), @@ -2122,11 +2142,93 @@ impl HeadlessServer { .unwrap_or_else(|_| "{}".to_string()) } + /// Pulls only titles reported dirty by the PTY parser. A focused pane title + /// is forwarded as an independent client side effect; only sidebar title + /// tokens require a UI render. + fn sync_terminal_title_sources( + &mut self, + sources: &HashSet<crate::layout::PaneId>, + ) -> (bool, bool) { + let focused_source = self + .app + .state + .active + .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) + .and_then(|workspace| workspace.focused_pane_id()) + .is_some_and(|pane_id| sources.contains(&pane_id)); + let changes = self.app.sync_terminal_titles(sources); + let outer_title_synced = focused_source && self.app.window_title_uses_terminal_title(); + if outer_title_synced { + self.sync_window_title(); + } + ( + self.app.terminal_title_sidebar_changed(&changes), + outer_title_synced, + ) + } + + /// Renders `ui.window_title` against current session state. `None` means + /// window titles are disabled or every token resolved empty, which leaves + /// the client on Herdr's default title. + fn configured_window_title(&self) -> Option<String> { + self.app + .window_title() + .and_then(|title| crate::config::sanitize_window_title_text(&title)) + } + + /// Pushes the configured outer window title to the foreground client when it + /// changed. Herdr consumes each pane's own `OSC 0`/`OSC 2`, so without this + /// the host terminal title never follows the session — which is what window + /// managers read for tab and group bar labels. + fn sync_window_title(&mut self) { + let title = match &self.api_window_title { + Some(title) => Some(title.clone()), + None if self.app.window_title_configured() => self.configured_window_title(), + None => return, + }; + if let (Some(client_id), Some((sent_client_id, sent_title))) = + (self.foreground_client_id, self.sent_window_title.as_ref()) + { + if *sent_client_id == client_id && *sent_title == title { + return; + } + } + self.send_window_title(title); + } + + /// Sends a window title and remembers it only when a foreground client took + /// it, so the next client to attach is written to rather than skipped. + fn send_window_title(&mut self, title: Option<String>) -> bool { + let Some(client_id) = self.foreground_client_id else { + self.sent_window_title = None; + return false; + }; + // A detached client keeps its entry with no writer, and a targeted send + // to one reports success without queuing anything. Caching the title + // against that client would skip the send once it attaches again. + if self + .clients + .get(&client_id) + .is_none_or(|client| client.writer.is_none()) + { + self.sent_window_title = None; + return false; + } + let sent = self.send_to_client( + client_id, + ServerMessage::WindowTitle { + title: title.clone(), + }, + ); + self.sent_window_title = sent.then_some((client_id, title)); + sent + } + fn handle_client_window_title_api(&mut self, id: String, title: Option<String>) -> String { use api::schema::{ClientWindowTitleReason, ResponseResult}; let title = match title { - Some(title) => match sanitize_window_title_text(&title, 200) { + Some(title) => match crate::config::sanitize_window_title_text(&title) { Some(title) => Some(title), None => { return serde_json::to_string(&api::schema::ErrorResponse { @@ -2142,7 +2244,11 @@ impl HeadlessServer { None => None, }; let set_title = title.is_some(); - let changed = self.send_to_foreground_client(ServerMessage::WindowTitle { title }); + // An explicit title suppresses `ui.window_title` until it is cleared, + // and clearing restores the configured title rather than only "herdr". + self.api_window_title = title.clone(); + let title = title.or_else(|| self.configured_window_title()); + let changed = self.send_window_title(title); let reason = match (changed, set_title) { (true, true) => ClientWindowTitleReason::Set, (true, false) => ClientWindowTitleReason::Cleared, @@ -4828,17 +4934,6 @@ fn sanitize_notification_text(value: &str, max_chars: usize) -> Option<String> { (!sanitized.is_empty()).then_some(sanitized) } -fn sanitize_window_title_text(value: &str, max_chars: usize) -> Option<String> { - let sanitized = value - .chars() - .filter(|ch| !matches!(*ch, '\u{1b}' | '\u{7}' | '\u{9c}') && !ch.is_control()) - .take(max_chars) - .collect::<String>() - .trim() - .to_string(); - (!sanitized.is_empty()).then_some(sanitized) -} - fn server_config_diagnostic_summaries(diagnostics: &[String]) -> (Option<String>, Option<String>) { let without_keybindings = diagnostics .iter() @@ -5045,6 +5140,9 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> Some(api_server), should_quit, )?; + // Carried across before any client attaches, so the first title sent is + // the override rather than the configured one it replaced. + server.api_window_title = received.manifest.api_window_title.take(); crate::server::handoff::report_ready(&mut received.stream)?; crate::server::handoff::wait_committed(&mut received.stream)?; server.app.assume_handoff_ownership(); @@ -5207,6 +5305,8 @@ mod tests { #[cfg(unix)] next_client_id: 1, foreground_client_id: None, + sent_window_title: None, + api_window_title: None, server_keybindings, server_config_diagnostic: None, server_config_diagnostic_without_keybindings: None, @@ -5294,6 +5394,7 @@ mod tests { .app .terminal_runtimes .insert(terminal_id.clone(), runtime); + server.app.render_dirty.request_terminal_title(pane_id); let first = headless_pane_list(&mut server).pop().unwrap(); assert_eq!(first.terminal_title.as_deref(), Some("⠋ task")); @@ -5319,6 +5420,7 @@ mod tests { .get(&terminal_id) .unwrap() .test_process_pty_bytes(b"\x1b]2;\xe2\xa0\x99 task\x1b\\"); + server.app.render_dirty.request_terminal_title(pane_id); let second = headless_pane_list(&mut server).pop().unwrap(); assert_eq!(second.terminal_title.as_deref(), Some("⠙ task")); assert_eq!(second.terminal_title_stripped.as_deref(), Some("task")); @@ -5473,6 +5575,311 @@ mod tests { shutdown_test_runtimes(&mut server); } + fn window_title_test_server() -> (HeadlessServer, std::sync::mpsc::Receiver<Vec<u8>>) { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + + let (client_tx, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(1); + drain_window_titles(&control_rx); + (server, control_rx) + } + + /// The test client writer drains its queue on a background thread, so + /// reading a pushed message needs a timeout rather than `try_recv`. + fn next_window_title( + control_rx: &std::sync::mpsc::Receiver<Vec<u8>>, + ) -> Option<Option<String>> { + let deadline = Instant::now() + Duration::from_secs(5); + while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { + let Ok(bytes) = control_rx.recv_timeout(remaining) else { + return None; + }; + if let ServerMessage::WindowTitle { title } = read_server_message(bytes) { + return Some(title); + } + } + None + } + + fn drain_window_titles(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) { + while control_rx.recv_timeout(Duration::from_millis(50)).is_ok() {} + } + + fn no_window_title(control_rx: &std::sync::mpsc::Receiver<Vec<u8>>) -> bool { + while let Ok(bytes) = control_rx.recv_timeout(Duration::from_millis(200)) { + if let ServerMessage::WindowTitle { .. } = read_server_message(bytes) { + return false; + } + } + true + } + + #[test] + fn window_title_waits_for_a_foreground_client_to_exist() { + let mut server = test_headless_server(); + server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("herd")]; + server.app.state.active = Some(0); + server.app.configure_window_title("{workspace}"); + + // The server renders before the first client attaches. Nothing was + // delivered, so nothing may be recorded as delivered either. + server.sync_window_title(); + assert_eq!(server.sent_window_title, None); + + let (client_tx, control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 1, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 1, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(1); + server.sync_window_title(); + + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); + } + + #[test] + fn an_attaching_client_gets_the_title_even_when_it_has_not_changed() { + let (mut server, first_control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + server.sync_window_title(); + assert_eq!( + next_window_title(&first_control_rx), + Some(Some("herd".to_string())) + ); + + // ClientConnected assigns the foreground client directly rather than + // going through promote_client_to_foreground, so the cache must notice + // the new client on its own. + let (client_tx, second_control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 2, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.foreground_client_id = Some(2); + server.sync_window_title(); + + assert_eq!( + next_window_title(&second_control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); + } + + #[test] + fn configured_window_title_reaches_the_foreground_client_once_per_change() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}/{tab}"); + + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd/1".to_string())) + ); + + // An unchanged title must not re-emit an OSC on every render. + server.sync_window_title(); + assert!(no_window_title(&control_rx)); + + server.app.state.workspaces[0].tabs[0].custom_name = Some("build".into()); + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd/build".to_string())) + ); + + shutdown_test_runtimes(&mut server); + } + + #[tokio::test] + async fn focused_terminal_title_syncs_without_requesting_a_sidebar_render() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{terminal_title}"); + server.app.state.ensure_test_terminals(); + let pane_id = server.app.state.workspaces[0].tabs[0].root_pane; + let terminal_id = server.app.state.workspaces[0] + .terminal_id(pane_id) + .expect("terminal") + .clone(); + let runtime = crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""); + runtime.test_process_pty_bytes("\x1b]0;⠋ building\x07".as_bytes()); + server + .app + .terminal_runtimes + .insert(terminal_id.clone(), runtime); + + assert_eq!( + server.sync_terminal_title_sources(&HashSet::from([pane_id])), + (false, true) + ); + assert_eq!( + next_window_title(&control_rx), + Some(Some("building".to_string())) + ); + + server + .app + .terminal_runtimes + .get(&terminal_id) + .expect("runtime") + .test_process_pty_bytes("\x1b]0;⠙ building\x07".as_bytes()); + assert_eq!( + server.sync_terminal_title_sources(&HashSet::from([pane_id])), + (false, true) + ); + assert!(no_window_title(&control_rx)); + + shutdown_test_runtimes(&mut server); + } + + #[test] + fn a_foreground_client_without_a_writer_does_not_cache_the_window_title() { + let (mut server, _control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + + // A detached client keeps its entry but loses its writer, so nothing + // reaches a terminal even though the targeted send reports success. + if let Some(client) = server.clients.get_mut(&1) { + client.writer = None; + } + server.sync_window_title(); + assert!(server.sent_window_title.is_none()); + + // Attaching again has to deliver the title rather than skip it as sent. + let (client_tx, control_rx, _render_rx) = test_client_writer(); + if let Some(client) = server.clients.get_mut(&1) { + client.writer = Some(client_tx); + } + server.sync_window_title(); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herd".to_string())) + ); + + shutdown_test_runtimes(&mut server); + } + + #[test] + fn empty_window_title_config_leaves_the_outer_title_alone() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title(""); + + server.sync_window_title(); + + assert!(no_window_title(&control_rx)); + shutdown_test_runtimes(&mut server); + } + + #[test] + fn api_window_title_wins_until_it_is_cleared() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + + server.handle_client_window_title_api("set".into(), Some("herdr api".into())); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herdr api".to_string())) + ); + + server.app.state.workspaces[0].custom_name = Some("ops".into()); + server.sync_window_title(); + assert!(no_window_title(&control_rx)); + + // Clearing hands the title back to ui.window_title, not to "herdr". + server.handle_client_window_title_api("clear".into(), None); + assert_eq!( + next_window_title(&control_rx), + Some(Some("ops".to_string())) + ); + + shutdown_test_runtimes(&mut server); + } + + #[test] + fn clearing_the_api_title_falls_back_to_herdr_when_window_titles_are_disabled() { + let (mut server, control_rx) = window_title_test_server(); + server.app.configure_window_title(""); + + server.handle_client_window_title_api("set".into(), Some("herdr api".into())); + assert_eq!( + next_window_title(&control_rx), + Some(Some("herdr api".to_string())) + ); + + server.handle_client_window_title_api("clear".into(), None); + assert_eq!(next_window_title(&control_rx), Some(None)); + + shutdown_test_runtimes(&mut server); + } + + #[test] + fn a_newly_promoted_client_gets_the_window_title_again() { + let (mut server, first_control_rx) = window_title_test_server(); + server.app.configure_window_title("{workspace}"); + server.sync_window_title(); + assert_eq!( + next_window_title(&first_control_rx), + Some(Some("herd".to_string())) + ); + + // A second terminal starts on whatever its shell or ssh left behind. + let (client_tx, second_control_rx, _render_rx) = test_client_writer(); + server.clients.insert( + 2, + ClientConnection::new( + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + crate::terminal_theme::TerminalTheme::default(), + None, + 2, + RenderEncoding::SemanticFrame, + Some(client_tx), + ), + ); + server.promote_client_to_foreground(2); + server.sync_window_title(); + + assert_eq!( + next_window_title(&second_control_rx), + Some(Some("herd".to_string())) + ); + shutdown_test_runtimes(&mut server); + } + fn test_client_writer() -> ( ClientWriter, std::sync::mpsc::Receiver<Vec<u8>>, |
