aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
Diffstat (limited to 'src/server')
-rw-r--r--src/server/handoff.rs58
-rw-r--r--src/server/headless.rs447
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>>,