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 /src | |
| parent | cc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (diff) | |
feat: support independent multi-client tab views (#3526)
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/api.rs | 81 | ||||
| -rw-r--r-- | src/app/creation.rs | 19 | ||||
| -rw-r--r-- | src/app/ids.rs | 8 | ||||
| -rw-r--r-- | src/app/popup.rs | 5 | ||||
| -rw-r--r-- | src/app/state.rs | 39 | ||||
| -rw-r--r-- | src/client/shell/actions.rs | 7 | ||||
| -rw-r--r-- | src/client/shell/tests/keybindings_settings.rs | 4 | ||||
| -rw-r--r-- | src/kitty_graphics.rs | 18 | ||||
| -rw-r--r-- | src/kitty_graphics/surface.rs | 2 | ||||
| -rw-r--r-- | src/server/client_commands.rs | 30 | ||||
| -rw-r--r-- | src/server/client_shell.rs | 143 | ||||
| -rw-r--r-- | src/server/client_shell_graphics.rs | 2 | ||||
| -rw-r--r-- | src/server/clients.rs | 77 | ||||
| -rw-r--r-- | src/server/headless.rs | 311 | ||||
| -rw-r--r-- | src/server/headless/client_views.rs | 789 | ||||
| -rw-r--r-- | src/server/headless/notifications.rs | 49 | ||||
| -rw-r--r-- | src/server/headless/render.rs | 260 | ||||
| -rw-r--r-- | src/server/headless/retained_surface.rs | 6 | ||||
| -rw-r--r-- | src/server/headless/tests/mod.rs | 848 | ||||
| -rw-r--r-- | src/server/headless/tests/pane_graphics.rs | 8 | ||||
| -rw-r--r-- | src/server/render_scale_benchmark.rs | 13 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 12 | ||||
| -rw-r--r-- | src/ui.rs | 15 | ||||
| -rw-r--r-- | src/ui/panes.rs | 76 | ||||
| -rw-r--r-- | src/ui/tab_surface.rs | 89 |
25 files changed, 2504 insertions, 407 deletions
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, }; |
