aboutsummaryrefslogtreecommitdiff
path: root/src/server
diff options
context:
space:
mode:
authorJJ Liebig <jonathan.liebig@gmail.com>2026-09-22 18:43:12 +0200
committerGitHub <noreply@github.com>2026-09-22 19:43:12 +0300
commitdd33ddf28d693b2c64ed91f75656c61416ee2538 (patch)
tree229769fa9e2535b72dc39ba0f64593ef5fdc93a2 /src/server
parent1c6397b1cddb288099ed212a5979732698bd72c2 (diff)
fix: move worktree lookups async (#4492)
Diffstat (limited to 'src/server')
-rw-r--r--src/server/headless.rs17
-rw-r--r--src/server/headless/client_views.rs27
-rw-r--r--src/server/headless/endpoint_requests.rs14
-rw-r--r--src/server/headless/notifications.rs57
-rw-r--r--src/server/headless/pane_graphics.rs2
-rw-r--r--src/server/headless/tests/mod.rs249
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");