aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless/tests
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-02 14:24:33 +0300
committerGitHub <noreply@github.com>2026-09-02 14:24:33 +0300
commit6c0bb273d5d5405a00985621b17e36f8b4d64609 (patch)
treeceef5ea8adf337bf3c1e977b133e76cb763c6f1b /src/server/headless/tests
parentcc88b3b8e5bb9f7d9f23ed6ae85a52fd7b5b9ed6 (diff)
feat: support independent multi-client tab views (#3526)
Diffstat (limited to 'src/server/headless/tests')
-rw-r--r--src/server/headless/tests/mod.rs848
-rw-r--r--src/server/headless/tests/pane_graphics.rs8
2 files changed, 844 insertions, 12 deletions
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,