diff options
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/headless.rs | 17 | ||||
| -rw-r--r-- | src/server/headless/client_views.rs | 27 | ||||
| -rw-r--r-- | src/server/headless/endpoint_requests.rs | 14 | ||||
| -rw-r--r-- | src/server/headless/notifications.rs | 57 | ||||
| -rw-r--r-- | src/server/headless/pane_graphics.rs | 2 | ||||
| -rw-r--r-- | src/server/headless/tests/mod.rs | 249 |
6 files changed, 319 insertions, 47 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs index 0aed2a37..bba59498 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -2970,6 +2970,7 @@ impl HeadlessServer { &mut self, msg: api::ApiRequestMessage, skip_default_workspace_for_request: bool, + client_local: bool, ) -> bool { if self.shutting_down { // During shutdown, respond with server_unavailable. @@ -3124,12 +3125,18 @@ impl HeadlessServer { } if matches!( &msg.request.method, - api::schema::Method::WorktreeCreate(_) | api::schema::Method::WorktreeRemove(_) + api::schema::Method::WorktreeCreate(_) + | api::schema::Method::WorktreeRemove(_) + | api::schema::Method::WorktreeList(_) + | api::schema::Method::WorktreeOpen(_) ) { - let deferred_changed = self - .app - .handle_deferred_worktree_api_request(msg.request, msg.respond_to); - return changed | deferred_changed; + let read_only = matches!(&msg.request.method, api::schema::Method::WorktreeList(_)); + let deferred_changed = self.app.handle_deferred_worktree_api_request( + msg.request, + msg.respond_to, + client_local, + ); + return changed | (deferred_changed && !read_only); } if self.foreground_client_id.is_some_and(|client_id| { self.clients diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index c2e3c2d5..31b39be2 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -313,7 +313,10 @@ impl HeadlessServer { 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" { + if !matches!( + response.pointer("/result/type")?.as_str()?, + "worktree_created" | "worktree_opened" + ) { return None; } response @@ -851,24 +854,17 @@ impl HeadlessServer { 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 inspect_pane_move = matches!( &msg.request.method, api::schema::Method::PaneMove(params) if params.focus ); - let response_proxy = (agent_focus_target.is_some() - || inspect_worktree_open - || inspect_pane_move) - .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 response_proxy = (agent_focus_target.is_some() || inspect_pane_move).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 changed = self.handle_api_request_with_shutdown_check_inner(msg, false, false); let proxied_result = forward_proxied_api_response(response_proxy); let proxied_request_succeeded = proxied_result.is_some(); // Same-tab and zoomed moves succeed without moving or requesting focus. @@ -895,7 +891,6 @@ impl HeadlessServer { .is_some_and(|target| self.default_shell_target() == Some(target)); let public_focus_succeeded = explicit_focus_succeeded || (create_focus_requested && target_changed) - || (inspect_worktree_open && proxied_request_succeeded) || pane_move_focus_succeeded; if public_focus_succeeded { self.focus_all_shell_clients_on_default_target(); @@ -927,7 +922,7 @@ impl HeadlessServer { 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); + let changed = self.handle_api_request_with_shutdown_check_inner(msg, false, true); 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; diff --git a/src/server/headless/endpoint_requests.rs b/src/server/headless/endpoint_requests.rs index 4f02b676..afdcc80a 100644 --- a/src/server/headless/endpoint_requests.rs +++ b/src/server/headless/endpoint_requests.rs @@ -105,12 +105,16 @@ impl HeadlessServer { client.shell_endpoint_command_surface_revision = Some(client.shell_projection_revision); 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 + api::schema::Method::WorktreeCreate(_) + | api::schema::Method::WorktreeRemove(_) + | api::schema::Method::WorktreeList(_) + | api::schema::Method::WorktreeOpen(_) ); + let deferred_navigation = match &request.method { + api::schema::Method::WorktreeCreate(params) => params.focus, + api::schema::Method::WorktreeOpen(params) => params.focus, + _ => false, + }; client.shell_deferred_navigation_request_id = deferred_worktree.then(|| api_request_id.clone()); client.shell_deferred_navigation_response = deferred_navigation.then(Vec::new); diff --git a/src/server/headless/notifications.rs b/src/server/headless/notifications.rs index a575cfe1..e6d217e2 100644 --- a/src/server/headless/notifications.rs +++ b/src/server/headless/notifications.rs @@ -302,19 +302,25 @@ impl HeadlessServer { if self.host_shutdown_requested.load(Ordering::Acquire) { return false; } - let mut focused_worktree_response = if let AppEvent::WorktreeAddFinished(result) = &mut ev { - result + let focus_response = match &mut ev { + AppEvent::WorktreeAddFinished(result) => 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 + .map(|request| &mut request.respond_to), + AppEvent::WorktreeReadFinished(result) + if matches!(&result.request.method, + api::schema::Method::WorktreeOpen(params) if params.focus) => + { + Some(&mut result.respond_to) + } + _ => None, }; + let mut focused_worktree_response = focus_response.map(|respond_to| { + let (proxy_tx, proxy_rx) = std::sync::mpsc::channel(); + let original = std::mem::replace(respond_to, proxy_tx); + (original, proxy_rx) + }); match &ev { AppEvent::TerminalBell { pane_id, count } => { if !self.send_to_foreground_client(ServerMessage::TerminalBell { count: *count }) { @@ -568,16 +574,29 @@ 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) - }) - }); + AppEvent::WorktreeReadFinished(result) + if matches!(&result.request.method, api::schema::Method::WorktreeList(_)) => + { + self.app.handle_internal_event_with_render_impact(ev) + } + AppEvent::WorktreeAddFinished(_) | AppEvent::WorktreeReadFinished(_) => { + let deferred_request_id = match &ev { + AppEvent::WorktreeAddFinished(result) => result + .api_request + .as_ref() + .map(|request| request.id.as_str()), + AppEvent::WorktreeReadFinished(result) => Some(result.request.id.as_str()), + _ => None, + }; + let client_local = + matches!(&ev, AppEvent::WorktreeReadFinished(result) if result.client_local); + let shell_navigation_pending = client_local + || 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(), diff --git a/src/server/headless/pane_graphics.rs b/src/server/headless/pane_graphics.rs index e1410be1..947d2cb8 100644 --- a/src/server/headless/pane_graphics.rs +++ b/src/server/headless/pane_graphics.rs @@ -13,7 +13,7 @@ impl HeadlessServer { msg: api::ApiRequestMessage, ) -> RenderImpact { if self.shutting_down { - let _ = self.handle_api_request_with_shutdown_check_inner(msg, false); + let _ = self.handle_api_request_with_shutdown_check_inner(msg, false, false); return RenderImpact::None; } diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 6accdb09..5f54f4b6 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -1804,7 +1804,7 @@ async fn deferred_worktree_response_moves_only_its_source_client() { .get_mut(&51) .unwrap() .shell_deferred_navigation_response = Some(Vec::new()); - let response = serde_json::json!({ + let mut response = serde_json::json!({ "id": "create-worktree", "result": { "type": "worktree_created", @@ -1833,6 +1833,40 @@ async fn deferred_worktree_response_moves_only_its_source_client() { ); assert!(server.focus_shell_client_on_tab(51, &original_tab_id)); + response["result"]["type"] = serde_json::json!("worktree_opened"); + server + .clients + .get_mut(&51) + .unwrap() + .shell_endpoint_command_surface_revision = Some(source_surface_revision); + 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()); + assert!( + server.handle_server_event(ServerEvent::ClientShellEndpointResponseChunkReady { + client_id: 51, + boot_id: server.client_shell_boot_id.clone(), + request_id: "open-worktree".into(), + final_chunk: true, + data: serde_json::to_vec(&response).unwrap(), + }) + ); + assert_eq!( + server.shell_tab_id_for_client(51).as_deref(), + Some(created_tab_id.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)); // A source command begun in the old presentation epoch may finish after source-off and // source-on rollback. Its response remains endpoint-local, but it must not apply deferred // client navigation to the restored source. @@ -3143,6 +3177,219 @@ async fn client_shell_release_under_popup_renders_when_it_resets_scrollback() { } #[tokio::test] +async fn worktree_discovery_does_not_block_client_typing() { + use api::schema::{Method, WorktreeListParams, WorktreeOpenParams}; + + let mut server = test_headless_server(); + let mut workspace = crate::workspace::Workspace::test_new("worktree-input"); + let pane_id = workspace.tabs[0].root_pane; + let (runtime, mut input_rx) = + crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(80, 24, 0, b"", 4); + workspace.insert_test_runtime(pane_id, runtime); + let repo = std::env::temp_dir().join(format!("herdr-blocked-git-{}", workspace.id)); + workspace.identity_cwd = repo.clone(); + workspace.cached_git_space = Some(crate::workspace::GitSpaceMetadata { + key: repo.display().to_string(), + checkout_key: repo.display().to_string(), + repo_name: "blocked-git".into(), + repo_root: repo.clone(), + is_linked_worktree: false, + }); + let workspace_id = workspace.id.clone(); + server.app.state.workspaces = vec![workspace]; + server.app.state.active = Some(0); + server.app.state.selected = 0; + let public_pane_id = server.app.public_pane_id(0, pane_id).unwrap(); + server.clients.insert( + 11, + ClientConnection::new_with_mode( + ClientConnectionMode::ClientShell, + (80, 24), + crate::kitty_graphics::HostCellSize::default(), + 1, + RenderEncoding::SemanticFrame, + None, + ), + ); + server.foreground_client_id = Some(11); + + for (linked, method) in [ + ( + false, + Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id.clone()), + ..Default::default() + }), + ), + ( + true, + Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id.clone()), + ..Default::default() + }), + ), + ( + false, + Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(workspace_id), + path: Some(repo.display().to_string()), + focus: true, + ..Default::default() + }), + ), + ] { + server.app.state.workspaces[0] + .cached_git_space + .as_mut() + .unwrap() + .is_linked_worktree = linked; + let (entered, release) = crate::worktree::test_list_gate::block(&repo); + let (respond_to, response_rx) = std::sync::mpsc::channel(); + server.handle_api_request_with_shutdown_check(api::ApiRequestMessage { + request: api::schema::Request { + id: "blocked-read".into(), + method, + }, + respond_to, + response_write_complete: None, + stream_active: None, + }); + entered + .recv_timeout(Duration::from_secs(5)) + .expect("Git discovery started"); + assert!(response_rx.try_recv().is_err(), "Git must still be blocked"); + server.handle_server_event(ServerEvent::ClientShellPaneInput { + client_id: 11, + pane_id: public_pane_id.clone(), + events: vec![protocol::ClientPaneInputEvent::TextCommit("x".into())], + }); + assert_eq!( + input_rx + .try_recv() + .expect("typing reaches PTY while Git is blocked"), + Bytes::from_static(b"x") + ); + release.send(()).unwrap(); + let event = tokio::time::timeout(Duration::from_secs(5), server.app.event_rx.recv()) + .await + .unwrap() + .unwrap(); + server.handle_internal_event_with_forwarding(event); + let response: api::schema::ErrorResponse = + serde_json::from_str(&response_rx.recv_timeout(Duration::from_secs(5)).unwrap()) + .unwrap(); + assert_eq!(response.error.code, "worktree_list_failed"); + } + shutdown_test_runtimes(&mut server); +} + +#[tokio::test] +async fn deferred_worktree_open_disconnect_keeps_other_clients_focus() { + let mut server = test_headless_server(); + let mut source = crate::workspace::Workspace::test_new("pending-open-source"); + let repo = std::env::temp_dir().join(format!("herdr-disconnected-open-{}", source.id)); + let checkout = repo.with_extension("checkout"); + let git = |args: &[&str]| { + let output = std::process::Command::new("git") + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "--quiet", repo.to_str().unwrap()]); + git(&[ + "-C", + repo.to_str().unwrap(), + "-c", + "user.name=Herdr Test", + "-c", + "user.email=herdr@example.invalid", + "commit", + "--quiet", + "--allow-empty", + "-m", + "initial", + ]); + git(&[ + "-C", + repo.to_str().unwrap(), + "worktree", + "add", + "--quiet", + "-b", + "pending-open", + checkout.to_str().unwrap(), + ]); + source.identity_cwd = repo.clone(); + let source_id = source.id.clone(); + let mut target = crate::workspace::Workspace::test_new("pending-open-target"); + target.identity_cwd = checkout.clone(); + server.app.state.workspaces = vec![source, target]; + server.app.state.ensure_test_terminals(); + server.app.state.active = Some(0); + server.app.state.selected = 0; + let (source_control, _) = connect_matching_test_shell(&mut server, 51); + let (other_control, _) = connect_matching_test_shell(&mut server, 52); + let _ = source_control.recv().unwrap(); + let _ = other_control.recv().unwrap(); + let original_tab = server.shell_tab_id_for_client(52); + + let (entered, release) = crate::worktree::test_list_gate::block(&repo); + server.handle_server_event(ServerEvent::ClientShellEndpointRequest { + client_id: 51, + boot_id: server.client_shell_boot_id.clone(), + request: Box::new(api::schema::Request { + id: "open-before-disconnect".into(), + method: api::schema::Method::WorktreeOpen(api::schema::WorktreeOpenParams { + workspace_id: Some(source_id), + branch: Some("pending-open".into()), + focus: true, + ..Default::default() + }), + }), + }); + entered + .recv_timeout(Duration::from_secs(5)) + .expect("Git started"); + server.handle_server_event(ServerEvent::ClientDisconnected { client_id: 51 }); + release.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let event = server.app.event_rx.recv().await.unwrap(); + let completed = matches!(&event, AppEvent::WorktreeReadFinished(_)); + server.handle_internal_event_with_forwarding(event); + if completed { + break; + } + } + }) + .await + .unwrap(); + assert!( + server.app.state.workspaces[1].worktree_space().is_some(), + "open completed successfully" + ); + assert_eq!( + server.shell_tab_id_for_client(52), + original_tab, + "disconnected endpoint must not turn into public navigation" + ); + shutdown_test_runtimes(&mut server); + git(&[ + "-C", + repo.to_str().unwrap(), + "worktree", + "remove", + checkout.to_str().unwrap(), + ]); + let _ = std::fs::remove_dir_all(repo); +} + +#[tokio::test] async fn client_shell_text_input_renders_only_when_resetting_scrollback() { let mut server = test_headless_server(); let mut workspace = crate::workspace::Workspace::test_new("scrolled-input"); |
