diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-02 14:24:33 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-02 14:24:33 +0300 |
| commit | 6c0bb273d5d5405a00985621b17e36f8b4d64609 (patch) | |
| tree | ceef5ea8adf337bf3c1e977b133e76cb763c6f1b | |
| parent | cc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (diff) | |
feat: support independent multi-client tab views (#3526)
33 files changed, 2561 insertions, 427 deletions
diff --git a/docs/next/website/src/content/docs/concepts.mdx b/docs/next/website/src/content/docs/concepts.mdx index 733da1bb..7641b17f 100644 --- a/docs/next/website/src/content/docs/concepts.mdx +++ b/docs/next/website/src/content/docs/concepts.mdx @@ -68,6 +68,8 @@ By default, Herdr runs as a background server plus one or more attached clients. The server owns panes and process state. The client is the terminal UI attached to that server. +With one attached client, all tabs follow its size as before. With multiple clients, each can view its own workspace and tab. Different viewed tabs follow their respective clients; when clients view the same tab, the last one to focus, select, or interact with it controls that tab's pane sizes. + Detach the client with `ctrl+b q`. The server and agents continue running. If you want to end the session and stop its panes, stop the server: diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 222a5d68..82acc779 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -60,7 +60,7 @@ headless_cols = 160 headless_rows = 50 ``` -An attached client remains authoritative for the shared runtime size. After it detaches, existing pane PTYs retain their last attached size while new headless layout uses the configured fallback. +With one attached client, all tabs follow its size. With multiple clients, each viewed tab follows the client that most recently focused, selected, or interacted with it. When all but one client detach, all tabs immediately return to the remaining client's size. When no clients remain, existing pane PTYs retain their last size and new headless layout uses the configured fallback. ## Terminal defaults diff --git a/docs/next/website/src/content/docs/ja/concepts.mdx b/docs/next/website/src/content/docs/ja/concepts.mdx index 640ba8a4..69bcb1e6 100644 --- a/docs/next/website/src/content/docs/ja/concepts.mdx +++ b/docs/next/website/src/content/docs/ja/concepts.mdx @@ -68,6 +68,8 @@ herdr session attach side-project サーバーはペインとプロセス状態を所有します。クライアントはそのサーバーにアタッチしたターミナル UI です。 +クライアントが 1 つだけ接続されている場合、従来どおりすべてのタブがそのサイズに従います。複数のクライアントが接続されている場合、それぞれ別のワークスペースやタブを表示できます。異なるタブはそれぞれを表示するクライアントのサイズに従い、同じタブを表示している場合は、最後にそのタブへフォーカス、選択、または操作したクライアントがペインのサイズを制御します。 + `ctrl+b q` でクライアントをデタッチします。サーバーとエージェントは動き続けます。 セッションを終了してそのペインを停止したい場合は、サーバーを停止します: diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index b2af36c8..2b227bd9 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -60,7 +60,7 @@ headless_cols = 160 headless_rows = 50 ``` -クライアントが接続されている間は、そのクライアントが共有ランタイムのサイズを決定します。切断後、既存のペイン PTY は最後に接続されていたときのサイズを保ち、新しいヘッドレスレイアウトには設定したフォールバックが使われます。 +クライアントが 1 つだけ接続されている場合、すべてのタブがそのサイズに従います。複数のクライアントが接続されている場合、表示中の各タブは、最後にそのタブへフォーカス、選択、または操作したクライアントのサイズに従います。1 つを除くすべてのクライアントが切断すると、すべてのタブは残ったクライアントのサイズへすぐに戻ります。クライアントが 1 つも残っていない場合、既存のペイン PTY は最後のサイズを保ち、新しいヘッドレスレイアウトには設定したフォールバックが使われます。 ## ターミナルのデフォルト diff --git a/docs/next/website/src/content/docs/zh-cn/concepts.mdx b/docs/next/website/src/content/docs/zh-cn/concepts.mdx index ab950416..5b5fefe5 100644 --- a/docs/next/website/src/content/docs/zh-cn/concepts.mdx +++ b/docs/next/website/src/content/docs/zh-cn/concepts.mdx @@ -68,6 +68,8 @@ herdr session attach side-project 服务器拥有窗格和进程状态。客户端是连接到该服务器的终端 UI。 +只有一个客户端连接时,所有标签页会像以前一样采用该客户端的尺寸。连接多个客户端时,每个客户端都可以查看自己的工作区和标签页。不同标签页会分别采用查看它们的客户端尺寸;客户端查看同一标签页时,最后聚焦、选择或操作该标签页的客户端控制其中窗格的尺寸。 + 用 `ctrl+b q` 分离客户端。服务器和智能体会继续运行。 如果想结束会话并停止其中的窗格,停止服务器: diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 46db8777..d88503b4 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -60,7 +60,7 @@ headless_cols = 160 headless_rows = 50 ``` -客户端连接后,其尺寸仍决定共享运行时的大小。客户端断开后,现有窗格 PTY 会保留上次连接时的尺寸,而新的无客户端布局会使用配置的回退值。 +只有一个客户端连接时,所有标签页都会采用它的尺寸。连接多个客户端时,每个正在查看的标签页会采用最后聚焦、选择或操作它的客户端尺寸。当只剩一个客户端时,所有标签页会立即恢复为该客户端的尺寸。没有客户端连接时,现有窗格 PTY 会保留最后的尺寸,新的无客户端布局会使用配置的回退值。 ## 终端默认值 diff --git a/src/app/api.rs b/src/app/api.rs index ff5a7dc4..e6f6b6ec 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -657,8 +657,56 @@ impl App { self.sync_focus_events_with_outer_event(None); } - pub(crate) fn send_outer_focus_event(&mut self, event: crate::ghostty::FocusEvent) { - self.sync_focus_events_with_outer_event(Some(event)); + pub(crate) fn accept_current_focus_without_events(&mut self) { + self.last_focus = self.state.active.and_then(|idx| { + self.state + .workspaces + .get(idx) + .and_then(|workspace| workspace.focused_pane_id().map(|pane_id| (idx, pane_id))) + }); + } + + pub(crate) fn accept_current_focus_with_api_events(&mut self) { + let current_focus = self.state.active.and_then(|idx| { + self.state + .workspaces + .get(idx) + .and_then(|workspace| workspace.focused_pane_id().map(|pane_id| (idx, pane_id))) + }); + if current_focus == self.last_focus { + return; + } + self.last_focus = current_focus; + if let Some((ws_idx, pane_id)) = current_focus { + self.emit_focus_api_events(ws_idx, pane_id); + } + } + + fn emit_focus_api_events(&mut self, ws_idx: usize, pane_id: crate::layout::PaneId) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::WorkspaceFocused, + data: crate::api::schema::EventData::WorkspaceFocused { + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + if let Some(tab_id) = self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::TabFocused, + data: crate::api::schema::EventData::TabFocused { + tab_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + } + if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { + self.emit_event(crate::api::schema::EventEnvelope { + event: crate::api::schema::EventKind::PaneFocused, + data: crate::api::schema::EventData::PaneFocused { + pane_id: public_pane_id, + workspace_id: self.public_workspace_id(ws_idx), + }, + }); + } } fn sync_focus_events_with_outer_event( @@ -690,38 +738,13 @@ impl App { } }); self.send_pane_focus_event(ws_idx, pane_id, event); - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::WorkspaceFocused, - data: crate::api::schema::EventData::WorkspaceFocused { - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - if let Some(tab_id) = - self.public_tab_id(ws_idx, self.state.workspaces[ws_idx].active_tab) - { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::TabFocused, - data: crate::api::schema::EventData::TabFocused { - tab_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - } - if let Some(public_pane_id) = self.public_pane_id(ws_idx, pane_id) { - self.emit_event(crate::api::schema::EventEnvelope { - event: crate::api::schema::EventKind::PaneFocused, - data: crate::api::schema::EventData::PaneFocused { - pane_id: public_pane_id, - workspace_id: self.public_workspace_id(ws_idx), - }, - }); - } + self.emit_focus_api_events(ws_idx, pane_id); } self.last_focus = current_focus; } - fn send_pane_focus_event( + pub(crate) fn send_pane_focus_event( &self, ws_idx: usize, pane_id: crate::layout::PaneId, diff --git a/src/app/creation.rs b/src/app/creation.rs index fbe61902..af34e540 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -79,8 +79,23 @@ impl App { } pub(crate) fn resolved_new_workspace_cwd_from(&self, ws_idx: usize) -> PathBuf { - let follow_cwd = self - .focused_pane_cwd_in_workspace(ws_idx) + let tab_idx = self + .state + .workspaces + .get(ws_idx) + .map(crate::workspace::Workspace::active_tab_index); + self.resolved_new_workspace_cwd_from_tab(ws_idx, tab_idx) + } + + pub(crate) fn resolved_new_workspace_cwd_from_tab( + &self, + ws_idx: usize, + tab_idx: Option<usize>, + ) -> PathBuf { + let follow_cwd = tab_idx + .and_then(|tab_idx| self.state.workspaces.get(ws_idx)?.tabs.get(tab_idx)) + .map(|tab| tab.layout.focused()) + .and_then(|pane_id| self.launch_cwd_for_pane_in_workspace(ws_idx, pane_id)) .or_else(|| self.seed_cwd_from_workspace(ws_idx)); self.resolve_new_terminal_cwd(follow_cwd) } diff --git a/src/app/ids.rs b/src/app/ids.rs index c33a5fff..df273250 100644 --- a/src/app/ids.rs +++ b/src/app/ids.rs @@ -12,11 +12,11 @@ impl App { .find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane))) } - pub(super) fn public_workspace_id(&self, ws_idx: usize) -> String { + pub(crate) fn public_workspace_id(&self, ws_idx: usize) -> String { self.state.workspaces[ws_idx].id.clone() } - pub(super) fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> { + pub(crate) fn public_tab_id(&self, ws_idx: usize, tab_idx: usize) -> Option<String> { let ws = self.state.workspaces.get(ws_idx)?; let tab_number = ws.public_tab_number(tab_idx)?; Some(crate::workspace::public_tab_id_for_number( @@ -57,7 +57,7 @@ impl App { ) } - pub(super) fn parse_workspace_id(&self, id: &str) -> Option<usize> { + pub(crate) fn parse_workspace_id(&self, id: &str) -> Option<usize> { self.state .workspaces .iter() @@ -66,7 +66,7 @@ impl App { .or_else(|| id.parse::<usize>().ok()?.checked_sub(1)) } - pub(super) fn parse_tab_id(&self, id: &str) -> Option<(usize, usize)> { + pub(crate) fn parse_tab_id(&self, id: &str) -> Option<(usize, usize)> { if let Some(rest) = id.strip_prefix("t_") { let (ws_raw, tab_raw) = rest.rsplit_once('_')?; let ws_idx = self.parse_workspace_id(ws_raw)?; diff --git a/src/app/popup.rs b/src/app/popup.rs index 8bcd8ae3..0643ea8f 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -13,11 +13,6 @@ pub(crate) struct PopupGeometry { } impl App { - pub(crate) fn popup_runtime(&self) -> Option<&TerminalRuntime> { - let terminal_id = &self.state.popup_pane.as_ref()?.terminal_id; - self.terminal_runtimes.get(terminal_id) - } - pub(crate) fn close_popup_pane(&mut self) -> bool { let Some(popup) = self.state.popup_pane.take() else { return false; diff --git a/src/app/state.rs b/src/app/state.rs index 0ce9fb6d..3d3700b2 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -921,35 +921,6 @@ impl AppState { }) } - pub(crate) fn app_surface_pane_ids(&self) -> std::collections::HashSet<PaneId> { - let mut pane_ids = std::collections::HashSet::new(); - if let Some(popup) = &self.popup_pane { - pane_ids.insert(popup.pane_id); - } - let Some(tab) = self - .active - .and_then(|ws_idx| self.workspaces.get(ws_idx)) - .and_then(crate::workspace::Workspace::active_tab) - else { - return pane_ids; - }; - if tab.zoomed { - pane_ids.insert(tab.layout.focused()); - } else { - pane_ids.extend(tab.panes.keys().copied()); - } - pane_ids - } - - pub(crate) fn focused_pane_requests_mouse_capture_from( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ) -> bool { - self.active - .and_then(|idx| self.focused_runtime_in_workspace(terminal_runtimes, idx)) - .is_some_and(crate::terminal::TerminalRuntime::mouse_reporting_enabled) - } - pub fn estimate_pane_size(&self) -> (u16, u16) { if let Some(info) = self.view.pane_infos.first() { (info.rect.height, info.rect.width) @@ -984,16 +955,6 @@ impl AppState { terminal_runtimes.get(terminal_id) } - pub(crate) fn focused_runtime_in_workspace<'a>( - &'a self, - terminal_runtimes: &'a crate::terminal::TerminalRuntimeRegistry, - ws_idx: usize, - ) -> Option<&'a crate::terminal::TerminalRuntime> { - let ws = self.workspaces.get(ws_idx)?; - let pane_id = ws.focused_pane_id()?; - self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id) - } - pub(crate) fn pane_visible_on_active_surface( &self, ws_idx: usize, diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 34c780da..1ebd4e29 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -388,11 +388,16 @@ impl ClientShellState { } else if !self.endpoint_notice_seen.insert(key.clone()) { return false; } + let duration_seconds = if kind == ClientEndpointNoticeKind::Rejected { + 3 + } else { + 8 + }; self.visible_endpoint_notice = Some(ClientVisibleEndpointNotice { key, title: title.into(), body, - deadline: std::time::Instant::now() + std::time::Duration::from_secs(8), + deadline: std::time::Instant::now() + std::time::Duration::from_secs(duration_seconds), }); true } diff --git a/src/client/shell/tests/keybindings_settings.rs b/src/client/shell/tests/keybindings_settings.rs index 5b0fced7..d20485b3 100644 --- a/src/client/shell/tests/keybindings_settings.rs +++ b/src/client/shell/tests/keybindings_settings.rs @@ -505,6 +505,7 @@ fn generic_endpoint_failures_and_control_errors_are_visible() { [ClientShellAction::Endpoint { request, .. }] => request.id.clone(), other => panic!("expected generic endpoint request, got {other:?}"), }; + let rejected_at = std::time::Instant::now(); let (repaint, actions) = state.handle_endpoint_result( "boot-1", &request_id, @@ -522,6 +523,9 @@ fn generic_endpoint_failures_and_control_errors_are_visible() { assert_eq!(notice.key.kind, ClientEndpointNoticeKind::Rejected); assert_eq!(notice.key.code, "workspace.focus:not_found"); assert_eq!(notice.body, "workspace no longer exists"); + let observed_at = std::time::Instant::now(); + assert!(notice.deadline >= rejected_at + std::time::Duration::from_secs(3)); + assert!(notice.deadline <= observed_at + std::time::Duration::from_secs(3)); assert!(state.endpoint_error.is_none()); assert!(state.receive_endpoint_error("Paste rejected: too large".into())); diff --git a/src/kitty_graphics.rs b/src/kitty_graphics.rs index 1ce90eb3..37bae34e 100644 --- a/src/kitty_graphics.rs +++ b/src/kitty_graphics.rs @@ -503,20 +503,22 @@ fn collect_visible_placements( uploaded_images: &HashMap<u32, ImageSignature>, oversized_images: &HashMap<HostSourceKey, ImageSignature>, ) -> Vec<HostPlacement> { - let ws_idx = match app.active { - Some(idx) => idx, - None => { - tracing::debug!("collect_visible_placements: no active workspace"); - return Vec::new(); - } + let Some(target) = surface.target else { + tracing::debug!("collect_visible_placements: no tab surface target"); + return Vec::new(); }; + let ws_idx = target.workspace_index; if app .workspaces .get(ws_idx) - .and_then(crate::workspace::Workspace::active_tab) + .and_then(|workspace| workspace.tabs.get(target.tab_index)) .is_none() { - tracing::debug!(ws_idx, "collect_visible_placements: no active tab"); + tracing::debug!( + ws_idx, + tab_idx = target.tab_index, + "collect_visible_placements: no target tab" + ); return Vec::new(); } diff --git a/src/kitty_graphics/surface.rs b/src/kitty_graphics/surface.rs index 4cd55a75..0e69a145 100644 --- a/src/kitty_graphics/surface.rs +++ b/src/kitty_graphics/surface.rs @@ -273,7 +273,7 @@ pub(crate) fn collect_scene( if !cell_size.is_known() { return (SurfaceGraphicsScene::default(), DeliveryCache::default()); } - let workspace_index = app.state.active; + let workspace_index = surface.target.map(|target| target.workspace_index); let mut targets = HashMap::new(); let mut public_panes = HashMap::new(); if let Some(workspace_index) = workspace_index { diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 86243e31..34be0519 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -92,6 +92,20 @@ pub(crate) fn error_message( } } +fn correlate_response_id(response: String, request_id: &str) -> String { + let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&response) else { + return response; + }; + let Some(id) = value.get_mut("id") else { + return response; + }; + if id.as_str() == Some(request_id) { + return response; + } + *id = serde_json::Value::String(request_id.to_owned()); + serde_json::to_string(&value).unwrap_or(response) +} + pub(crate) fn spawn_response_waiter( client_id: u64, boot_id: String, @@ -109,7 +123,7 @@ pub(crate) fn spawn_response_waiter( "endpoint command ended without a response", ) }); - let response = response.into_bytes(); + let response = correlate_response_id(response, &request_id).into_bytes(); if response.is_empty() { let _ = server_event_tx.blocking_send( ServerEvent::ClientShellEndpointResponseChunkReady { @@ -319,6 +333,20 @@ mod tests { } #[test] + fn endpoint_response_uses_the_client_request_id() { + let response = serde_json::json!({ + "id": "endpoint:boot-a:7:client-shell:1", + "result": { "type": "ok" } + }) + .to_string(); + + let correlated = correlate_response_id(response, "client-shell:1"); + let decoded: serde_json::Value = serde_json::from_str(&correlated).expect("response json"); + + assert_eq!(decoded["id"], "client-shell:1"); + } + + #[test] fn endpoint_responses_are_chunked_without_truncation() { let (response_tx, response_rx) = mpsc::channel(); let (event_tx, mut event_rx) = tokio_mpsc::channel(8); diff --git a/src/server/client_shell.rs b/src/server/client_shell.rs index 8f0a90ee..f511b641 100644 --- a/src/server/client_shell.rs +++ b/src/server/client_shell.rs @@ -8,8 +8,30 @@ pub(super) fn snapshot( boot_id: &str, revision: u64, config_diagnostic: Option<&str>, + location: Option<&crate::server::clients::ClientShellLocation>, ) -> protocol::ClientShellSnapshot { let snapshot = app.session_snapshot(); + let focused_workspace_id = location + .and_then(|location| location.focused_workspace_id.clone()) + .or_else(|| snapshot.focused_workspace_id.clone()); + let focused_tab_id = location + .and_then(|location| location.focused_tab_id().map(str::to_owned)) + .or_else(|| snapshot.focused_tab_id.clone()); + let focused_pane_id = focused_tab_id + .as_deref() + .and_then(|tab_id| app.parse_tab_id(tab_id)) + .and_then(|(workspace_index, tab_index)| { + let pane_id = app + .state + .workspaces + .get(workspace_index)? + .tabs + .get(tab_index)? + .layout + .focused(); + app.public_pane_id(workspace_index, pane_id) + }) + .or_else(|| snapshot.focused_pane_id.clone()); let workspaces = snapshot .workspaces .into_iter() @@ -18,11 +40,22 @@ pub(super) fn snapshot( .map(|(workspace_index, (workspace, state))| { let mut tokens = workspace.tokens.into_iter().collect::<Vec<_>>(); tokens.sort_by(|left, right| left.0.cmp(&right.0)); + let workspace_id = workspace.workspace_id; + let active_tab_id = location + .and_then(|location| location.active_tab_ids.get(&workspace_id)) + .cloned() + .unwrap_or(workspace.active_tab_id); + let active_tab_index = + app.parse_tab_id(&active_tab_id) + .and_then(|(tab_workspace_index, tab_index)| { + (tab_workspace_index == workspace_index).then_some(tab_index) + }); protocol::ClientShellWorkspace { - workspace_id: workspace.workspace_id, - active_tab_id: workspace.active_tab_id, + focused: focused_workspace_id.as_deref() == Some(workspace_id.as_str()), + workspace_id, + active_tab_id, new_workspace_cwd: app - .resolved_new_workspace_cwd_from(workspace_index) + .resolved_new_workspace_cwd_from_tab(workspace_index, active_tab_index) .display() .to_string(), number: workspace.number, @@ -38,7 +71,6 @@ pub(super) fn snapshot( label: worktree.repo_name, is_linked_worktree: worktree.is_linked_worktree, }), - focused: workspace.focused, agent_status: workspace.agent_status, } }) @@ -52,23 +84,28 @@ pub(super) fn snapshot( .iter() .flat_map(|workspace| workspace.tabs.iter()), ) - .map(|(tab, state)| protocol::ClientShellTab { - tab_id: tab.tab_id, - workspace_id: tab.workspace_id, - number: tab.number, - label: tab.label, - custom_label: !state.is_auto_named(), - zoomed: state.zoomed, - focused: tab.focused, - agent_status: tab.agent_status, + .map(|(tab, state)| { + let tab_id = tab.tab_id; + protocol::ClientShellTab { + focused: focused_tab_id.as_deref() == Some(tab_id.as_str()), + tab_id, + workspace_id: tab.workspace_id, + number: tab.number, + label: tab.label, + custom_label: !state.is_auto_named(), + zoomed: state.zoomed, + agent_status: tab.agent_status, + } }) .collect(); let panes = snapshot .panes .into_iter() .map(|pane| { + let pane_id = pane.pane_id; + let focused = focused_pane_id.as_deref() == Some(pane_id.as_str()); let right_click_passthrough = app - .parse_pane_id(&pane.pane_id) + .parse_pane_id(&pane_id) .and_then(|(workspace_index, pane_id)| { app.state .workspaces @@ -77,13 +114,13 @@ pub(super) fn snapshot( }) .is_some_and(|pane| pane.right_click_passthrough); protocol::ClientShellPane { - pane_id: pane.pane_id, + pane_id, workspace_id: pane.workspace_id, tab_id: pane.tab_id, label: pane.label, cwd: pane.cwd, foreground_cwd: pane.foreground_cwd, - focused: pane.focused, + focused, right_click_passthrough, } }) @@ -92,12 +129,14 @@ pub(super) fn snapshot( .agents .into_iter() .map(|agent| { + let pane_id = agent.pane_id; + let focused = focused_pane_id.as_deref() == Some(pane_id.as_str()); let mut state_labels = agent.state_labels.into_iter().collect::<Vec<_>>(); state_labels.sort_by(|left, right| left.0.cmp(&right.0)); let mut tokens = agent.tokens.into_iter().collect::<Vec<_>>(); tokens.sort_by(|left, right| left.0.cmp(&right.0)); protocol::ClientShellAgent { - pane_id: agent.pane_id, + pane_id, workspace_id: agent.workspace_id, tab_id: agent.tab_id, name: agent.name, @@ -110,7 +149,7 @@ pub(super) fn snapshot( state_change_seq: agent.state_change_seq, state_labels, tokens, - focused: agent.focused, + focused, } }) .collect(); @@ -125,11 +164,17 @@ pub(super) fn snapshot( .filter_map(|entry| app.public_pane_id(entry.ws_idx, entry.pane_id)) .collect(); - let zoomed = app - .state - .active - .and_then(|index| app.state.workspaces.get(index)) - .is_some_and(|workspace| workspace.zoomed); + let zoomed = focused_tab_id + .as_deref() + .and_then(|tab_id| app.parse_tab_id(tab_id)) + .and_then(|(workspace_index, tab_index)| { + app.state + .workspaces + .get(workspace_index)? + .tabs + .get(tab_index) + }) + .is_some_and(|tab| tab.zoomed); let tab_bar_right = app .state .tab_bar_right @@ -183,9 +228,9 @@ pub(super) fn snapshot( integration_updates_available: app.state.integration_updates_available(), worktree_directory: app.state.worktree_directory.to_string_lossy().into_owned(), release_notes, - focused_workspace_id: snapshot.focused_workspace_id, - focused_tab_id: snapshot.focused_tab_id, - focused_pane_id: snapshot.focused_pane_id, + focused_workspace_id, + focused_tab_id, + focused_pane_id, tab_bar_right, tab_bar_right_separator: app.state.tab_bar_right_separator.clone(), agent_view_label, @@ -209,18 +254,18 @@ pub(super) struct RenderedPaneSurface { pub(super) fn render_pane_surface( app: &mut app::App, + target: Option<crate::ui::TabSurfaceTarget>, area: Rect, - is_foreground: bool, + resize_panes: bool, + show_popup: bool, cell_size: crate::kitty_graphics::HostCellSize, graphics_delivery: &crate::kitty_graphics::surface::DeliveryCache, client_id: u64, ) -> RenderedPaneSurface { - let content_revisions_before = app - .state - .active - .and_then(|workspace_index| { - let workspace = app.state.workspaces.get(workspace_index)?; - let tab = workspace.tabs.get(workspace.active_tab)?; + let content_revisions_before = target + .and_then(|target| { + let workspace = app.state.workspaces.get(target.workspace_index)?; + let tab = workspace.tabs.get(target.tab_index)?; Some( tab.layout .pane_ids() @@ -229,7 +274,7 @@ pub(super) fn render_pane_surface( app.state .runtime_for_pane_in_workspace( &app.terminal_runtimes, - workspace_index, + target.workspace_index, pane_id, ) .map(|runtime| (pane_id, runtime.content_seq())) @@ -242,14 +287,14 @@ pub(super) fn render_pane_surface( crate::server::render_stream::render_tab_surface_virtual( &app.state, &app.terminal_runtimes, + target, area, - is_foreground, + resize_panes, cell_size, ); - let panes = app - .state - .active - .map(|workspace_index| { + let panes = target + .map(|target| { + let workspace_index = target.workspace_index; layout .pane_infos .iter() @@ -340,12 +385,15 @@ pub(super) fn render_pane_surface( }) }) .collect(); - let popup = render_popup_surface(app, area, is_foreground, cell_size); + let popup = show_popup + .then(|| render_popup_surface(app, area, resize_panes, cell_size)) + .flatten(); let (graphics, next_graphics_delivery) = crate::server::client_shell_graphics::collect( app, &layout.pane_infos, &layout.split_borders, popup.as_deref(), + target, cell_size, graphics_delivery, client_id, @@ -363,22 +411,19 @@ pub(super) fn render_pane_surface( fn render_popup_surface( app: &app::App, area: Rect, - is_foreground: bool, + resize_runtime: bool, cell_size: crate::kitty_graphics::HostCellSize, ) -> Option<Box<protocol::ClientShellPopupSurface>> { let popup = app.state.popup_pane.as_ref()?; - let geometry = if is_foreground { + let geometry = if resize_runtime { resize_popup_runtime(app, area, cell_size)? } else { crate::popup_size::resolve_popup_geometry(popup.width, popup.height, area)? }; let runtime = app.terminal_runtimes.get(&popup.terminal_id)?; let content_area = Rect::new(0, 0, geometry.inner.width, geometry.inner.height); - let (buffer, mut cursor) = + let (buffer, cursor) = crate::server::render_stream::render_terminal_virtual(runtime, content_area); - if !is_foreground { - cursor = None; - } let hyperlinks = runtime.visible_hyperlinks(content_area); let title = app .state @@ -518,7 +563,7 @@ mod tests { preview: true, }); - let snapshot = snapshot(&app, "boot", 7, None); + let snapshot = snapshot(&app, "boot", 7, None, None); assert_eq!(snapshot.update_available.as_deref(), Some("0.8.3")); assert_eq!(snapshot.update_install_command, "herdr update"); @@ -554,11 +599,11 @@ mod tests { state: crate::integration::IntegrationStatusKind::NotInstalled, }]; - assert!(!snapshot(&app, "boot", 1, None).integration_updates_available); + assert!(!snapshot(&app, "boot", 1, None, None).integration_updates_available); app.state.integration_recommendations[0].state = crate::integration::IntegrationStatusKind::Outdated; - assert!(snapshot(&app, "boot", 2, None).integration_updates_available); + assert!(snapshot(&app, "boot", 2, None, None).integration_updates_available); } #[test] diff --git a/src/server/client_shell_graphics.rs b/src/server/client_shell_graphics.rs index b16001cf..578350b2 100644 --- a/src/server/client_shell_graphics.rs +++ b/src/server/client_shell_graphics.rs @@ -6,6 +6,7 @@ pub(crate) fn collect( pane_infos: &[crate::layout::PaneInfo], split_borders: &[crate::layout::SplitBorder], popup: Option<&ClientShellPopupSurface>, + target: Option<crate::ui::TabSurfaceTarget>, cell_size: crate::kitty_graphics::HostCellSize, delivered: &DeliveryCache, client_id: u64, @@ -14,6 +15,7 @@ pub(crate) fn collect( crate::kitty_graphics::surface::collect_scene( app, crate::ui::TabSurfaceView { + target, pane_infos, split_borders, }, diff --git a/src/server/clients.rs b/src/server/clients.rs index 3860f826..4f1baadb 100644 --- a/src/server/clients.rs +++ b/src/server/clients.rs @@ -60,6 +60,75 @@ pub(crate) enum DeferredRender { Full, } +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct ClientShellLocation { + pub(crate) focused_workspace_id: Option<String>, + pub(crate) active_tab_ids: HashMap<String, String>, +} + +pub(crate) struct ClientShellTopology { + pub(crate) focused_workspace_id: Option<String>, + pub(crate) fallback_workspace_id: Option<String>, + pub(crate) active_tab_ids: HashMap<String, String>, + pub(crate) tab_workspace_ids: HashMap<String, String>, +} + +impl ClientShellLocation { + pub(crate) fn from_snapshot(snapshot: &crate::protocol::ClientShellSnapshot) -> Self { + Self { + focused_workspace_id: snapshot.focused_workspace_id.clone(), + active_tab_ids: snapshot + .workspaces + .iter() + .map(|workspace| { + ( + workspace.workspace_id.clone(), + workspace.active_tab_id.clone(), + ) + }) + .collect(), + } + } + + pub(crate) fn focused_tab_id(&self) -> Option<&str> { + self.focused_workspace_id + .as_deref() + .and_then(|workspace_id| self.active_tab_ids.get(workspace_id)) + .map(String::as_str) + } + + pub(crate) fn focus_workspace(&mut self, workspace_id: String) { + self.focused_workspace_id = Some(workspace_id); + } + + pub(crate) fn focus_tab(&mut self, workspace_id: String, tab_id: String) { + self.focused_workspace_id = Some(workspace_id.clone()); + self.active_tab_ids.insert(workspace_id, tab_id); + } + + pub(crate) fn reconcile(&mut self, topology: &ClientShellTopology) { + self.active_tab_ids.retain(|workspace_id, tab_id| { + topology.active_tab_ids.contains_key(workspace_id) + && topology.tab_workspace_ids.get(tab_id) == Some(workspace_id) + }); + for (workspace_id, tab_id) in &topology.active_tab_ids { + self.active_tab_ids + .entry(workspace_id.clone()) + .or_insert_with(|| tab_id.clone()); + } + if self + .focused_workspace_id + .as_ref() + .is_none_or(|workspace_id| !topology.active_tab_ids.contains_key(workspace_id)) + { + self.focused_workspace_id = topology + .focused_workspace_id + .clone() + .or_else(|| topology.fallback_workspace_id.clone()); + } + } +} + /// A connected client tracked by the server. pub(crate) struct ClientConnection { /// Whether this connection owns the Herdr shell or one direct terminal stream. @@ -102,12 +171,17 @@ pub(crate) struct ClientConnection { shell_held_inputs: HashMap<ClientShellPressId, ClientShellHeldInput>, /// Temporary files staged from this client's local clipboard image pastes. pub(crate) staged_clipboard_files: Vec<PathBuf>, + /// Connection-local workspace and tab projection for a client-owned shell. + pub(crate) shell_location: Option<ClientShellLocation>, /// Last coherent shell replacement sent to this client. pub(crate) shell_snapshot: Option<crate::protocol::ClientShellSnapshot>, /// Monotonic shell replacement revision for this connection. pub(crate) shell_projection_revision: u64, /// Whether this shell is waiting for one ordered endpoint command response. pub(crate) shell_endpoint_command_in_flight: bool, + /// Request id and buffered response for a deferred worktree-created navigation. + pub(crate) shell_deferred_navigation_request_id: Option<String>, + pub(crate) shell_deferred_navigation_response: Option<Vec<u8>>, /// Whether this shell uses the endpoint-owned keymap rather than a client-owned keymap. pub(crate) shell_uses_endpoint_keybindings: bool, /// Channels for sending framed ServerMessage data to the client writer thread. @@ -162,9 +236,12 @@ impl ClientConnection { host_keyboard_protocol_active: None, shell_held_inputs: HashMap::new(), staged_clipboard_files: Vec::new(), + shell_location: None, shell_snapshot: None, shell_projection_revision: 0, shell_endpoint_command_in_flight: false, + shell_deferred_navigation_request_id: None, + shell_deferred_navigation_response: None, shell_uses_endpoint_keybindings: false, writer, } diff --git a/src/server/headless.rs b/src/server/headless.rs index 1cf8ebd4..c77462ab 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -51,8 +51,7 @@ use crate::server::client_accept::{ accept_pending_client_connections, reject_pending_client_connections, }; use crate::server::client_shell::{ - render_pane_surface as render_client_shell_pane_surface, - resize_popup_runtime as resize_client_shell_popup_runtime, snapshot as client_shell_snapshot, + render_pane_surface as render_client_shell_pane_surface, snapshot as client_shell_snapshot, }; use crate::server::client_transport::ServerEvent; use crate::server::clients::{ @@ -73,6 +72,7 @@ use crate::server::socket_paths::{ use crate::server::terminal_attach::paste_payload_for_runtime; mod bootstrap; +mod client_views; mod lifecycle; mod notifications; mod pane_graphics; @@ -204,8 +204,12 @@ pub struct HeadlessServer { clients: HashMap<u64, ClientConnection>, #[cfg(unix)] next_client_id: u64, - /// The client currently driving the shared pane runtime size, theme, and input keybindings. + /// The client currently driving session-wide host presentation and side effects. foreground_client_id: Option<u64>, + /// Ephemeral shell connection controlling PTY geometry for each stable tab id. + tab_geometry_controllers: HashMap<String, u64>, + /// Stable tab id whose viewers may see and interact with the one terminal popup. + popup_owner_tab_id: Option<String>, /// Process-local identity used to reject shell replacements from an earlier server boot. client_shell_boot_id: String, /// Outer window title last pushed, paired with the client that received it. @@ -345,6 +349,8 @@ impl HeadlessServer { #[cfg(unix)] next_client_id: 1, foreground_client_id: None, + tab_geometry_controllers: HashMap::new(), + popup_owner_tab_id: None, client_shell_boot_id: format!( "{}-{}", std::process::id(), @@ -726,6 +732,7 @@ impl HeadlessServer { stamp } + #[cfg(unix)] fn resize_shared_runtime_to_effective_size(&mut self) { self.resize_shared_runtime_to_effective_size_with_pending_agent_resumes(true); } @@ -738,35 +745,20 @@ impl HeadlessServer { &mut self, start_pending_agent_resumes: bool, ) { - if self.foreground_client_id.is_none() { - return; - } let Some(client_id) = self.foreground_client_id else { return; }; let Some(client) = self.clients.get(&client_id) else { return; }; - let client_shell = matches!(client.mode, ClientConnectionMode::ClientShell); + if matches!(client.mode, ClientConnectionMode::ClientShell) { + self.resize_shell_tab_if_controller(client_id, start_pending_agent_resumes); + return; + } let cell_size = client.cell_size; let (cols, rows) = self.effective_size; let area = Rect::new(0, 0, cols, rows); - if client_shell { - let cell_size = if cell_size.is_known() { - cell_size - } else { - Default::default() - }; - self.app.state.view.terminal_area = area; - let _ = resize_client_shell_popup_runtime(&self.app, area, cell_size); - crate::ui::compute_tab_surface( - &self.app.state, - &self.app.terminal_runtimes, - area, - true, - cell_size, - ); - } else if self.app.state.kitty_graphics_enabled && cell_size.is_known() { + if self.app.state.kitty_graphics_enabled && cell_size.is_known() { crate::ui::compute_view_with_cell_size( &mut self.app.state, &self.app.terminal_runtimes, @@ -959,8 +951,24 @@ impl HeadlessServer { fn remove_client(&mut self, client_id: u64) -> bool { self.retire_direct_graphics_for_client(client_id); + let disconnected_focus = self + .clients + .get(&client_id) + .filter(|client| client.is_shell_client() && client.outer_terminal_focus == Some(true)) + .and_then(|_| self.shell_focus_target(client_id)); + let should_release_focus = disconnected_focus.as_ref().is_some_and(|target| { + !self.clients.iter().any(|(&other_id, client)| { + other_id != client_id + && client.is_shell_client() + && client.outer_terminal_focus == Some(true) + && self.shell_tab_id_for_client(other_id).as_deref() + == Some(target.tab_id.as_str()) + }) + }); let was_foreground = self.foreground_client_id == Some(client_id); let removed = self.clients.remove(&client_id); + self.tab_geometry_controllers + .retain(|_, controller_id| *controller_id != client_id); if let Some(mut removed) = removed { self.release_client_shell_inputs(client_id, &mut removed); crate::server::clipboard_image::remove_files(removed.staged_clipboard_files); @@ -974,6 +982,11 @@ impl HeadlessServer { } } } + if should_release_focus { + if let Some(target) = disconnected_focus.as_ref() { + self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Lost); + } + } if was_foreground { self.promote_latest_remaining_client() } else { @@ -1011,24 +1024,18 @@ impl HeadlessServer { } } - fn client_removal_needs_shared_resize(&self, client_id: u64) -> bool { - if self.foreground_client_id == Some(client_id) { - return true; - } - matches!( - self.clients.get(&client_id).map(|client| &client.mode), - Some( - ClientConnectionMode::TerminalAttach { .. } - | ClientConnectionMode::TerminalObserve { .. } - ) - ) && self.foreground_client_id.is_some() - } - fn remove_client_and_resize_if_needed(&mut self, client_id: u64) { - let needs_shared_resize = self.client_removal_needs_shared_resize(client_id); - let foreground_changed = self.remove_client(client_id); - if needs_shared_resize || foreground_changed { - self.resize_shared_runtime_to_effective_size(); + let restore_shell_controller = self.clients.get(&client_id).and_then(|client| { + let ClientConnectionMode::TerminalAttach { terminal_id } = &client.mode else { + return None; + }; + self.shell_geometry_controller_for_terminal(terminal_id) + }); + self.remove_client(client_id); + if let Some((controller_id, target)) = restore_shell_controller { + self.restore_shell_tab_geometry(controller_id, target); + } else { + self.resize_tabs_for_only_shell_client(true); } } @@ -1191,7 +1198,6 @@ impl HeadlessServer { } protocol::ClientClipboardImageTarget::Pane(pane_id) => { if self.handoff_in_progress - || self.app.state.popup_pane.is_some() || !self.clients.get(&client_id).is_some_and(|client| { matches!(client.mode, ClientConnectionMode::ClientShell) }) @@ -1202,16 +1208,21 @@ impl HeadlessServer { else { return false; }; - let foreground_changed = self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); + let popup_blocks_input = self.app.state.popup_pane.is_some() + && self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id); + if popup_blocks_input + || !self.shell_client_views_pane(client_id, workspace_index, runtime_pane_id) + { + return false; } + let foreground_changed = self.promote_client_to_foreground(client_id); + let geometry_changed = self.claim_shell_tab_geometry(client_id, false); let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( &self.app.terminal_runtimes, workspace_index, runtime_pane_id, ) else { - return foreground_changed; + return foreground_changed | geometry_changed; }; if let Err(err) = apply_client_pane_input_events( runtime, @@ -1238,15 +1249,15 @@ impl HeadlessServer { else { return false; }; - if popup_terminal_id.as_str() != terminal_id { + if popup_terminal_id.as_str() != terminal_id + || self.popup_owner_tab_id != self.shell_tab_id_for_client(client_id) + { return false; } let foreground_changed = self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } + let geometry_changed = self.claim_shell_tab_geometry(client_id, false); let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { - return foreground_changed; + return foreground_changed | geometry_changed; }; if let Err(err) = apply_client_popup_input_events( runtime, @@ -1928,29 +1939,36 @@ impl HeadlessServer { } else { self.server_config_diagnostic_without_keybindings.as_deref() }; - let snapshot = client_shell_snapshot( + let seed_snapshot = client_shell_snapshot( &self.app, &self.client_shell_boot_id, connection.shell_projection_revision, config_diagnostic, + None, ); - let snapshot_message = match crate::protocol::endpoint::snapshot_message(&snapshot) - { - Ok(message) => message, - Err(err) => { - warn!(client_id, err = %err, "failed to encode endpoint snapshot"); - return false; - } - }; - connection.shell_snapshot = Some(snapshot); + let location = + crate::server::clients::ClientShellLocation::from_snapshot(&seed_snapshot); + let snapshot_message = + match crate::protocol::endpoint::snapshot_message(&seed_snapshot) { + Ok(message) => message, + Err(err) => { + warn!(client_id, err = %err, "failed to encode endpoint snapshot"); + return false; + } + }; + connection.shell_location = Some(location); + connection.shell_snapshot = Some(seed_snapshot); self.clients.insert(client_id, connection); + if self.app.state.popup_pane.is_some() && self.popup_owner_tab_id.is_none() { + self.popup_owner_tab_id = self.shell_tab_id_for_client(client_id); + } self.send_to_client(client_id, snapshot_message); self.foreground_client_id = Some(client_id); if first_app_client { self.app.mark_git_status_refresh_due(Instant::now()); } self.sync_foreground_client_state(); - self.resize_shared_runtime_to_effective_size(); + self.claim_unowned_shell_tab_geometry(client_id, true); self.nudge_handoff_panes_on_first_client_attach(); true } @@ -2169,7 +2187,7 @@ impl HeadlessServer { client.pixel_mouse = pixel_mouse && observed.is_known(); client.request_repaint(); self.promote_client_to_foreground(client_id); - self.resize_shared_runtime_to_effective_size(); + self.resize_shell_tab_if_controller(client_id, true); true } ServerEvent::ClientShellHostTheme { client_id, update } => { @@ -2196,7 +2214,7 @@ impl HeadlessServer { changed } ServerEvent::ClientShellFocus { client_id, focused } => { - let Some(client) = self.clients.get_mut(&client_id) else { + let Some(client) = self.clients.get(&client_id) else { return false; }; if !matches!(client.mode, ClientConnectionMode::ClientShell) @@ -2204,20 +2222,38 @@ impl HeadlessServer { { return false; } - client.outer_terminal_focus = Some(focused); + let tab_id = self.shell_tab_id_for_client(client_id); + let another_focused_viewer = self.clients.iter().any(|(&other_id, client)| { + other_id != client_id + && client.is_shell_client() + && client.outer_terminal_focus == Some(true) + && self.shell_tab_id_for_client(other_id) == tab_id + }); + if let Some(client) = self.clients.get_mut(&client_id) { + client.outer_terminal_focus = Some(focused); + } if focused { self.promote_client_to_foreground(client_id); - self.resize_shared_runtime_to_effective_size_before_input(); - self.app - .send_outer_focus_event(crate::ghostty::FocusEvent::Gained); - true - } else if self.foreground_client_id == Some(client_id) { - self.app.state.outer_terminal_focus = Some(false); - self.app - .send_outer_focus_event(crate::ghostty::FocusEvent::Lost); + self.claim_shell_tab_geometry(client_id, false); + if !another_focused_viewer { + if let Some(target) = self.shell_focus_target(client_id) { + self.send_shell_focus_target( + &target, + crate::ghostty::FocusEvent::Gained, + ); + } + } true } else { - false + if self.foreground_client_id == Some(client_id) { + self.app.state.outer_terminal_focus = Some(false); + } + if !another_focused_viewer { + if let Some(target) = self.shell_focus_target(client_id) { + self.send_shell_focus_target(&target, crate::ghostty::FocusEvent::Lost); + } + } + true } } ServerEvent::ClientShellMouseCapture { client_id, enabled } => { @@ -2266,11 +2302,10 @@ impl HeadlessServer { runtime.current_size(), runtime.pixel_size(), ); - if self.app.state.popup_pane.is_some() - || !self - .app - .state - .pane_visible_on_active_surface(workspace_index, runtime_pane_id) + let popup_blocks_input = self.app.state.popup_pane.is_some() + && self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id); + if popup_blocks_input + || !self.shell_client_views_pane(client_id, workspace_index, runtime_pane_id) { let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( &self.app.terminal_runtimes, @@ -2305,21 +2340,20 @@ impl HeadlessServer { } let foreground_changed = interaction && self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } + let geometry_changed = + interaction && self.claim_shell_tab_geometry(client_id, false); let Some(runtime) = self.app.state.runtime_for_pane_in_workspace( &self.app.terminal_runtimes, workspace_index, runtime_pane_id, ) else { - return foreground_changed; + return foreground_changed | geometry_changed; }; let scroll_before = runtime.scroll_metrics(); if let Err(err) = apply_client_pane_input_events(runtime, &events) { warn!(client_id, pane_id, err = %err, "targeted client shell input failed"); } - foreground_changed || runtime.scroll_metrics() != scroll_before + foreground_changed | geometry_changed || runtime.scroll_metrics() != scroll_before } ServerEvent::ClientShellPopupInput { client_id, @@ -2358,6 +2392,26 @@ impl HeadlessServer { runtime.current_size(), runtime.pixel_size(), ); + if self.popup_owner_tab_id != self.shell_tab_id_for_client(client_id) { + let releases = events + .into_iter() + .filter(client_pane_input_releases_press) + .collect::<Vec<_>>(); + if releases.is_empty() { + return false; + } + if let Some(client) = self.clients.get_mut(&client_id) { + client.track_shell_input( + ClientShellInputTarget::Popup(terminal_id.clone()), + &releases, + ); + } + let scroll_before = runtime.scroll_metrics(); + if let Err(err) = apply_client_popup_input_events(runtime, &releases) { + warn!(client_id, terminal_id, err = %err, "targeted client popup release failed"); + } + return runtime.scroll_metrics() != scroll_before; + } let interaction = client_pane_input_has_interaction(&events); if let Some(client) = self.clients.get_mut(&client_id) { client.track_shell_input( @@ -2367,17 +2421,16 @@ impl HeadlessServer { } let foreground_changed = interaction && self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } + let geometry_changed = + interaction && self.claim_shell_tab_geometry(client_id, false); let Some(runtime) = self.app.terminal_runtimes.get(&popup_terminal_id) else { - return foreground_changed; + return foreground_changed | geometry_changed; }; let scroll_before = runtime.scroll_metrics(); if let Err(err) = apply_client_popup_input_events(runtime, &events) { warn!(client_id, terminal_id, err = %err, "targeted client popup input failed"); } - foreground_changed || runtime.scroll_metrics() != scroll_before + foreground_changed | geometry_changed || runtime.scroll_metrics() != scroll_before } ServerEvent::ClientShellEndpointRequestError { client_id, @@ -2402,7 +2455,7 @@ impl HeadlessServer { ServerEvent::ClientShellEndpointRequest { client_id, boot_id, - request, + mut request, } => { let Some(client) = self.clients.get(&client_id) else { return false; @@ -2443,6 +2496,11 @@ impl HeadlessServer { return true; } + let api_request_id = format!( + "endpoint:{}:{client_id}:{request_id}", + self.client_shell_boot_id + ); + request.id = api_request_id.clone(); let (respond_to, response_rx) = std::sync::mpsc::channel(); if let Err(err) = crate::server::client_commands::spawn_response_waiter( client_id, @@ -2462,18 +2520,30 @@ impl HeadlessServer { } if let Some(client) = self.clients.get_mut(&client_id) { client.shell_endpoint_command_in_flight = true; + let deferred_worktree = matches!( + &request.method, + api::schema::Method::WorktreeCreate(_) + | api::schema::Method::WorktreeRemove(_) + ); + let deferred_navigation = matches!( + &request.method, + api::schema::Method::WorktreeCreate(params) if params.focus + ); + client.shell_deferred_navigation_request_id = + deferred_worktree.then(|| api_request_id.clone()); + client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new); } let foreground_changed = self.promote_client_to_foreground(client_id); - if foreground_changed { - self.resize_shared_runtime_to_effective_size_before_input(); - } foreground_changed - | self.handle_api_request_with_shutdown_check(api::ApiRequestMessage { - request: *request, - respond_to, - response_write_complete: None, - stream_active: None, - }) + | self.handle_client_shell_api_request( + client_id, + api::ApiRequestMessage { + request: *request, + respond_to, + response_write_complete: None, + stream_active: None, + }, + ) } ServerEvent::ClientShellEndpointResponseChunkReady { client_id, @@ -2490,11 +2560,42 @@ impl HeadlessServer { if !valid { return false; } + let completed_deferred_response = + self.clients.get_mut(&client_id).and_then(|client| { + let response = client.shell_deferred_navigation_response.as_mut()?; + response.extend_from_slice(&data); + final_chunk + .then(|| client.shell_deferred_navigation_response.take()) + .flatten() + }); if final_chunk { if let Some(client) = self.clients.get_mut(&client_id) { client.shell_endpoint_command_in_flight = false; + client.shell_deferred_navigation_request_id = None; } } + let deferred_tab_id = completed_deferred_response + .as_deref() + .and_then(Self::deferred_endpoint_navigation_tab_id); + let focus_before = self.shell_focus_target(client_id); + let focused_tabs_before = self.focused_shell_tabs(); + let navigation_changed = deferred_tab_id + .as_deref() + .is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, tab_id)); + let geometry_changed = + navigation_changed && self.claim_shell_tab_geometry(client_id, false); + if navigation_changed { + self.reconcile_client_shell_locations(); + let focus_after = self.shell_focus_target(client_id); + let focused_tabs_after = self.focused_shell_tabs(); + self.app.accept_current_focus_without_events(); + self.send_shell_navigation_focus_events( + focus_before.as_ref(), + focus_after.as_ref(), + &focused_tabs_before, + &focused_tabs_after, + ); + } self.send_to_client( client_id, ServerMessage::ClientShellEndpointResponseChunk { @@ -2504,7 +2605,7 @@ impl HeadlessServer { data, }, ); - false + navigation_changed | geometry_changed } ServerEvent::ClientDetach { client_id } => { info!(client_id, "client detached"); @@ -2768,16 +2869,6 @@ impl HeadlessServer { impact } - /// Handles a single API request with shutdown awareness. - /// - /// Also forwards any toast/sound notifications that result from the API - /// request to connected clients. API methods like `pane.report_agent` - /// trigger internal events that may set toast state or would normally - /// play sounds — in headless mode we forward these to clients instead. - fn handle_api_request_with_shutdown_check(&mut self, msg: api::ApiRequestMessage) -> bool { - self.handle_api_request_with_shutdown_check_inner(msg, false) - } - fn handle_api_request_with_render_impact( &mut self, msg: api::ApiRequestMessage, @@ -2789,7 +2880,7 @@ impl HeadlessServer { ) { return self.handle_pane_graphics_stream_frame(msg); } - if self.handle_api_request_with_shutdown_check_inner(msg, false) { + if self.handle_api_request_with_shutdown_check(msg) { RenderImpact::Full } else { RenderImpact::None diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs new file mode 100644 index 00000000..3a0f895c --- /dev/null +++ b/src/server/headless/client_views.rs @@ -0,0 +1,789 @@ +use super::*; +use crate::server::client_shell::resize_popup_runtime; +use crate::server::clients::ClientShellTopology; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct ShellFocusTarget { + pub(super) tab_id: String, + pub(super) workspace_index: usize, + pub(super) pane_id: crate::layout::PaneId, +} + +fn classify_shell_focus_transition<'a>( + before: Option<&'a ShellFocusTarget>, + after: Option<&'a ShellFocusTarget>, + focused_tabs_before: &HashSet<String>, + focused_tabs_after: &HashSet<String>, +) -> (Option<&'a ShellFocusTarget>, Option<&'a ShellFocusTarget>) { + if before == after { + return (None, None); + } + if before.map(|target| target.tab_id.as_str()) == after.map(|target| target.tab_id.as_str()) { + return match (before, after) { + (Some(before), Some(after)) if focused_tabs_after.contains(&after.tab_id) => { + (Some(before), Some(after)) + } + _ => (None, None), + }; + } + let lost = before.filter(|target| { + focused_tabs_before.contains(&target.tab_id) && !focused_tabs_after.contains(&target.tab_id) + }); + let gained = after.filter(|target| { + !focused_tabs_before.contains(&target.tab_id) && focused_tabs_after.contains(&target.tab_id) + }); + (lost, gained) +} + +pub(super) fn forward_proxied_api_response( + proxy: Option<( + std::sync::mpsc::Sender<String>, + std::sync::mpsc::Receiver<String>, + )>, +) -> bool { + let Some((respond_to, response_rx)) = proxy else { + return false; + }; + let Ok(response) = response_rx.recv() else { + return false; + }; + let succeeded = serde_json::from_str::<api::schema::SuccessResponse>(&response).is_ok(); + let _ = respond_to.send(response); + succeeded +} + +impl HeadlessServer { + pub(super) fn default_shell_target(&self) -> Option<crate::ui::TabSurfaceTarget> { + let workspace_index = self.app.state.active?; + let workspace = self.app.state.workspaces.get(workspace_index)?; + Some(crate::ui::TabSurfaceTarget { + workspace_index, + tab_index: workspace.active_tab_index(), + }) + } + + pub(super) fn shell_target_for_client( + &self, + client_id: u64, + ) -> Option<crate::ui::TabSurfaceTarget> { + let tab_id = self + .clients + .get(&client_id)? + .shell_location + .as_ref() + .and_then(crate::server::clients::ClientShellLocation::focused_tab_id); + tab_id + .and_then(|tab_id| self.app.parse_tab_id(tab_id)) + .map(|(workspace_index, tab_index)| crate::ui::TabSurfaceTarget { + workspace_index, + tab_index, + }) + .or_else(|| self.default_shell_target()) + } + + fn tab_id_for_target(&self, target: crate::ui::TabSurfaceTarget) -> Option<String> { + self.app + .public_tab_id(target.workspace_index, target.tab_index) + } + + pub(super) fn shell_tab_id_for_client(&self, client_id: u64) -> Option<String> { + self.shell_target_for_client(client_id) + .and_then(|target| self.tab_id_for_target(target)) + } + + fn client_shell_topology(&self) -> ClientShellTopology { + let focused_workspace_id = self + .app + .state + .active + .map(|workspace_index| self.app.public_workspace_id(workspace_index)); + let fallback_workspace_id = self + .app + .state + .workspaces + .first() + .map(|_| self.app.public_workspace_id(0)); + let mut active_tab_ids = HashMap::new(); + let mut tab_workspace_ids = HashMap::new(); + for (workspace_index, workspace) in self.app.state.workspaces.iter().enumerate() { + let workspace_id = self.app.public_workspace_id(workspace_index); + if let Some(tab_id) = self + .app + .public_tab_id(workspace_index, workspace.active_tab_index()) + { + active_tab_ids.insert(workspace_id.clone(), tab_id); + } + for tab_index in 0..workspace.tabs.len() { + if let Some(tab_id) = self.app.public_tab_id(workspace_index, tab_index) { + tab_workspace_ids.insert(tab_id, workspace_id.clone()); + } + } + } + ClientShellTopology { + focused_workspace_id, + fallback_workspace_id, + active_tab_ids, + tab_workspace_ids, + } + } + + pub(super) fn reconcile_client_shell_locations(&mut self) { + if self.app.state.popup_pane.is_none() { + self.popup_owner_tab_id = None; + } else if self + .popup_owner_tab_id + .as_deref() + .is_some_and(|tab_id| self.app.parse_tab_id(tab_id).is_none()) + { + self.popup_owner_tab_id = None; + self.app.close_popup_pane(); + } else if self.popup_owner_tab_id.is_none() { + self.popup_owner_tab_id = self + .default_shell_target() + .and_then(|target| self.tab_id_for_target(target)); + } + let topology = self.client_shell_topology(); + let live_clients = self.clients.keys().copied().collect::<HashSet<_>>(); + self.tab_geometry_controllers.retain(|tab_id, client_id| { + topology.tab_workspace_ids.contains_key(tab_id) && live_clients.contains(client_id) + }); + for client in self + .clients + .values_mut() + .filter(|client| client.is_shell_client()) + { + let location = client.shell_location.get_or_insert_with(|| { + crate::server::clients::ClientShellLocation { + focused_workspace_id: topology.focused_workspace_id.clone(), + active_tab_ids: topology.active_tab_ids.clone(), + } + }); + location.reconcile(&topology); + } + } + + pub(super) fn focus_all_shell_clients_on_default_target(&mut self) { + let Some(target) = self.default_shell_target() else { + return; + }; + let workspace_id = self.app.public_workspace_id(target.workspace_index); + let Some(tab_id) = self.tab_id_for_target(target) else { + return; + }; + let focus_before = self.shell_focus_targets(); + let focused_tabs_before = self.focused_shell_tabs(); + for client in self + .clients + .values_mut() + .filter(|client| client.is_shell_client()) + { + if let Some(location) = client.shell_location.as_mut() { + location.focus_tab(workspace_id.clone(), tab_id.clone()); + } + } + let (lost, gained) = + self.shell_location_focus_transitions(focus_before, &focused_tabs_before); + self.app.accept_current_focus_with_api_events(); + self.send_shell_focus_transitions(&lost, &gained); + } + + pub(super) fn focus_shell_client_on_tab(&mut self, client_id: u64, tab_id: &str) -> bool { + let Some((workspace_index, _)) = self.app.parse_tab_id(tab_id) else { + return false; + }; + let workspace_id = self.app.public_workspace_id(workspace_index); + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + let Some(location) = client.shell_location.as_mut() else { + return false; + }; + location.focus_tab(workspace_id, tab_id.to_owned()); + true + } + + fn set_default_shell_target_from_client(&mut self, client_id: u64) -> bool { + let Some(target) = self.shell_target_for_client(client_id) else { + return false; + }; + if self.default_shell_target() == Some(target) { + return false; + } + self.app + .state + .switch_workspace_tab(target.workspace_index, target.tab_index) + } + + fn focus_shell_client_on_default_target(&mut self, client_id: u64) -> bool { + let Some(tab_id) = self + .default_shell_target() + .and_then(|target| self.tab_id_for_target(target)) + else { + return false; + }; + self.focus_shell_client_on_tab(client_id, &tab_id) + } + + fn shell_locations_may_need_reconcile(method: &api::schema::Method) -> bool { + use api::schema::Method; + + matches!( + method, + Method::CommandInvoke(_) + | Method::PaneClose(_) + | Method::PaneEditScrollback(_) + | Method::PaneSplit(_) + | Method::TabClose(_) + | Method::TabCreate(_) + | Method::WorkspaceClose(_) + | Method::WorkspaceCreate(_) + | Method::WorktreeCreate(_) + | Method::WorktreeOpen(_) + | Method::WorktreeRemove(_) + ) + } + + fn shell_endpoint_claims_geometry(method: &api::schema::Method) -> bool { + use api::schema::Method; + + matches!( + method, + Method::CommandInvoke(_) + | Method::LayoutSetSplitRatio(_) + | Method::PaneClose(_) + | Method::PaneCopyMotion(_) + | Method::PaneCopySearch(_) + | Method::PaneEditScrollback(_) + | Method::PaneFocus(_) + | Method::PaneFocusDirection(_) + | Method::PaneInputSet(_) + | Method::PaneLinkActivate(_) + | Method::PaneRename(_) + | Method::PaneResize(_) + | Method::PaneScroll(_) + | Method::PaneSplit(_) + | Method::PaneSwap(_) + | Method::PaneZoom(_) + | Method::TabClose(_) + | Method::TabCreate(_) + | Method::TabFocus(_) + | Method::TabMove(_) + | Method::TabRename(_) + | Method::WorkspaceClose(_) + | Method::WorkspaceCreate(_) + | Method::WorkspaceFocus(_) + | Method::WorkspaceMove(_) + | Method::WorkspaceMoveBlock(_) + | Method::WorkspaceRename(_) + | Method::WorktreeCreate(_) + | Method::WorktreeOpen(_) + | Method::WorktreeRemove(_) + ) + } + + pub(super) fn deferred_endpoint_navigation_tab_id(response: &[u8]) -> Option<String> { + let response = serde_json::from_slice::<serde_json::Value>(response).ok()?; + if response.pointer("/result/type")?.as_str()? != "worktree_created" { + return None; + } + response + .pointer("/result/tab/tab_id")? + .as_str() + .map(str::to_owned) + } + + fn apply_shell_navigation_request( + &mut self, + client_id: u64, + method: &api::schema::Method, + ) -> bool { + match method { + api::schema::Method::WorkspaceFocus(target) => { + let Some(workspace_index) = self.app.parse_workspace_id(&target.workspace_id) + else { + return false; + }; + let workspace_id = self.app.public_workspace_id(workspace_index); + let Some(client) = self.clients.get_mut(&client_id) else { + return false; + }; + let Some(location) = client.shell_location.as_mut() else { + return false; + }; + location.focus_workspace(workspace_id); + true + } + api::schema::Method::TabFocus(target) => { + self.focus_shell_client_on_tab(client_id, &target.tab_id) + } + api::schema::Method::PaneFocus(target) => self + .app + .parse_pane_id(&target.pane_id) + .and_then(|(workspace_index, pane_id)| { + let tab_index = self.app.state.workspaces[workspace_index] + .find_tab_index_for_pane(pane_id)?; + self.app.public_tab_id(workspace_index, tab_index) + }) + .is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, &tab_id)), + api::schema::Method::CommandInvoke(params) => params + .tab_id + .as_deref() + .is_some_and(|tab_id| self.focus_shell_client_on_tab(client_id, tab_id)), + _ => false, + } + } + + fn focus_target_for_surface( + &self, + target: crate::ui::TabSurfaceTarget, + ) -> Option<ShellFocusTarget> { + let pane_id = self + .app + .state + .workspaces + .get(target.workspace_index)? + .tabs + .get(target.tab_index)? + .layout + .focused(); + Some(ShellFocusTarget { + tab_id: self.tab_id_for_target(target)?, + workspace_index: target.workspace_index, + pane_id, + }) + } + + pub(super) fn shell_focus_target(&self, client_id: u64) -> Option<ShellFocusTarget> { + self.focus_target_for_surface(self.shell_target_for_client(client_id)?) + } + + pub(super) fn focused_shell_tabs(&self) -> HashSet<String> { + self.clients + .iter() + .filter(|(_, client)| { + client.is_shell_client() && client.outer_terminal_focus == Some(true) + }) + .filter_map(|(&client_id, _)| self.shell_tab_id_for_client(client_id)) + .collect() + } + + pub(super) fn shell_focus_targets(&self) -> Vec<(u64, Option<ShellFocusTarget>)> { + self.clients + .iter() + .filter(|(_, client)| client.is_shell_client()) + .map(|(&client_id, _)| (client_id, self.shell_focus_target(client_id))) + .collect() + } + + pub(super) fn send_shell_focus_target( + &self, + target: &ShellFocusTarget, + event: crate::ghostty::FocusEvent, + ) { + self.app + .send_pane_focus_event(target.workspace_index, target.pane_id, event); + } + + fn shell_location_focus_transitions( + &self, + focus_before: Vec<(u64, Option<ShellFocusTarget>)>, + focused_tabs_before: &HashSet<String>, + ) -> ( + HashMap<String, ShellFocusTarget>, + HashMap<String, ShellFocusTarget>, + ) { + let focused_tabs_after = self.focused_shell_tabs(); + let mut lost = HashMap::<String, ShellFocusTarget>::new(); + let mut gained = HashMap::<String, ShellFocusTarget>::new(); + for (client_id, before) in focus_before { + let after = self.shell_focus_target(client_id); + let (lost_target, gained_target) = classify_shell_focus_transition( + before.as_ref(), + after.as_ref(), + focused_tabs_before, + &focused_tabs_after, + ); + if let Some(target) = lost_target { + lost.entry(target.tab_id.clone()) + .or_insert_with(|| target.clone()); + } + if let Some(target) = gained_target { + gained + .entry(target.tab_id.clone()) + .or_insert_with(|| target.clone()); + } + } + (lost, gained) + } + + fn send_shell_focus_transitions( + &self, + lost: &HashMap<String, ShellFocusTarget>, + gained: &HashMap<String, ShellFocusTarget>, + ) { + for target in lost.values() { + self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Lost); + } + for target in gained.values() { + self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Gained); + } + } + + pub(super) fn finish_shell_location_reconciliation( + &mut self, + focus_before: Vec<(u64, Option<ShellFocusTarget>)>, + focused_tabs_before: &HashSet<String>, + ) { + let (lost, gained) = + self.shell_location_focus_transitions(focus_before, focused_tabs_before); + self.app.accept_current_focus_without_events(); + self.send_shell_focus_transitions(&lost, &gained); + } + + pub(super) fn send_shell_navigation_focus_events( + &self, + before: Option<&ShellFocusTarget>, + after: Option<&ShellFocusTarget>, + focused_tabs_before: &HashSet<String>, + focused_tabs_after: &HashSet<String>, + ) { + let (lost, gained) = + classify_shell_focus_transition(before, after, focused_tabs_before, focused_tabs_after); + if let Some(target) = lost { + self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Lost); + } + if let Some(target) = gained { + self.send_shell_focus_target(target, crate::ghostty::FocusEvent::Gained); + } + } + + pub(super) fn shell_client_views_pane( + &self, + client_id: u64, + workspace_index: usize, + pane_id: crate::layout::PaneId, + ) -> bool { + let Some(target) = self.shell_target_for_client(client_id) else { + return false; + }; + if target.workspace_index != workspace_index { + return false; + } + let Some(tab) = self + .app + .state + .workspaces + .get(workspace_index) + .and_then(|workspace| workspace.tabs.get(target.tab_index)) + else { + return false; + }; + if tab.zoomed { + tab.layout.focused() == pane_id + } else { + tab.layout.pane_ids().contains(&pane_id) + } + } + + fn finish_shell_tab_geometry_change(&mut self, start_pending_agent_resumes: bool) { + for client in self.clients.values_mut() { + client.request_repaint(); + } + if !start_pending_agent_resumes { + self.app.pending_agent_resume_deadline = None; + return; + } + let now = Instant::now(); + self.app.sync_pending_agent_resume_deadline(now); + if self + .app + .start_pending_agent_resumes(self.app.pending_agent_resume_due(now)) + { + for client in self.clients.values_mut() { + client.request_repaint(); + } + } + } + + pub(super) fn apply_shell_tab_geometry( + &mut self, + client_id: u64, + start_pending_agent_resumes: bool, + ) -> bool { + let Some(target) = self.shell_target_for_client(client_id) else { + return false; + }; + self.apply_shell_tab_geometry_to_target(client_id, target, start_pending_agent_resumes) + } + + fn apply_shell_tab_geometry_to_target( + &mut self, + client_id: u64, + target: crate::ui::TabSurfaceTarget, + start_pending_agent_resumes: bool, + ) -> bool { + let Some(client) = self.clients.get(&client_id) else { + return false; + }; + let (cols, rows) = client.terminal_size; + let cell_size = if client.cell_size.is_known() { + client.cell_size + } else { + crate::kitty_graphics::HostCellSize::default() + }; + let area = Rect::new(0, 0, cols, rows); + if self.app_client_count() == 1 { + for (workspace_index, workspace) in self.app.state.workspaces.iter().enumerate() { + for tab_index in 0..workspace.tabs.len() { + crate::ui::resize_tab_surface( + &self.app.state, + &self.app.terminal_runtimes, + workspace_index, + tab_index, + area, + cell_size, + ); + } + } + } else { + crate::ui::compute_tab_surface_for( + &self.app.state, + &self.app.terminal_runtimes, + Some(target), + area, + true, + cell_size, + ); + } + if self + .popup_owner_tab_id + .as_deref() + .is_some_and(|owner| self.tab_id_for_target(target).as_deref() == Some(owner)) + { + let _ = resize_popup_runtime(&self.app, Rect::new(0, 0, cols, rows), cell_size); + } + self.finish_shell_tab_geometry_change(start_pending_agent_resumes); + true + } + + pub(super) fn resize_tabs_for_only_shell_client( + &mut self, + start_pending_agent_resumes: bool, + ) -> bool { + if self.app_client_count() != 1 { + return false; + } + let Some(client_id) = self.clients.iter().find_map(|(&client_id, client)| { + (client.is_shell_client() && client.writer.is_some()).then_some(client_id) + }) else { + return false; + }; + self.apply_shell_tab_geometry(client_id, start_pending_agent_resumes) + } + + pub(super) fn claim_shell_tab_geometry( + &mut self, + client_id: u64, + start_pending_agent_resumes: bool, + ) -> bool { + let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { + return false; + }; + if self.tab_geometry_controllers.insert(tab_id, client_id) == Some(client_id) { + return false; + } + self.apply_shell_tab_geometry(client_id, start_pending_agent_resumes) + } + + pub(super) fn claim_unowned_shell_tab_geometry( + &mut self, + client_id: u64, + start_pending_agent_resumes: bool, + ) -> bool { + let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { + return false; + }; + if self.tab_geometry_controllers.contains_key(&tab_id) { + return false; + } + self.tab_geometry_controllers.insert(tab_id, client_id); + self.apply_shell_tab_geometry(client_id, start_pending_agent_resumes) + } + + pub(super) fn resize_shell_tab_if_controller( + &mut self, + client_id: u64, + start_pending_agent_resumes: bool, + ) -> bool { + let Some(tab_id) = self.shell_tab_id_for_client(client_id) else { + return false; + }; + if self.tab_geometry_controllers.get(&tab_id) != Some(&client_id) { + return false; + } + self.apply_shell_tab_geometry(client_id, start_pending_agent_resumes) + } + + pub(super) fn shell_geometry_controller_for_terminal( + &self, + terminal_id: &str, + ) -> Option<(u64, crate::ui::TabSurfaceTarget)> { + let target = if self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.terminal_id.as_str() == terminal_id) + { + self.popup_owner_tab_id + .as_deref() + .and_then(|tab_id| self.app.parse_tab_id(tab_id)) + .map(|(workspace_index, tab_index)| crate::ui::TabSurfaceTarget { + workspace_index, + tab_index, + })? + } else { + self.app.state.workspaces.iter().enumerate().find_map( + |(workspace_index, workspace)| { + workspace + .tabs + .iter() + .enumerate() + .find_map(|(tab_index, tab)| { + tab.panes + .values() + .any(|pane| pane.attached_terminal_id.as_str() == terminal_id) + .then_some(crate::ui::TabSurfaceTarget { + workspace_index, + tab_index, + }) + }) + }, + )? + }; + let tab_id = self.tab_id_for_target(target)?; + self.tab_geometry_controllers + .get(&tab_id) + .copied() + .map(|client_id| (client_id, target)) + } + + pub(super) fn restore_shell_tab_geometry( + &mut self, + client_id: u64, + target: crate::ui::TabSurfaceTarget, + ) -> bool { + self.apply_shell_tab_geometry_to_target(client_id, target, true) + } + + /// Applies a public socket request, including its session-wide focus projection. + pub(super) fn handle_api_request_with_shutdown_check( + &mut self, + mut msg: api::ApiRequestMessage, + ) -> bool { + let target_before = self.default_shell_target(); + let popup_before = self.app.state.popup_pane.is_some(); + let explicit_public_focus_target = match &msg.request.method { + api::schema::Method::WorkspaceFocus(params) => self + .app + .parse_workspace_id(¶ms.workspace_id) + .and_then(|workspace_index| { + let workspace = self.app.state.workspaces.get(workspace_index)?; + Some(crate::ui::TabSurfaceTarget { + workspace_index, + tab_index: workspace.active_tab_index(), + }) + }), + api::schema::Method::TabFocus(params) => { + self.app + .parse_tab_id(¶ms.tab_id) + .map(|(workspace_index, tab_index)| crate::ui::TabSurfaceTarget { + workspace_index, + tab_index, + }) + } + api::schema::Method::PaneFocus(params) => self + .app + .parse_pane_id(¶ms.pane_id) + .and_then(|(workspace_index, pane_id)| { + let workspace = self.app.state.workspaces.get(workspace_index)?; + Some(crate::ui::TabSurfaceTarget { + workspace_index, + tab_index: workspace.find_tab_index_for_pane(pane_id)?, + }) + }), + _ => None, + }; + let create_focus_requested = match &msg.request.method { + api::schema::Method::WorkspaceCreate(params) => params.focus, + api::schema::Method::TabCreate(params) => params.focus, + _ => false, + }; + let inspect_worktree_open = matches!( + &msg.request.method, + api::schema::Method::WorktreeOpen(params) if params.focus + ); + let response_proxy = inspect_worktree_open.then(|| { + let (proxy_tx, proxy_rx) = std::sync::mpsc::channel(); + let original = std::mem::replace(&mut msg.respond_to, proxy_tx); + (original, proxy_rx) + }); + let reconcile = Self::shell_locations_may_need_reconcile(&msg.request.method); + let changed = self.handle_api_request_with_shutdown_check_inner(msg, false); + let worktree_open_succeeded = forward_proxied_api_response(response_proxy); + let target_changed = self.default_shell_target() != target_before; + let public_focus_succeeded = explicit_public_focus_target + .is_some_and(|target| self.default_shell_target() == Some(target)) + || (create_focus_requested && target_changed) + || worktree_open_succeeded; + if public_focus_succeeded { + self.focus_all_shell_clients_on_default_target(); + } + if reconcile || target_changed || self.app.state.popup_pane.is_some() != popup_before { + self.reconcile_client_shell_locations(); + } + changed + } + + pub(super) fn handle_client_shell_api_request( + &mut self, + client_id: u64, + msg: api::ApiRequestMessage, + ) -> bool { + let focus_before = self.shell_focus_target(client_id); + let focused_tabs_before = self.focused_shell_tabs(); + let method_claims_geometry = Self::shell_endpoint_claims_geometry(&msg.request.method); + let reconcile = Self::shell_locations_may_need_reconcile(&msg.request.method); + let all_focus_before = reconcile.then(|| self.shell_focus_targets()); + let navigation_changed = + self.apply_shell_navigation_request(client_id, &msg.request.method); + self.set_default_shell_target_from_client(client_id); + let popup_before = self.app.state.popup_pane.is_some(); + let popup_owner = self.shell_tab_id_for_client(client_id); + let changed = self.handle_api_request_with_shutdown_check_inner(msg, false); + self.focus_shell_client_on_default_target(client_id); + if !popup_before && self.app.state.popup_pane.is_some() { + self.popup_owner_tab_id = popup_owner; + } + if reconcile || self.app.state.popup_pane.is_some() != popup_before { + self.reconcile_client_shell_locations(); + } + if let Some(all_focus_before) = all_focus_before { + self.finish_shell_location_reconciliation(all_focus_before, &focused_tabs_before); + } else { + let focus_after = self.shell_focus_target(client_id); + let focused_tabs_after = self.focused_shell_tabs(); + self.app.accept_current_focus_without_events(); + self.send_shell_navigation_focus_events( + focus_before.as_ref(), + focus_after.as_ref(), + &focused_tabs_before, + &focused_tabs_after, + ); + } + let geometry_changed = method_claims_geometry + && (self.claim_shell_tab_geometry(client_id, false) + || self.resize_shell_tab_if_controller(client_id, false)); + changed | navigation_changed | geometry_changed + } +} diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index 3587b5cf..92020d63 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -320,7 +320,20 @@ impl HeadlessServer { /// in the headless server — use this method instead. /// /// Returns true if the event changed visual state (requiring a re-render). - pub(super) fn handle_internal_event_with_forwarding(&mut self, ev: AppEvent) -> bool { + pub(super) fn handle_internal_event_with_forwarding(&mut self, mut ev: AppEvent) -> bool { + let mut focused_worktree_response = if let AppEvent::WorktreeAddFinished(result) = &mut ev { + result + .api_request + .as_mut() + .filter(|request| request.focus) + .map(|request| { + let (proxy_tx, proxy_rx) = std::sync::mpsc::channel(); + let original = std::mem::replace(&mut request.respond_to, proxy_tx); + (original, proxy_rx) + }) + } else { + None + }; match &ev { AppEvent::TerminalBell { pane_id, count } => { if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { @@ -592,7 +605,39 @@ impl HeadlessServer { true } + AppEvent::WorktreeAddFinished(result) => { + let deferred_request_id = result + .api_request + .as_ref() + .map(|request| request.id.as_str()); + let shell_navigation_pending = deferred_request_id.is_some_and(|request_id| { + self.clients.values().any(|client| { + client.shell_deferred_navigation_request_id.as_deref() == Some(request_id) + }) + }); + let changed = self.app.handle_internal_event_with_render_impact(ev); + let api_focus_succeeded = super::client_views::forward_proxied_api_response( + focused_worktree_response.take(), + ); + self.reconcile_client_shell_locations(); + if shell_navigation_pending { + self.app.accept_current_focus_without_events(); + } else if api_focus_succeeded { + self.focus_all_shell_clients_on_default_target(); + } + changed + } + AppEvent::WorktreeRemoveFinished(_) => { + let focus_before = self.shell_focus_targets(); + let focused_tabs_before = self.focused_shell_tabs(); + let changed = self.app.handle_internal_event_with_render_impact(ev); + self.reconcile_client_shell_locations(); + self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before); + changed + } AppEvent::PaneDied { pane_id } => { + let focus_before = self.shell_focus_targets(); + let focused_tabs_before = self.focused_shell_tabs(); let pane_id_val = *pane_id; let terminal_id = self.app.state.workspaces.iter().find_map(|ws| { ws.tabs.iter().find_map(|tab| { @@ -612,6 +657,8 @@ impl HeadlessServer { } self.app.handle_internal_event(ev); + self.reconcile_client_shell_locations(); + self.finish_shell_location_reconciliation(focus_before, &focused_tabs_before); if self.app.find_pane(pane_id_val).is_none() { if let Some(terminal_id) = terminal_id { diff --git a/src/server/headless/render.rs b/src/server/headless/render.rs index 19581abc..991d1861 100644 --- a/src/server/headless/render.rs +++ b/src/server/headless/render.rs @@ -1,55 +1,58 @@ use super::*; impl HeadlessServer { - fn focused_pane_graphics_demand(&self) -> bool { + fn shell_focused_runtime( + &self, + client_id: u64, + ) -> Option<(&crate::terminal::TerminalRuntime, crate::layout::PaneId)> { + if self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id) { + if let Some(popup) = &self.app.state.popup_pane { + return self + .app + .terminal_runtimes + .get(&popup.terminal_id) + .map(|runtime| (runtime, popup.pane_id)); + } + } + let target = self.shell_target_for_client(client_id)?; + let tab = self + .app + .state + .workspaces + .get(target.workspace_index)? + .tabs + .get(target.tab_index)?; + let pane_id = tab.layout.focused(); self.app .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - .and_then(crate::workspace::Workspace::focused_pane_id) - .is_some_and(|pane_id| self.app.pane_graphics.active_for_pane(pane_id)) + .runtime_for_pane_in_workspace( + &self.app.terminal_runtimes, + target.workspace_index, + pane_id, + ) + .map(|runtime| (runtime, pane_id)) } pub(super) fn stream_host_mouse_capture_mode(&mut self) { - let endpoint_shell_mouse = self.app.state.popup_pane.is_some() - || self - .app - .state - .focused_pane_requests_mouse_capture_from(&self.app.terminal_runtimes); - let pixel_mouse_requested = self - .clients - .values() - .any(|client| client.is_shell_client() && client.pixel_mouse); - let shell_sgr_pixels = pixel_mouse_requested - && self.focused_pane_graphics_demand() - && self - .app - .state - .active - .and_then(|ws_idx| { - self.app - .state - .workspaces - .get(ws_idx) - .and_then(crate::workspace::Workspace::focused_pane_id) - .and_then(|pane_id| { - self.app.state.runtime_for_pane_in_workspace( - &self.app.terminal_runtimes, - ws_idx, - pane_id, - ) - }) - }) - .is_some_and(crate::terminal::TerminalRuntime::sgr_pixel_mouse_enabled); let requested = self .clients .iter() .filter_map(|(&client_id, client)| match &client.mode { - ClientConnectionMode::ClientShell => Some(( - client_id, - client.shell_mouse_capture || endpoint_shell_mouse, - shell_sgr_pixels && client.pixel_mouse, - )), + ClientConnectionMode::ClientShell => { + let focused = self.shell_focused_runtime(client_id); + let child_requests_mouse = + focused.is_some_and(|(runtime, _)| runtime.mouse_reporting_enabled()); + let sgr_pixels = client.pixel_mouse + && focused.is_some_and(|(runtime, pane_id)| { + self.app.pane_graphics.active_for_pane(pane_id) + && runtime.sgr_pixel_mouse_enabled() + }); + Some(( + client_id, + client.shell_mouse_capture || child_requests_mouse, + sgr_pixels, + )) + } ClientConnectionMode::TerminalAttach { terminal_id } => { let runtime = self.runtime_for_terminal_id_string(terminal_id); let child_requests_mouse = runtime @@ -106,45 +109,49 @@ impl HeadlessServer { } pub(super) fn stream_direct_terminal_keyboard_mode(&mut self) { - let shell_report_all = { - let runtime = if self.app.state.popup_pane.is_some() { - self.app.popup_runtime() - } else { - self.app.state.active.and_then(|workspace_index| { - self.app - .state - .focused_runtime_in_workspace(&self.app.terminal_runtimes, workspace_index) - }) - }; - runtime.is_some_and(|runtime| { - let protocol = runtime.keyboard_protocol(); - protocol.reports_all_keys() - || (protocol.reports_event_types() && runtime.modify_other_keys_level() > 0) + let shell_modes = self + .clients + .iter() + .filter(|(_, client)| client.is_shell_client()) + .map(|(&client_id, _)| { + let report_all = + self.shell_focused_runtime(client_id) + .is_some_and(|(runtime, _)| { + let protocol = runtime.keyboard_protocol(); + protocol.reports_all_keys() + || (protocol.reports_event_types() + && runtime.modify_other_keys_level() > 0) + }); + (client_id, report_all) }) - }; - let serialized_shell = - Self::frame_server_message(&ServerMessage::ClientShellKeyboardReportAll { - enabled: shell_report_all, - }); + .collect::<Vec<_>>(); let mut broken_clients = Vec::new(); - for (&client_id, client) in &mut self.clients { - if !client.is_shell_client() - || client.host_keyboard_report_all_active == Some(shell_report_all) - { + for (client_id, report_all) in shell_modes { + let Some(client) = self.clients.get_mut(&client_id) else { + continue; + }; + if client.host_keyboard_report_all_active == Some(report_all) { continue; } let Some(writer) = &client.writer else { continue; }; - let Ok(serialized) = serialized_shell.as_ref() else { - warn!("failed to serialize client shell keyboard report-all mode"); - break; + let serialized = match Self::frame_server_message( + &ServerMessage::ClientShellKeyboardReportAll { + enabled: report_all, + }, + ) { + Ok(serialized) => serialized, + Err(err) => { + warn!(err = %err, "failed to serialize client shell keyboard report-all mode"); + continue; + } }; - if writer.control.send(serialized.clone()).is_err() { + if writer.control.send(serialized).is_err() { broken_clients.push(client_id); continue; } - client.host_keyboard_report_all_active = Some(shell_report_all); + client.host_keyboard_report_all_active = Some(report_all); } let requested = self @@ -214,11 +221,36 @@ impl HeadlessServer { pub(super) fn sync_immediate_pty_sources(&self) { let (has_app_target, direct_terminal_targets) = self.pty_render_targets(); - let mut pane_ids = if has_app_target { - self.app.state.app_surface_pane_ids() - } else { - HashSet::new() - }; + let mut pane_ids = HashSet::new(); + if has_app_target { + for (&client_id, client) in &self.clients { + if !client.is_shell_client() || client.writer.is_none() { + continue; + } + let Some(target) = self.shell_target_for_client(client_id) else { + continue; + }; + let Some(tab) = self + .app + .state + .workspaces + .get(target.workspace_index) + .and_then(|workspace| workspace.tabs.get(target.tab_index)) + else { + continue; + }; + if tab.zoomed { + pane_ids.insert(tab.layout.focused()); + } else { + pane_ids.extend(tab.layout.pane_ids()); + } + if self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id) { + if let Some(popup) = &self.app.state.popup_pane { + pane_ids.insert(popup.pane_id); + } + } + } + } if !direct_terminal_targets.is_empty() { for workspace in &self.app.state.workspaces { for tab in &workspace.tabs { @@ -265,7 +297,7 @@ impl HeadlessServer { direct_terminal_targets: &HashSet<&str>, ) -> bool { let terminal_id = self.terminal_id_for_pane(pane_id); - (has_app_target && (terminal_id.is_none() || self.app_surface_contains_pane(pane_id))) + (has_app_target && (terminal_id.is_none() || self.any_shell_surface_contains_pane(pane_id))) || terminal_id.is_none_or(|source| direct_terminal_targets.contains(source.as_str())) } @@ -305,31 +337,34 @@ impl HeadlessServer { .map(|(_, pane)| &pane.attached_terminal_id) } - fn app_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { - if self - .app - .state - .popup_pane - .as_ref() - .is_some_and(|popup| popup.pane_id == pane_id) - { - return true; - } - let Some(workspace) = self - .app - .state - .active - .and_then(|ws_idx| self.app.state.workspaces.get(ws_idx)) - else { - return false; - }; - let Some(tab) = workspace.active_tab() else { - return false; - }; - if !tab.panes.contains_key(&pane_id) { - return false; - } - !tab.zoomed || tab.layout.focused() == pane_id + fn any_shell_surface_contains_pane(&self, pane_id: crate::layout::PaneId) -> bool { + self.clients.iter().any(|(&client_id, client)| { + if !client.is_shell_client() || client.writer.is_none() { + return false; + } + if self + .app + .state + .popup_pane + .as_ref() + .is_some_and(|popup| popup.pane_id == pane_id) + { + return self.popup_owner_tab_id == self.shell_tab_id_for_client(client_id); + } + let Some(target) = self.shell_target_for_client(client_id) else { + return false; + }; + let Some(tab) = self + .app + .state + .workspaces + .get(target.workspace_index) + .and_then(|workspace| workspace.tabs.get(target.tab_index)) + else { + return false; + }; + tab.panes.contains_key(&pane_id) && (!tab.zoomed || tab.layout.focused() == pane_id) + }) } pub(super) fn render_and_stream(&mut self) { @@ -362,21 +397,28 @@ impl HeadlessServer { return; } - let shell_snapshot_template = render_targets - .iter() - .any(|(_, _, _, _, mode)| matches!(mode, ClientConnectionMode::ClientShell)) - .then(|| client_shell_snapshot(&self.app, &self.client_shell_boot_id, 0, None)); let mut broken_clients: Vec<u64> = Vec::new(); - for (client_id, (cols, rows), cell_size, is_foreground, mode) in render_targets { + for (client_id, (cols, rows), cell_size, _is_foreground, mode) in render_targets { let area = Rect::new(0, 0, cols, rows); + let shell_target = self.shell_target_for_client(client_id); + let shell_tab_id = self.shell_tab_id_for_client(client_id); + let shell_shows_popup = shell_tab_id.as_deref() == self.popup_owner_tab_id.as_deref(); let mut shell_projection_revision = 0; if matches!(mode, ClientConnectionMode::ClientShell) { + let location = self + .clients + .get(&client_id) + .and_then(|client| client.shell_location.clone()); let Some(client) = self.clients.get_mut(&client_id) else { continue; }; - let Some(mut candidate) = shell_snapshot_template.clone() else { - continue; - }; + let mut candidate = client_shell_snapshot( + &self.app, + &self.client_shell_boot_id, + client.shell_projection_revision, + None, + location.as_ref(), + ); candidate.config_diagnostic = if client.shell_uses_endpoint_keybindings { self.server_config_diagnostic.clone() } else { @@ -438,8 +480,10 @@ impl HeadlessServer { graphics_delivery: next_graphics_delivery, } = render_client_shell_pane_surface( &mut self.app, + shell_target, area, - is_foreground, + false, + shell_shows_popup, render_cell_size, &shell_graphics_delivery, client_id, diff --git a/src/server/headless/retained_surface.rs b/src/server/headless/retained_surface.rs index b15c4319..230704a6 100644 --- a/src/server/headless/retained_surface.rs +++ b/src/server/headless/retained_surface.rs @@ -292,14 +292,14 @@ impl HeadlessServer { .parse_pane_id(&pane.pane_id) .is_some_and(|(_, pane_id)| pane_id == *source) }) else { - fallback!("source_not_visible"); + continue; }; public_pane_id.get_or_insert_with(|| pane.pane_id.clone()); width = width.max(pane.inner_rect.width); height = height.max(pane.inner_rect.height); } let Some(public_pane_id) = public_pane_id else { - fallback!("pane_missing"); + continue; }; let Some((workspace_index, pane_id)) = self.app.parse_pane_id(&public_pane_id) else { fallback!("pane_missing"); @@ -355,7 +355,7 @@ impl HeadlessServer { .iter_mut() .find(|pane| pane.pane_id == collected_pane.pane_id) else { - fallback!("pane_missing"); + continue; }; // Alternate-screen transitions change whether the pane reserves // a scrollbar gutter. Recompute layout and resize the runtime diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 93c7420c..2fb6fd55 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -64,6 +64,8 @@ fn test_headless_server_with_event_hub(event_hub: api::EventHub) -> HeadlessServ #[cfg(unix)] next_client_id: 1, foreground_client_id: None, + tab_geometry_controllers: HashMap::new(), + popup_owner_tab_id: None, client_shell_boot_id: "test-boot".into(), sent_window_title: None, api_window_title: None, @@ -1052,6 +1054,57 @@ async fn different_size_shells_receive_geometry_specific_patches_from_one_dirty_ } #[tokio::test] +async fn retained_patches_only_reach_shells_viewing_the_dirty_tab() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("divergent-retained"); + let first_pane = workspace.tabs[0].root_pane; + let second_tab = workspace.test_add_tab(Some("second")); + let second_pane = workspace.tabs[second_tab].root_pane; + workspace.insert_test_runtime( + first_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"FIRST"), + ); + workspace.insert_test_runtime( + second_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"SECOND"), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap(); + + let (first_control, first_render) = connect_matching_test_shell(&mut server, 7); + let (second_control, second_render) = connect_matching_test_shell(&mut server, 8); + let _ = first_control.recv().expect("first snapshot"); + let _ = second_control.recv().expect("second snapshot"); + assert!(server.focus_shell_client_on_tab(8, &second_tab_id)); + assert!(server.claim_shell_tab_geometry(8, false)); + assert!( + server.pty_sources_visible_to_any_render_target(&HashSet::from([first_pane, second_pane,])) + ); + server.render_and_stream(); + let _ = recv_pane_surface(&first_render, "first baseline"); + let _ = recv_pane_surface(&second_render, "second baseline"); + + server.app.state.workspaces[0].test_runtimes[&first_pane] + .test_process_pty_bytes(b"\rFIRST_PATCH"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([first_pane]))); + let first_patch = recv_pane_surface_patch(&first_render, "first patch"); + assert_eq!(first_patch.panes.len(), 1); + assert!(second_render.try_recv().is_err()); + + server.app.state.workspaces[0].test_runtimes[&second_pane] + .test_process_pty_bytes(b"\rSECOND_PATCH"); + assert!(server.render_retained_pane_surface_and_stream(&HashSet::from([second_pane]))); + let second_patch = recv_pane_surface_patch(&second_render, "second patch"); + assert_eq!(second_patch.panes.len(), 1); + assert!(first_render.try_recv().is_err()); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] async fn backpressured_shell_does_not_disable_retained_patches_for_responsive_peer() { let mut server = test_headless_server(); let pane_id = install_shared_view_test_runtime(&mut server); @@ -1200,7 +1253,542 @@ async fn client_shell_config_diagnostics_follow_keybinding_ownership() { } #[tokio::test] -async fn client_shell_replaces_projection_and_focuses_stable_ids() { +async fn client_shell_tab_focus_changes_only_the_source_connection() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("independent-tabs"); + let second_tab = workspace.test_add_tab(Some("second")); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let tab_ids = server + .app + .session_snapshot() + .tabs + .into_iter() + .map(|tab| tab.tab_id) + .collect::<Vec<_>>(); + let first_tab_id = tab_ids[0].clone(); + let second_tab_id = tab_ids[second_tab].clone(); + + let (first_control, _first_render) = connect_test_shell(&mut server, 7, 100, 30); + let (second_control, _second_render) = connect_test_shell(&mut server, 8, 80, 24); + let first_initial = client_shell_snapshot(read_server_message( + first_control.recv().expect("first snapshot"), + )); + let second_initial = client_shell_snapshot(read_server_message( + second_control.recv().expect("second snapshot"), + )); + assert_eq!( + first_initial.focused_tab_id.as_deref(), + Some(first_tab_id.as_str()) + ); + assert_eq!( + second_initial.focused_tab_id.as_deref(), + Some(first_tab_id.as_str()) + ); + + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id: 8, + boot_id: server.client_shell_boot_id.clone(), + request: Box::new(api::schema::Request { + id: "focus-second".into(), + method: api::schema::Method::TabFocus(api::schema::TabTarget { + tab_id: second_tab_id.clone(), + }), + }), + }) + ); + let response_ready = server + .server_event_rx + .recv() + .await + .expect("focus response ready"); + assert!(!server.handle_server_event(response_ready)); + let _ = second_control.recv().expect("focus response"); + + server.render_and_stream(); + + assert!( + first_control.try_recv().is_err(), + "another shell must not receive a navigation replacement" + ); + let second_replacement = client_shell_snapshot(read_server_message( + second_control.recv().expect("second replacement snapshot"), + )); + assert_eq!( + second_replacement.focused_tab_id.as_deref(), + Some(second_tab_id.as_str()) + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn deferred_worktree_response_moves_only_its_source_client() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("deferred-worktree"); + let created_tab = workspace.test_add_tab(Some("created")); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let created_tab_id = server.app.public_tab_id(0, created_tab).unwrap(); + + let (source_control, _) = connect_matching_test_shell(&mut server, 51); + let (other_control, _) = connect_matching_test_shell(&mut server, 52); + let _ = source_control.recv().expect("source snapshot"); + let _ = other_control.recv().expect("other snapshot"); + let original_tab_id = server.shell_tab_id_for_client(51).unwrap(); + server + .clients + .get_mut(&51) + .unwrap() + .shell_endpoint_command_in_flight = true; + server + .clients + .get_mut(&51) + .unwrap() + .shell_deferred_navigation_response = Some(Vec::new()); + let response = serde_json::json!({ + "id": "create-worktree", + "result": { + "type": "worktree_created", + "tab": { "tab_id": created_tab_id } + } + }); + + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady { + client_id: 51, + boot_id: server.client_shell_boot_id.clone(), + request_id: "create-worktree".into(), + final_chunk: true, + data: serde_json::to_vec(&response).unwrap(), + }) + ); + assert_eq!( + server.shell_tab_id_for_client(51).as_deref(), + response + .pointer("/result/tab/tab_id") + .and_then(serde_json::Value::as_str) + ); + assert_eq!( + server.shell_tab_id_for_client(52).as_deref(), + Some(original_tab_id.as_str()) + ); + + assert!(server.focus_shell_client_on_tab(51, &original_tab_id)); + server + .clients + .get_mut(&51) + .unwrap() + .shell_endpoint_command_in_flight = true; + server + .clients + .get_mut(&51) + .unwrap() + .shell_deferred_navigation_request_id = Some("background-worktree".into()); + assert!( + !server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady { + client_id: 51, + boot_id: server.client_shell_boot_id.clone(), + request_id: "background-worktree".into(), + final_chunk: true, + data: serde_json::to_vec(&response).unwrap(), + }) + ); + assert_eq!( + server.shell_tab_id_for_client(51).as_deref(), + Some(original_tab_id.as_str()) + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_local_navigation_does_not_emit_global_focus_transitions() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("independent-focus"); + let first_pane = workspace.tabs[0].root_pane; + let second_tab = workspace.test_add_tab(Some("second")); + let second_pane = workspace.tabs[second_tab].root_pane; + let (first_runtime, mut first_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + let (second_runtime, mut second_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + workspace.insert_test_runtime(first_pane, first_runtime); + workspace.insert_test_runtime(second_pane, second_runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap(); + + let (first_control, _) = connect_matching_test_shell(&mut server, 61); + let (second_control, _) = connect_matching_test_shell(&mut server, 62); + let _ = first_control.recv().expect("first snapshot"); + let _ = second_control.recv().expect("second snapshot"); + assert!(server.focus_shell_client_on_tab(62, &second_tab_id)); + server.clients.get_mut(&61).unwrap().outer_terminal_focus = Some(true); + server.clients.get_mut(&62).unwrap().outer_terminal_focus = Some(true); + + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + server.handle_client_shell_api_request( + 62, + crate::api::ApiRequestMessage { + request: crate::api::schema::Request { + id: "focus-own-tab".into(), + method: crate::api::schema::Method::TabFocus(crate::api::schema::TabTarget { + tab_id: second_tab_id, + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }, + ); + server.app.sync_focus_events(); + assert!(first_input.try_recv().is_err()); + assert!(second_input.try_recv().is_err()); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn public_focus_moves_shell_focus_between_tabs() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("public-focus-events"); + let first_pane = workspace.tabs[0].root_pane; + let second_tab = workspace.test_add_tab(Some("second")); + let second_pane = workspace.tabs[second_tab].root_pane; + let (first_runtime, mut first_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + let (second_runtime, mut second_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + workspace.insert_test_runtime(first_pane, first_runtime); + workspace.insert_test_runtime(second_pane, second_runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_tab_id = server + .app + .public_tab_id(0, second_tab) + .expect("second tab id"); + + let (first_control, _) = connect_matching_test_shell(&mut server, 63); + let (second_control, _) = connect_matching_test_shell(&mut server, 64); + let _ = first_control.recv().expect("first snapshot"); + let _ = second_control.recv().expect("second snapshot"); + assert!(server.focus_shell_client_on_tab(64, &second_tab_id)); + server.clients.get_mut(&63).unwrap().outer_terminal_focus = Some(true); + server.clients.get_mut(&64).unwrap().outer_terminal_focus = Some(true); + assert!(server.app.state.switch_workspace_tab(0, second_tab)); + + server.focus_all_shell_clients_on_default_target(); + + assert_eq!( + server.shell_tab_id_for_client(63).as_deref(), + Some(second_tab_id.as_str()) + ); + assert_eq!( + first_input.try_recv().expect("previous tab focus lost"), + Bytes::from_static(b"\x1b[O") + ); + assert!( + second_input.try_recv().is_err(), + "focus gain was duplicated" + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn repeated_layout_action_reapplies_controller_geometry() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("layout-geometry"); + let first_pane = workspace.tabs[0].root_pane; + let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal); + workspace.insert_test_runtime( + first_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + workspace.insert_test_runtime( + second_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), + ); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let tab_id = server.app.public_tab_id(0, 0).expect("tab id"); + + let (control, _) = connect_test_shell(&mut server, 65, 100, 30); + let _ = control.recv().expect("snapshot"); + let before = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(); + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + + assert!(server.handle_client_shell_api_request( + 65, + crate::api::ApiRequestMessage { + request: crate::api::schema::Request { + id: "resize-layout".into(), + method: crate::api::schema::Method::LayoutSetSplitRatio( + crate::api::schema::LayoutSetSplitRatioParams { + tab_id: Some(tab_id), + pane_id: None, + path: Vec::new(), + ratio: 0.8, + }, + ), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }, + )); + + let after = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(); + assert_ne!(after, before); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn client_shell_tabs_render_accept_input_and_resize_independently() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("independent-geometry"); + let first_pane = workspace.tabs[0].root_pane; + let second_tab = workspace.test_add_tab(Some("second")); + let second_pane = workspace.tabs[second_tab].root_pane; + workspace.insert_test_runtime( + first_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b"FIRST_TAB"), + ); + let (second_runtime, mut second_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"SECOND_TAB", + 4, + ); + workspace.insert_test_runtime(second_pane, second_runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap(); + let second_pane_id = server.app.public_pane_id(0, second_pane).unwrap(); + let initial_second_size = + server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(); + + let (first_control, first_render) = connect_test_shell(&mut server, 21, 100, 30); + let _ = first_control.recv().expect("first snapshot"); + let first_size = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(); + let singleton_second_size = + server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(); + assert_ne!(singleton_second_size, initial_second_size); + assert_eq!(singleton_second_size, first_size); + + let (second_control, second_render) = connect_test_shell(&mut server, 22, 70, 20); + let _ = second_control.recv().expect("second snapshot"); + + assert!(server.focus_shell_client_on_tab(22, &second_tab_id)); + assert!(server.claim_shell_tab_geometry(22, false)); + let second_size = server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(); + assert_ne!(first_size, second_size); + assert_eq!( + server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(), + first_size + ); + + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 22, + pane_id: second_pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "typed".into(), + )], + }); + assert_eq!( + second_input.try_recv().expect("second tab input"), + Bytes::from_static(b"typed") + ); + + server.render_and_stream(); + let first_surface = match read_server_message(first_render.recv().expect("first surface")) { + ServerMessage::PaneSurface(surface) => surface, + other => panic!("expected first pane surface, got {other:?}"), + }; + let second_surface = match read_server_message(second_render.recv().expect("second surface")) { + ServerMessage::PaneSurface(surface) => surface, + other => panic!("expected second pane surface, got {other:?}"), + }; + assert!(frame_text(&first_surface.frame).contains("FIRST_TAB")); + assert!(frame_text(&second_surface.frame).contains("SECOND_TAB")); + + assert!(server.handle_server_event(ServerEvent::ClientShellResize { + client_id: 22, + surface_cols: 60, + surface_rows: 16, + cell_width_px: 0, + cell_height_px: 0, + pixel_mouse: false, + })); + let resized_second = server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(); + assert_ne!(resized_second, second_size); + assert_eq!( + server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(), + first_size + ); + + assert!(server.focus_shell_client_on_tab(21, &second_tab_id)); + assert!(server.claim_shell_tab_geometry(21, false)); + assert_ne!( + server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(), + resized_second + ); + + server.remove_client_and_resize_if_needed(21); + let singleton_first = server.app.state.workspaces[0].test_runtimes[&first_pane].current_size(); + let singleton_second = + server.app.state.workspaces[0].test_runtimes[&second_pane].current_size(); + assert_ne!(singleton_first, first_size); + assert_eq!(singleton_first, singleton_second); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn public_background_tab_create_preserves_client_locations() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("background-create"); + let second_tab = workspace.test_add_tab(Some("second")); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let workspace_id = server.app.public_workspace_id(0); + let first_tab_id = server.app.public_tab_id(0, 0).unwrap(); + let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap(); + + let (first_control, _) = connect_matching_test_shell(&mut server, 71); + let (second_control, _) = connect_matching_test_shell(&mut server, 72); + let _ = first_control.recv().expect("first snapshot"); + let _ = second_control.recv().expect("second snapshot"); + assert!(server.focus_shell_client_on_tab(71, &second_tab_id)); + + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage { + request: crate::api::schema::Request { + id: "create-background-tab".into(), + method: crate::api::schema::Method::TabCreate(crate::api::schema::TabCreateParams { + workspace_id: Some(workspace_id), + cwd: None, + focus: false, + label: Some("background".into()), + env: std::collections::HashMap::new(), + }), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + assert_eq!( + server.shell_tab_id_for_client(71).as_deref(), + Some(second_tab_id.as_str()) + ); + assert_eq!( + server.shell_tab_id_for_client(72).as_deref(), + Some(first_tab_id.as_str()) + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn public_workspace_focus_preserves_each_clients_remembered_tabs() { + let mut server = test_headless_server(); + let mut first = crate::workspace::Workspace::test_new("first"); + let second_tab = first.test_add_tab(Some("second")); + let second = crate::workspace::Workspace::test_new("second"); + server.app.state.workspaces = vec![first, second]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let first_workspace_id = server.app.public_workspace_id(0); + let second_workspace_id = server.app.public_workspace_id(1); + let first_tab_id = server.app.public_tab_id(0, 0).unwrap(); + let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap(); + + let (first_control, _) = connect_test_shell(&mut server, 41, 100, 30); + let (second_control, _) = connect_test_shell(&mut server, 42, 80, 24); + let _ = first_control.recv().expect("first snapshot"); + let _ = second_control.recv().expect("second snapshot"); + assert!(server.focus_shell_client_on_tab(41, &second_tab_id)); + + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage { + request: crate::api::schema::Request { + id: "focus-second-workspace".into(), + method: crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { + workspace_id: second_workspace_id.clone(), + }, + ), + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + + let first_location = server.clients[&41].shell_location.as_ref().unwrap(); + let second_location = server.clients[&42].shell_location.as_ref().unwrap(); + assert_eq!( + first_location.focused_workspace_id.as_deref(), + Some(second_workspace_id.as_str()) + ); + assert_eq!( + second_location.focused_workspace_id.as_deref(), + Some(second_workspace_id.as_str()) + ); + assert_eq!( + first_location.active_tab_ids[&first_workspace_id], + second_tab_id + ); + assert_eq!( + second_location.active_tab_ids[&first_workspace_id], + first_tab_id + ); + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn public_api_focus_replaces_every_client_shell_projection() { let mut server = test_headless_server(); let first = crate::workspace::Workspace::test_new("first"); let second = crate::workspace::Workspace::test_new("second"); @@ -1233,11 +1821,19 @@ async fn client_shell_replaces_projection_and_focuses_stable_ids() { )) .revision; - let _ = server.app.handle_api_request(crate::api::schema::Request { - id: "test.client.shell.workspace.focus".into(), - method: crate::api::schema::Method::WorkspaceFocus(crate::api::schema::WorkspaceTarget { - workspace_id: second_id.clone(), - }), + let (respond_to, _response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(crate::api::ApiRequestMessage { + request: crate::api::schema::Request { + id: "test.client.shell.workspace.focus".into(), + method: crate::api::schema::Method::WorkspaceFocus( + crate::api::schema::WorkspaceTarget { + workspace_id: second_id.clone(), + }, + ), + }, + respond_to, + response_write_complete: None, + stream_active: None, }); assert_eq!(server.app.state.active, Some(1)); server.render_and_stream(); @@ -1483,6 +2079,7 @@ async fn client_shell_streams_and_targets_popup_terminal_content() { assert_eq!(popup.terminal_id, popup_terminal_id.as_str()); assert!(frame_text(&popup.frame).contains("POPUP_SHELL_LIVE")); assert_eq!((popup.frame.width, popup.frame.height), (37, 9)); + assert!(popup.frame.cursor.is_some()); assert_eq!(surface.graphics.assets.len(), 1); assert_eq!(surface.graphics.placements.len(), 1); assert!(matches!( @@ -1574,6 +2171,152 @@ async fn client_shell_streams_and_targets_popup_terminal_content() { } #[tokio::test] +async fn terminal_popup_is_visible_and_modal_only_on_its_owning_tab() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("tab-popup"); + let first_pane = workspace.tabs[0].root_pane; + let second_tab = workspace.test_add_tab(Some("second")); + let second_pane = workspace.tabs[second_tab].root_pane; + workspace.insert_test_runtime( + first_pane, + crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 23, b"FIRST"), + ); + let (second_runtime, mut second_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, 23, 0, b"SECOND", 4, + ); + workspace.insert_test_runtime(second_pane, second_runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let first_tab_id = server.app.public_tab_id(0, 0).unwrap(); + let second_tab_id = server.app.public_tab_id(0, second_tab).unwrap(); + let second_pane_id = server.app.public_pane_id(0, second_pane).unwrap(); + + let (first_control, first_render) = connect_matching_test_shell(&mut server, 31); + let (second_control, second_render) = connect_matching_test_shell(&mut server, 32); + let _ = first_control.recv().expect("first snapshot"); + let _ = second_control.recv().expect("second snapshot"); + assert!(server.focus_shell_client_on_tab(32, &second_tab_id)); + assert!(server.claim_shell_tab_geometry(32, false)); + + let (popup_runtime, mut popup_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 40, + 12, + 0, + b"POPUP\x1b[>3u", + 4, + ); + let (_, popup_terminal_id) = server.app.install_test_popup_runtime(popup_runtime); + server.popup_owner_tab_id = Some(first_tab_id); + assert!(server.apply_shell_tab_geometry(31, false)); + let popup_size = server + .app + .terminal_runtimes + .get(&popup_terminal_id) + .unwrap() + .current_size(); + connect_pending_terminal_client(&mut server, 33); + assert!( + server.handle_server_event(ServerEvent::ClientAttachTerminal { + client_id: 33, + terminal_id: popup_terminal_id.to_string(), + takeover: false, + }) + ); + assert_ne!( + server + .app + .terminal_runtimes + .get(&popup_terminal_id) + .unwrap() + .current_size(), + popup_size + ); + assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 33 })); + assert_eq!( + server + .app + .terminal_runtimes + .get(&popup_terminal_id) + .unwrap() + .current_size(), + popup_size + ); + server.render_and_stream(); + assert!(recv_pane_surface(&first_render, "popup owner surface") + .popup + .is_some()); + assert!(recv_pane_surface(&second_render, "other tab surface") + .popup + .is_none()); + + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 32, + pane_id: second_pane_id, + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "typed".into(), + )], + }); + assert_eq!( + second_input.try_recv().expect("other tab input"), + Bytes::from_static(b"typed") + ); + + let popup_key = |kind| crate::protocol::ClientPaneInputEvent::Key { + code: crate::protocol::ClientKeyCode::Char('x'), + modifiers: 0, + kind, + repeat_count: 1, + shifted_codepoint: None, + generated_text: (kind == crate::protocol::ClientKeyKind::Press).then(|| "x".into()), + tracks_release: true, + physical_key_id: Some(0x2d), + windows_record: None, + }; + server.handle_server_event(ServerEvent::ClientShellPopupInput { + client_id: 31, + terminal_id: popup_terminal_id.to_string(), + events: vec![popup_key(crate::protocol::ClientKeyKind::Press)], + }); + assert!( + !tokio::time::timeout(Duration::from_secs(1), popup_input.recv()) + .await + .expect("popup press timed out") + .expect("popup press") + .is_empty() + ); + assert!(server.focus_shell_client_on_tab(31, &second_tab_id)); + server.handle_server_event(ServerEvent::ClientShellPopupInput { + client_id: 31, + terminal_id: popup_terminal_id.to_string(), + events: vec![popup_key(crate::protocol::ClientKeyKind::Release)], + }); + assert!( + !tokio::time::timeout(Duration::from_secs(1), popup_input.recv()) + .await + .expect("popup release timed out") + .expect("popup release after navigation") + .is_empty() + ); + + assert!( + !server.handle_server_event(ServerEvent::ClientShellPopupInput { + client_id: 32, + terminal_id: popup_terminal_id.to_string(), + events: vec![crate::protocol::ClientPaneInputEvent::TextCommit( + "wrong".into() + )], + }) + ); + assert!(popup_input.try_recv().is_err()); + + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] async fn client_shell_release_under_popup_renders_when_it_resets_scrollback() { let mut server = test_headless_server(); let mut workspace = crate::workspace::Workspace::test_new("popup-release-scroll"); @@ -1724,6 +2467,7 @@ async fn client_shell_mouse_motion_delivers_without_render_when_foreground() { ), ); server.foreground_client_id = Some(11); + assert!(server.claim_unowned_shell_tab_geometry(11, false)); let render_impact = server.handle_server_event_with_render_impact(ServerEvent::ClientShellPaneInput { @@ -1839,7 +2583,7 @@ fn retained_test_server( ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); + assert!(server.claim_unowned_shell_tab_geometry(1, true)); (server, client_rx, pane_id) } @@ -2150,7 +2894,8 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() { .expect("test runtime"); let _runtime_guard = rt.enter(); let mut server = test_headless_server(); - let workspace = crate::workspace::Workspace::test_new("test"); + let mut workspace = crate::workspace::Workspace::test_new("test"); + let second_tab = workspace.test_add_tab(Some("second")); let pane_id = workspace.tabs[0].root_pane; let terminal_id = workspace.terminal_id(pane_id).expect("terminal id").clone(); let terminal_id_string = terminal_id.to_string(); @@ -2158,6 +2903,10 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() { server.app.state.ensure_test_terminals(); server.app.state.active = Some(0); server.app.state.selected = 0; + let second_tab_id = server + .app + .public_tab_id(0, second_tab) + .expect("second tab id"); server.app.terminal_runtimes.insert( terminal_id.clone(), crate::terminal::TerminalRuntime::test_with_screen_bytes(80, 24, b""), @@ -2174,7 +2923,8 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() { ); server.foreground_client_id = Some(1); server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); + server.reconcile_client_shell_locations(); + assert!(server.claim_unowned_shell_tab_geometry(1, true)); let expected_shell_size = server .app .terminal_runtimes @@ -2206,6 +2956,7 @@ fn terminal_attach_disconnect_restores_client_shell_pane_size() { (30, 100) ); + assert!(server.focus_shell_client_on_tab(1, &second_tab_id)); assert!(server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 2 })); assert!(!server .app @@ -2659,6 +3410,63 @@ fn changed_git_refresh_requests_headless_render() { assert!(changed); } +#[tokio::test] +async fn pane_death_reconciles_each_client_view_and_focus() { + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("pane-death-views"); + let dead_pane = workspace.tabs[0].root_pane; + let second_tab = workspace.test_add_tab(Some("second")); + let second_pane = workspace.tabs[second_tab].root_pane; + let (second_runtime, mut second_input) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes( + 80, + 24, + 0, + b"\x1b[?1004h", + 4, + ); + workspace.insert_test_runtime(second_pane, second_runtime); + server.app.state.workspaces = vec![workspace]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + server.app.state.mode = crate::app::Mode::Terminal; + let second_tab_id = server + .app + .public_tab_id(0, second_tab) + .expect("second tab id"); + + let (first_control, _) = connect_matching_test_shell(&mut server, 71); + let (second_control, _) = connect_matching_test_shell(&mut server, 72); + let _ = first_control.recv().expect("first snapshot"); + let _ = second_control.recv().expect("second snapshot"); + assert!(server.focus_shell_client_on_tab(72, &second_tab_id)); + server.clients.get_mut(&71).unwrap().outer_terminal_focus = Some(true); + server.clients.get_mut(&72).unwrap().outer_terminal_focus = Some(false); + + assert!(server.handle_internal_event_with_forwarding(AppEvent::PaneDied { pane_id: dead_pane })); + + assert_eq!( + server.shell_tab_id_for_client(71).as_deref(), + Some(second_tab_id.as_str()) + ); + assert_eq!( + server.shell_tab_id_for_client(72).as_deref(), + Some(second_tab_id.as_str()) + ); + assert_eq!( + second_input + .try_recv() + .expect("fallback focus gained input"), + Bytes::from_static(b"\x1b[I") + ); + assert!( + second_input.try_recv().is_err(), + "focus gain was duplicated" + ); + shutdown_test_runtimes(&mut server); +} + #[test] fn terminal_attach_client_exits_when_attached_pane_dies() { let mut server = test_headless_server(); @@ -3515,7 +4323,7 @@ fn pending_terminal_resize_does_not_take_shell_foreground_or_geometry() { ), ); server.foreground_client_id = Some(1); - server.resize_shared_runtime_to_effective_size(); + server.sync_foreground_client_state(); let shell_size = server.effective_size; assert!(server.handle_server_event(ServerEvent::ClientResize { @@ -3712,7 +4520,7 @@ fn client_shell_focus_promotes_and_reaches_reporting_pane() { ); server.foreground_client_id = Some(2); server.sync_foreground_client_state(); - server.resize_shared_runtime_to_effective_size(); + assert!(server.claim_unowned_shell_tab_geometry(2, true)); assert_eq!( server .app @@ -3744,12 +4552,28 @@ fn client_shell_focus_promotes_and_reaches_reporting_pane() { ); assert!(server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: 2, + focused: true, + })); + assert!( + input_rx.try_recv().is_err(), + "second viewer duplicated focus gain" + ); + assert!(server.handle_server_event(ServerEvent::ClientShellFocus { client_id: 1, focused: false, })); + assert!( + input_rx.try_recv().is_err(), + "remaining viewer lost tab focus" + ); + assert!(server.handle_server_event(ServerEvent::ClientShellFocus { + client_id: 2, + focused: false, + })); assert_eq!(server.app.state.outer_terminal_focus, Some(false)); assert_eq!( - input_rx.try_recv().expect("focus lost input"), + input_rx.try_recv().expect("last viewer focus lost input"), Bytes::from_static(b"\x1b[O") ); }); diff --git a/src/server/headless/tests/pane_graphics.rs b/src/server/headless/tests/pane_graphics.rs index ecdbc2b7..b5938d56 100644 --- a/src/server/headless/tests/pane_graphics.rs +++ b/src/server/headless/tests/pane_graphics.rs @@ -665,6 +665,10 @@ async fn client_shell_direct_graphics_uploads_without_server_authored_coordinate &[], &[], None, + Some(crate::ui::TabSurfaceTarget { + workspace_index: 0, + tab_index: 0, + }), crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, @@ -700,6 +704,10 @@ async fn client_shell_direct_graphics_uploads_without_server_authored_coordinate &[], &[], None, + Some(crate::ui::TabSurfaceTarget { + workspace_index: 0, + tab_index: 0, + }), crate::kitty_graphics::HostCellSize { width_px: 10, height_px: 20, diff --git a/src/server/render_scale_benchmark.rs b/src/server/render_scale_benchmark.rs index 15b23bf8..b8bb2e89 100644 --- a/src/server/render_scale_benchmark.rs +++ b/src/server/render_scale_benchmark.rs @@ -59,6 +59,7 @@ impl RenderPipeline { "bench-boot", 1, None, + None, ))); Self { @@ -71,10 +72,20 @@ impl RenderPipeline { fn render_once(&mut self) -> (Duration, Duration) { let surface_size = self.client.surface_size(COLS, ROWS); let started = Instant::now(); + let target = self + .app + .state + .active + .map(|workspace_index| crate::ui::TabSurfaceTarget { + workspace_index, + tab_index: self.app.state.workspaces[workspace_index].active_tab_index(), + }); let rendered = super::client_shell::render_pane_surface( &mut self.app, + target, Rect::new(0, 0, surface_size.cols, surface_size.rows), true, + true, HostCellSize { width_px: 1, height_px: 1, @@ -219,7 +230,7 @@ fn profile_snapshot_encoding( let pipeline = RenderPipeline::new(build(count)); let run = || { let started = Instant::now(); - let template = super::client_shell::snapshot(&pipeline.app, "bench-boot", 1, None); + let template = super::client_shell::snapshot(&pipeline.app, "bench-boot", 1, None, None); for client_index in 0..client_count { let mut snapshot = template.clone(); snapshot.revision = client_index as u64 + 1; diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index 3d9e1141..41684c26 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -350,13 +350,21 @@ pub(crate) type RenderedTabSurface = ( pub(crate) fn render_tab_surface_virtual( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, + target: Option<crate::ui::TabSurfaceTarget>, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) -> RenderedTabSurface { - let layout = - crate::ui::compute_tab_surface(app_state, terminal_runtimes, area, resize_panes, cell_size); + let layout = crate::ui::compute_tab_surface_for( + app_state, + terminal_runtimes, + target, + area, + resize_panes, + cell_size, + ); let surface = crate::ui::TabSurfaceView { + target: layout.target, pane_infos: &layout.pane_infos, split_borders: &layout.split_borders, }; @@ -39,8 +39,8 @@ pub(crate) use self::sidebar::{ use self::status::copy_feedback_rect; pub(crate) use self::status::{render_config_diagnostic_buffer, render_copy_feedback_buffer}; pub(crate) use self::tab_surface::{ - compute_tab_surface, render_tab_surface, resize_tab_surface, tab_surface_cursor, - tab_surface_hyperlinks, TabSurfaceLayout, TabSurfaceView, + compute_tab_surface, compute_tab_surface_for, render_tab_surface, resize_tab_surface, + tab_surface_cursor, tab_surface_hyperlinks, TabSurfaceLayout, TabSurfaceTarget, TabSurfaceView, }; pub(crate) use self::text::truncate_end; pub(crate) use self::widgets::{centered_popup_rect, modal_stack_areas}; @@ -113,11 +113,18 @@ fn resize_background_tab_panes( cell_size: crate::kitty_graphics::HostCellSize, ) { for (workspace_index, workspace) in app.workspaces.iter().enumerate() { - for (tab_index, tab) in workspace.tabs.iter().enumerate() { + for tab_index in 0..workspace.tabs.len() { if app.active == Some(workspace_index) && tab_index == workspace.active_tab_index() { continue; } - resize_tab_surface(app, terminal_runtimes, tab, area, cell_size); + resize_tab_surface( + app, + terminal_runtimes, + workspace_index, + tab_index, + area, + cell_size, + ); } } } diff --git a/src/ui/panes.rs b/src/ui/panes.rs index cd5ea34c..e31ee0ad 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -158,13 +158,21 @@ pub(crate) fn apply_pane_chrome( } fn runtime_for_tab_pane<'a>( + _app: &'a AppState, terminal_runtimes: &'a TerminalRuntimeRegistry, + _workspace_index: usize, tab: &'a crate::workspace::Tab, pane_id: crate::layout::PaneId, ) -> Option<(&'a crate::terminal::TerminalId, &'a TerminalRuntime)> { let terminal_id = tab.terminal_id(pane_id)?; #[cfg(test)] - if let Some(runtime) = tab.runtimes.get(&pane_id) { + if let Some(runtime) = _app + .workspaces + .get(_workspace_index)? + .test_runtimes + .get(&pane_id) + .or_else(|| tab.runtimes.get(&pane_id)) + { return Some((terminal_id, runtime)); } terminal_runtimes @@ -199,6 +207,7 @@ fn stable_scrollbar_gutter( pub(super) fn resize_tab_panes( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, + workspace_index: usize, tab: &crate::workspace::Tab, area: Rect, cell_size: crate::kitty_graphics::HostCellSize, @@ -207,7 +216,9 @@ pub(super) fn resize_tab_panes( if tab.zoomed { let focused_id = tab.layout.focused(); - if let Some((terminal_id, rt)) = runtime_for_tab_pane(terminal_runtimes, tab, focused_id) { + if let Some((terminal_id, rt)) = + runtime_for_tab_pane(app, terminal_runtimes, workspace_index, tab, focused_id) + { let borders = if multi_pane && app.pane_borders && app.pane_outer_borders { Borders::ALL } else { @@ -235,7 +246,9 @@ pub(super) fn resize_tab_panes( ) { let pane_inner = pane_inner_rect(info.rect, info.borders); - if let Some((terminal_id, rt)) = runtime_for_tab_pane(terminal_runtimes, tab, info.id) { + if let Some((terminal_id, rt)) = + runtime_for_tab_pane(app, terminal_runtimes, workspace_index, tab, info.id) + { let inner_rect = terminal_inner_rect(rt, pane_inner, app.pane_scrollbars); if !app.direct_attach_resize_locks.contains(terminal_id) { rt.resize( @@ -250,24 +263,27 @@ pub(super) fn resize_tab_panes( } /// Compute pane layout info and optionally resize pane runtimes to match. -pub(super) fn compute_pane_infos( +pub(super) fn compute_pane_infos_for_tab( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, + ws_idx: usize, + tab_idx: usize, area: Rect, resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) -> Vec<PaneInfo> { - let Some(ws_idx) = app.active else { - return Vec::new(); - }; - let Some(ws) = app.workspaces.get(ws_idx) else { + let Some(tab) = app + .workspaces + .get(ws_idx) + .and_then(|workspace| workspace.tabs.get(tab_idx)) + else { return Vec::new(); }; - let multi_pane = ws.layout.pane_count() > 1; + let multi_pane = tab.layout.pane_count() > 1; - if ws.zoomed { - let focused_id = ws.layout.focused(); + if tab.zoomed { + let focused_id = tab.layout.focused(); let borders = if multi_pane && app.pane_borders && app.pane_outer_borders { Borders::ALL } else { @@ -280,7 +296,7 @@ pub(super) fn compute_pane_infos( (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner, app.pane_scrollbars); if resize_panes - && ws.terminal_id(focused_id).is_some_and(|terminal_id| { + && tab.terminal_id(focused_id).is_some_and(|terminal_id| { !app.direct_attach_resize_locks.contains(terminal_id) }) { @@ -303,7 +319,7 @@ pub(super) fn compute_pane_infos( } let mut pane_infos = apply_pane_chrome( - ws.layout.panes(area), + tab.layout.panes(area), app.pane_borders, app.pane_gaps, app.pane_outer_borders, @@ -318,7 +334,7 @@ pub(super) fn compute_pane_infos( (inner_rect, scrollbar_rect) = stable_scrollbar_gutter(rt, pane_inner, app.pane_scrollbars); if resize_panes - && ws.terminal_id(info.id).is_some_and(|terminal_id| { + && tab.terminal_id(info.id).is_some_and(|terminal_id| { !app.direct_attach_resize_locks.contains(terminal_id) }) { @@ -338,14 +354,44 @@ pub(super) fn compute_pane_infos( pane_infos } +#[cfg(test)] +fn compute_pane_infos( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + area: Rect, + resize_panes: bool, + cell_size: crate::kitty_graphics::HostCellSize, +) -> Vec<PaneInfo> { + let Some(workspace_index) = app.active else { + return Vec::new(); + }; + let Some(tab_index) = app + .workspaces + .get(workspace_index) + .map(crate::workspace::Workspace::active_tab_index) + else { + return Vec::new(); + }; + compute_pane_infos_for_tab( + app, + terminal_runtimes, + workspace_index, + tab_index, + area, + resize_panes, + cell_size, + ) +} + pub(super) fn render_panes( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, frame: &mut Frame, + target: Option<super::tab_surface::TabSurfaceTarget>, pane_infos: &[PaneInfo], split_borders: &[crate::layout::SplitBorder], ) { - let Some(ws_idx) = app.active else { + let Some(ws_idx) = target.map(|target| target.workspace_index) else { return; }; let Some(ws) = app.workspaces.get(ws_idx) else { diff --git a/src/ui/tab_surface.rs b/src/ui/tab_surface.rs index 766c1d83..d7b80bff 100644 --- a/src/ui/tab_surface.rs +++ b/src/ui/tab_surface.rs @@ -1,18 +1,26 @@ use ratatui::{layout::Rect, Frame}; -use super::panes::{compute_pane_infos, render_panes, resize_tab_panes}; +use super::panes::{compute_pane_infos_for_tab, render_panes, resize_tab_panes}; use crate::app::AppState; use crate::layout::{PaneInfo, SplitBorder}; use crate::protocol::CursorState; use crate::terminal::TerminalRuntimeRegistry; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct TabSurfaceTarget { + pub(crate) workspace_index: usize, + pub(crate) tab_index: usize, +} + pub(crate) struct TabSurfaceLayout { + pub(crate) target: Option<TabSurfaceTarget>, pub(crate) pane_infos: Vec<PaneInfo>, pub(crate) split_borders: Vec<SplitBorder>, } #[derive(Clone, Copy)] pub(crate) struct TabSurfaceView<'a> { + pub(crate) target: Option<TabSurfaceTarget>, pub(crate) pane_infos: &'a [PaneInfo], pub(crate) split_borders: &'a [SplitBorder], } @@ -24,20 +32,60 @@ pub(crate) fn compute_tab_surface( resize_panes: bool, cell_size: crate::kitty_graphics::HostCellSize, ) -> TabSurfaceLayout { - let split_borders = app - .active - .and_then(|i| app.workspaces.get(i)) - .map(|ws| { - if ws.zoomed { + let target = app.active.and_then(|workspace_index| { + let workspace = app.workspaces.get(workspace_index)?; + Some(TabSurfaceTarget { + workspace_index, + tab_index: workspace.active_tab_index(), + }) + }); + compute_tab_surface_for( + app, + terminal_runtimes, + target, + area, + resize_panes, + cell_size, + ) +} + +pub(crate) fn compute_tab_surface_for( + app: &AppState, + terminal_runtimes: &TerminalRuntimeRegistry, + target: Option<TabSurfaceTarget>, + area: Rect, + resize_panes: bool, + cell_size: crate::kitty_graphics::HostCellSize, +) -> TabSurfaceLayout { + let tab = target.and_then(|target| { + app.workspaces + .get(target.workspace_index)? + .tabs + .get(target.tab_index) + }); + let split_borders = tab + .map(|tab| { + if tab.zoomed { Vec::new() } else { - ws.layout.splits(area) + tab.layout.splits(area) } }) .unwrap_or_default(); - let pane_infos = compute_pane_infos(app, terminal_runtimes, area, resize_panes, cell_size); + let pane_infos = target.map_or_else(Vec::new, |target| { + compute_pane_infos_for_tab( + app, + terminal_runtimes, + target.workspace_index, + target.tab_index, + area, + resize_panes, + cell_size, + ) + }); TabSurfaceLayout { + target, pane_infos, split_borders, } @@ -46,11 +94,26 @@ pub(crate) fn compute_tab_surface( pub(crate) fn resize_tab_surface( app: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, - tab: &crate::workspace::Tab, + workspace_index: usize, + tab_index: usize, area: Rect, cell_size: crate::kitty_graphics::HostCellSize, ) { - resize_tab_panes(app, terminal_runtimes, tab, area, cell_size); + let Some(tab) = app + .workspaces + .get(workspace_index) + .and_then(|workspace| workspace.tabs.get(tab_index)) + else { + return; + }; + resize_tab_panes( + app, + terminal_runtimes, + workspace_index, + tab, + area, + cell_size, + ); } pub(crate) fn render_tab_surface( @@ -63,6 +126,7 @@ pub(crate) fn render_tab_surface( app, terminal_runtimes, frame, + surface.target, surface.pane_infos, surface.split_borders, ); @@ -73,7 +137,7 @@ pub(crate) fn tab_surface_hyperlinks( terminal_runtimes: &TerminalRuntimeRegistry, surface: TabSurfaceView<'_>, ) -> Vec<((u16, u16), String, String)> { - let Some(ws_idx) = app.active else { + let Some(ws_idx) = surface.target.map(|target| target.workspace_index) else { return Vec::new(); }; if app.workspaces.get(ws_idx).is_none() { @@ -95,7 +159,7 @@ pub(crate) fn tab_surface_cursor( terminal_runtimes: &TerminalRuntimeRegistry, surface: TabSurfaceView<'_>, ) -> Option<CursorState> { - let ws_idx = app.active?; + let ws_idx = surface.target?.workspace_index; let info = surface.pane_infos.iter().find(|info| info.is_focused)?; if !app.pane_exposes_host_cursor(ws_idx, info.id) { return None; @@ -192,6 +256,7 @@ mod tests { app.view.pane_infos.clear(); let surface_view = TabSurfaceView { + target: surface.target, pane_infos: &surface.pane_infos, split_borders: &surface.split_borders, }; diff --git a/tests/multi_client.rs b/tests/multi_client.rs index ae642a22..514493cb 100644 --- a/tests/multi_client.rs +++ b/tests/multi_client.rs @@ -14,7 +14,7 @@ use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize} use serde_json::Value; use support::{ cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir, - register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, + register_spawned_herdr_pid, send_client_shell_focus, send_detach, unregister_spawned_herdr_pid, wait_for_client_shell_bootstrap, wait_for_message_variant, wait_for_message_variants, CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL, SERVER_MESSAGE_PANE_SURFACE, SERVER_MESSAGE_PANE_SURFACE_PATCH, @@ -252,8 +252,29 @@ fn tty_size(socket: &Path, pane: &str, timeout: Duration) -> (u16, u16) { panic!("pane did not report tty size: {}", pane_text(socket, pane)); } +fn wait_for_tty_size( + socket: &Path, + pane: &str, + timeout: Duration, + expected: impl Fn((u16, u16)) -> bool, +) -> (u16, u16) { + let deadline = Instant::now() + timeout; + loop { + let size = tty_size( + socket, + pane, + deadline.saturating_duration_since(Instant::now()), + ); + if expected(size) { + return size; + } + assert!(Instant::now() < deadline, "unexpected tty size: {size:?}"); + thread::sleep(Duration::from_millis(50)); + } +} + #[test] -fn effective_size_uses_smallest_foreground_client_and_recovers_on_disconnect() { +fn same_tab_geometry_follows_meaningful_client_activity() { let _lock = test_lock(); let base = unique_test_dir(); let config = base.join("config"); @@ -264,26 +285,24 @@ fn effective_size_uses_smallest_foreground_client_and_recovers_on_disconnect() { wait_for_socket(&api, Duration::from_secs(10)); wait_for_file(&clients, Duration::from_secs(10)); let pane = create_pane(&api, "effective-size"); - let mut large = shell(&clients, 120, 40); + let _large = shell(&clients, 120, 40); let mut small = shell(&clients, 80, 24); - let reduced = tty_size(&api, &pane, Duration::from_secs(5)); + let initial = tty_size(&api, &pane, Duration::from_secs(5)); assert!( - reduced.0 <= 24 && reduced.1 <= 80, - "small ClientShell should determine effective size: {reduced:?}" + initial.0 > 24 && initial.1 > 80, + "a passive second connection must not steal geometry: {initial:?}" ); + + send_client_shell_focus(&mut small, true).unwrap(); + let reduced = wait_for_tty_size(&api, &pane, Duration::from_secs(5), |(rows, cols)| { + rows <= 24 && cols <= 80 + }); + send_detach(&mut small).unwrap(); drop(small); - assert!(wait_for_message_variant( - &mut large, - Duration::from_secs(3), - SERVER_MESSAGE_PANE_SURFACE - ) - .unwrap()); - let restored = tty_size(&api, &pane, Duration::from_secs(5)); - assert!( - restored.0 > reduced.0 && restored.1 > reduced.1, - "size should recover: {reduced:?} -> {restored:?}" - ); + wait_for_tty_size(&api, &pane, Duration::from_secs(5), |(rows, cols)| { + rows > reduced.0 && cols > reduced.1 + }); cleanup(server, base); } diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 68337ade..b2dae4be 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -21,6 +21,7 @@ pub const SERVER_MESSAGE_PANE_SURFACE: u32 = 13; pub const SERVER_MESSAGE_SEMANTIC_NOTIFICATION: u32 = 14; pub const SERVER_MESSAGE_PANE_SURFACE_PATCH: u32 = 19; const CLIENT_MESSAGE_CLIENT_SHELL_PANE_INPUT: u32 = 13; +const CLIENT_MESSAGE_CLIENT_SHELL_FOCUS: u32 = 18; const CLIENT_MESSAGE_ENDPOINT_CONTROL: u32 = 20; pub fn register_spawned_herdr_pid(pid: Option<u32>) { @@ -387,6 +388,17 @@ pub fn send_client_shell_shift_enter(stream: &mut UnixStream, pane_id: &str) -> .map_err(|e| format!("flush client shell key: {e}")) } +pub fn send_client_shell_focus(stream: &mut UnixStream, focused: bool) -> Result<(), String> { + let mut payload = encode_varint_u32(CLIENT_MESSAGE_CLIENT_SHELL_FOCUS); + payload.push(u8::from(focused)); + stream + .write_all(&frame_message(&payload)) + .map_err(|e| format!("write client shell focus: {e}"))?; + stream + .flush() + .map_err(|e| format!("flush client shell focus: {e}")) +} + pub fn send_detach(stream: &mut UnixStream) -> Result<(), String> { let detach_payload = encode_varint_u32(4); let framed = frame_message(&detach_payload); |
