diff options
| author | JJ Liebig <jonathan.liebig@gmail.com> | 2026-09-22 18:43:12 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-22 19:43:12 +0300 |
| commit | dd33ddf28d693b2c64ed91f75656c61416ee2538 (patch) | |
| tree | 229769fa9e2535b72dc39ba0f64593ef5fdc93a2 /src | |
| parent | 1c6397b1cddb288099ed212a5979732698bd72c2 (diff) | |
fix: move worktree lookups async (#4492)
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 1 | ||||
| -rw-r--r-- | src/app/api.rs | 22 | ||||
| -rw-r--r-- | src/app/api/worktrees.rs | 728 | ||||
| -rw-r--r-- | src/app/api/worktrees/deferred.rs | 6 | ||||
| -rw-r--r-- | src/app/api/worktrees/reads.rs | 339 | ||||
| -rw-r--r-- | src/app/mod.rs | 2 | ||||
| -rw-r--r-- | src/events.rs | 23 | ||||
| -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 | ||||
| -rw-r--r-- | src/worktree.rs | 43 |
14 files changed, 1156 insertions, 374 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 953bcd94..e1bedd26 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1604,6 +1604,7 @@ impl AppState { } AppEvent::WorktreeAddFinished(_) => Vec::new(), AppEvent::WorktreeRemoveFinished(_) => Vec::new(), + AppEvent::WorktreeReadFinished(_) => Vec::new(), AppEvent::TabBarCommandFinished { .. } => Vec::new(), AppEvent::PluginCommandFinished { .. } => Vec::new(), } diff --git a/src/app/api.rs b/src/app/api.rs index 1b5bad71..c56a07a1 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -39,6 +39,14 @@ impl App { segment_index, result, } => self.handle_tab_bar_command_finished(generation, segment_index, result), + AppEvent::WorktreeReadFinished(result) => { + let changes_workspace = matches!( + &result.request.method, + crate::api::schema::Method::WorktreeOpen(_) + ); + self.handle_api_worktree_read_finished(*result); + changes_workspace + } ev @ AppEvent::TerminalBell { .. } => { self.handle_internal_event(ev); false @@ -157,6 +165,11 @@ impl App { return Vec::new(); } + if let AppEvent::WorktreeReadFinished(result) = ev { + self.handle_api_worktree_read_finished(*result); + return Vec::new(); + } + if let AppEvent::WorktreeAddFinished(result) = ev { self.handle_api_worktree_add_finished(*result); return Vec::new(); @@ -1035,7 +1048,13 @@ impl App { Method::WorkspaceClose(target) => { return self.handle_workspace_close(request.id, target); } - Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params), + Method::WorktreeList(_) | Method::WorktreeOpen(_) => { + return responses::encode_error( + request.id, + "invalid_request", + "worktree discovery is handled asynchronously by the app runtime", + ); + } Method::WorktreeCreate(params) => { let _ = params; return responses::encode_error( @@ -1044,7 +1063,6 @@ impl App { "worktree.create is handled asynchronously by the app runtime", ); } - Method::WorktreeOpen(params) => return self.handle_worktree_open(request.id, params), Method::WorktreeRemove(params) => { let _ = params; return responses::encode_error( diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index f578f101..b2fc0330 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -1,14 +1,15 @@ use std::path::{Path, PathBuf}; use crate::api::schema::{ - EventData, EventEnvelope, EventKind, ResponseResult, WorktreeInfo, WorktreeListParams, - WorktreeOpenParams, WorktreeSourceInfo, + EventData, EventEnvelope, EventKind, ResponseResult, WorktreeInfo, WorktreeOpenParams, + WorktreeSourceInfo, }; use crate::app::App; use super::responses::{encode_error, encode_success}; mod deferred; +mod reads; struct ApiFailure { code: &'static str, @@ -45,26 +46,12 @@ struct WorktreeSource { } impl App { - pub(super) fn handle_worktree_list( - &mut self, + fn finish_worktree_list( + &self, id: String, - params: WorktreeListParams, + source: WorktreeSource, + entries: Vec<crate::worktree::ExistingWorktree>, ) -> String { - let source = match self.resolve_worktree_list_source( - params.workspace_id, - params.cwd, - params.trust_repository, - ) { - Ok(source) => source, - Err(err) => return encode_error(id, err.code, err.message), - }; - let entries = match crate::worktree::list_existing_worktrees( - &source.source_repo_root, - params.trust_repository, - ) { - Ok(entries) => entries, - Err(err) => return encode_error(id, "worktree_list_failed", err), - }; let worktrees = entries .into_iter() .map(|entry| self.worktree_info_for_entry(&source, entry)) @@ -79,35 +66,29 @@ impl App { ) } - pub(super) fn handle_worktree_open( + fn finish_worktree_open( &mut self, id: String, params: WorktreeOpenParams, + mut source: WorktreeSource, + entry: crate::worktree::ExistingWorktree, ) -> String { - if params.path.is_some() == params.branch.is_some() { - return encode_error( - id, - "invalid_request", - "exactly one of path or branch is required", - ); - } - let mut source = match self.resolve_worktree_source(params.workspace_id, params.cwd) { - Ok(source) => source, - Err(err) => return encode_error(id, err.code, err.message), - }; - let entry = match self.find_worktree_entry( - &source, - params.path, - params.branch, - params.trust_repository, - ) { - Ok(entry) => entry, - Err(err) => return encode_error(id, err.code, err.message), - }; if entry.is_bare || entry.is_prunable { return encode_error(id, "worktree_not_found", "worktree cannot be opened"); } let canonical_path = crate::worktree::canonical_or_original(&entry.path); + let target_matches = + crate::workspace::git_space_metadata(&entry.path).is_some_and(|space| { + space.key == source.repo_key + && space.checkout_key == canonical_path.display().to_string() + }); + if !target_matches { + return encode_error( + id, + "worktree_not_found", + "worktree checkout changed during discovery; retry the request", + ); + } let canonical_source = crate::worktree::canonical_or_original(&source.source_checkout_path); let target_is_source = canonical_path == canonical_source; let already_open = self.open_workspace_idx_for_checkout(&canonical_path); @@ -243,60 +224,6 @@ impl App { self.worktree_source_from_workspace(ws_idx) } - fn resolve_worktree_list_source( - &mut self, - workspace_id: Option<String>, - cwd: Option<String>, - trust_repository: bool, - ) -> Result<WorktreeSource, ApiFailure> { - if workspace_id.is_some() && cwd.is_some() { - return Err(ApiFailure::new( - "invalid_request", - "only one of workspace_id or cwd may be supplied", - )); - } - - if let Some(workspace_id) = workspace_id { - let Some(ws_idx) = self.parse_workspace_id(&workspace_id) else { - return Err(ApiFailure::new( - "workspace_not_found", - format!("workspace {workspace_id} not found"), - )); - }; - return self.worktree_list_source_from_workspace(ws_idx, trust_repository); - } - - if let Some(cwd) = cwd { - let path = absolute_user_path(&cwd)?; - let space = crate::workspace::git_space_metadata(&path).ok_or_else(|| { - ApiFailure::new( - "not_git_worktree", - "Herdr worktree actions require a path inside a Git work tree", - ) - })?; - let workspace_idx = self.list_source_workspace_idx_for_space(&space, trust_repository); - return Ok(worktree_source_from_space( - space, - workspace_idx, - true, - trust_repository, - )); - } - - let Some(ws_idx) = self.state.active.or_else(|| { - self.state - .workspaces - .get(self.state.selected) - .map(|_| self.state.selected) - }) else { - return Err(ApiFailure::new( - "invalid_request", - "workspace_id or cwd is required when no workspace is active", - )); - }; - self.worktree_list_source_from_workspace(ws_idx, trust_repository) - } - fn worktree_source_from_workspace(&self, ws_idx: usize) -> Result<WorktreeSource, ApiFailure> { let Some(ws) = self.state.workspaces.get(ws_idx) else { return Err(ApiFailure::new( @@ -346,61 +273,6 @@ impl App { }) } - fn worktree_list_source_from_workspace( - &self, - ws_idx: usize, - trust_repository: bool, - ) -> Result<WorktreeSource, ApiFailure> { - let Some(ws) = self.state.workspaces.get(ws_idx) else { - return Err(ApiFailure::new( - "workspace_not_found", - "workspace not found", - )); - }; - if let Some(membership) = ws.worktree_space() { - let source_checkout_path = if membership.is_linked_worktree { - membership.repo_root.clone() - } else { - membership.checkout_path.clone() - }; - let workspace_idx = if membership.is_linked_worktree { - self.open_workspace_idx_for_checkout(&membership.repo_root) - } else { - Some(ws_idx) - }; - return Ok(WorktreeSource { - workspace_idx, - source_checkout_path, - source_repo_root: membership.repo_root.clone(), - repo_key: membership.key.clone(), - repo_name: membership.label.clone(), - }); - } - - let git_space = ws.git_space().cloned().or_else(|| { - ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) - .as_deref() - .and_then(crate::workspace::git_space_metadata) - }); - let Some(space) = git_space else { - return Err(ApiFailure::new( - "not_git_worktree", - "Herdr worktree actions require a workspace inside a Git work tree", - )); - }; - let workspace_idx = if space.is_linked_worktree { - self.list_source_workspace_idx_for_space(&space, trust_repository) - } else { - Some(ws_idx) - }; - Ok(worktree_source_from_space( - space, - workspace_idx, - true, - trust_repository, - )) - } - fn ensure_source_parent_membership( &mut self, source: &mut WorktreeSource, @@ -436,19 +308,6 @@ impl App { .or_else(|| self.open_workspace_idx_for_checkout(&space.repo_root)) } - fn list_source_workspace_idx_for_space( - &self, - space: &crate::workspace::GitSpaceMetadata, - trust_repository: bool, - ) -> Option<usize> { - if space.is_linked_worktree { - let parent_checkout = parent_checkout_path_for_space(space, trust_repository); - self.open_workspace_idx_for_checkout(&parent_checkout) - } else { - self.find_parent_workspace_for_space(space) - } - } - fn find_parent_workspace_by_key(&self, repo_key: &str) -> Option<usize> { self.state.workspaces.iter().position(|ws| { ws.worktree_space() @@ -495,52 +354,6 @@ impl App { } } - fn find_worktree_entry( - &self, - source: &WorktreeSource, - path: Option<String>, - branch: Option<String>, - trust_repository: bool, - ) -> Result<crate::worktree::ExistingWorktree, ApiFailure> { - let entries = - crate::worktree::list_existing_worktrees(&source.source_repo_root, trust_repository) - .map_err(|err| ApiFailure::new("worktree_list_failed", err))?; - if let Some(path) = path { - let expected = absolute_user_path(&path)?; - let expected = crate::worktree::canonical_or_original(&expected); - entries - .into_iter() - .find(|entry| crate::worktree::canonical_or_original(&entry.path) == expected) - .ok_or_else(|| ApiFailure::new("worktree_not_found", "worktree path not found")) - } else if let Some(branch) = branch { - let matches = entries - .into_iter() - .filter(|entry| { - !entry.is_bare - && !entry.is_prunable - && !entry.is_detached - && entry.branch.as_deref() == Some(branch.as_str()) - }) - .collect::<Vec<_>>(); - match matches.len() { - 0 => Err(ApiFailure::new( - "worktree_not_found", - "worktree branch not found", - )), - 1 => Ok(matches.into_iter().next().expect("one match should exist")), - _ => Err(ApiFailure::new( - "ambiguous_worktree_branch", - "multiple worktrees matched branch", - )), - } - } else { - Err(ApiFailure::new( - "invalid_request", - "exactly one of path or branch is required", - )) - } - } - fn worktree_source_info(&self, source: &WorktreeSource) -> WorktreeSourceInfo { WorktreeSourceInfo { repo_key: source.repo_key.clone(), @@ -682,49 +495,47 @@ impl App { } } -fn worktree_source_from_space( - space: crate::workspace::GitSpaceMetadata, - workspace_idx: Option<usize>, - allow_linked: bool, - trust_repository: bool, -) -> WorktreeSource { - let source_checkout_path = if allow_linked { - parent_checkout_path_for_space(&space, trust_repository) +fn find_worktree_entry( + entries: Vec<crate::worktree::ExistingWorktree>, + path: Option<String>, + branch: Option<String>, +) -> Result<crate::worktree::ExistingWorktree, ApiFailure> { + if let Some(path) = path { + let expected = absolute_user_path(&path)?; + let expected = crate::worktree::canonical_or_original(&expected); + entries + .into_iter() + .find(|entry| crate::worktree::canonical_or_original(&entry.path) == expected) + .ok_or_else(|| ApiFailure::new("worktree_not_found", "worktree path not found")) + } else if let Some(branch) = branch { + let matches = entries + .into_iter() + .filter(|entry| { + !entry.is_bare + && !entry.is_prunable + && !entry.is_detached + && entry.branch.as_deref() == Some(branch.as_str()) + }) + .collect::<Vec<_>>(); + match matches.len() { + 0 => Err(ApiFailure::new( + "worktree_not_found", + "worktree branch not found", + )), + 1 => Ok(matches.into_iter().next().expect("one match should exist")), + _ => Err(ApiFailure::new( + "ambiguous_worktree_branch", + "multiple worktrees matched branch", + )), + } } else { - space.repo_root.clone() - }; - WorktreeSource { - workspace_idx, - source_checkout_path: source_checkout_path.clone(), - source_repo_root: source_checkout_path, - repo_key: space.key, - repo_name: space.repo_name, + Err(ApiFailure::new( + "invalid_request", + "exactly one of path or branch is required", + )) } } -fn parent_checkout_path_for_space( - space: &crate::workspace::GitSpaceMetadata, - trust_repository: bool, -) -> PathBuf { - if !space.is_linked_worktree { - return space.repo_root.clone(); - } - - crate::worktree::list_existing_worktrees(&space.repo_root, trust_repository) - .ok() - .and_then(|entries| { - entries.into_iter().find_map(|entry| { - let entry_space = crate::workspace::git_space_metadata(&entry.path)?; - if entry_space.key == space.key && !entry_space.is_linked_worktree { - Some(entry_space.repo_root) - } else { - None - } - }) - }) - .unwrap_or_else(|| space.repo_root.clone()) -} - fn worktree_membership( source: &WorktreeSource, checkout_path: PathBuf, @@ -745,7 +556,8 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use crate::api::schema::{ - ErrorResponse, Request, SuccessResponse, WorktreeCreateParams, WorktreeRemoveParams, + ErrorResponse, Request, SuccessResponse, WorktreeCreateParams, WorktreeListParams, + WorktreeRemoveParams, }; use crate::events::{ ApiWorktreeAddRequest, ApiWorktreeRemoveRequest, AppEvent, WorktreeAddResult, @@ -883,7 +695,7 @@ mod tests { fn run_deferred_api_request(app: &mut App, request: Request) -> String { let (respond_to, response_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request, respond_to)); + assert!(app.handle_deferred_worktree_api_request(request, respond_to, false)); if let Ok(response) = response_rx.recv_timeout(std::time::Duration::from_millis(50)) { return response; } @@ -1025,6 +837,7 @@ mod tests { }), }, respond_to, + false, )); assert!(response_rx.try_recv().is_err()); @@ -1100,7 +913,7 @@ mod tests { }; let (response_tx, response_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request(), response_tx)); + assert!(app.handle_deferred_worktree_api_request(request(), response_tx, false)); let event = wait_for_app_event(&mut app); app.handle_internal_event(event); let response = response_rx @@ -1165,7 +978,7 @@ mod tests { }; let (first_tx, first_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request(), first_tx)); + assert!(app.handle_deferred_worktree_api_request(request(), first_tx, false)); let event = wait_for_app_event(&mut app); app.handle_internal_event(event); let response = first_rx @@ -1176,7 +989,7 @@ mod tests { assert!(app.pending_api_worktree_creates.is_empty()); let (second_tx, second_rx) = response_channel(); - assert!(app.handle_deferred_worktree_api_request(request(), second_tx)); + assert!(app.handle_deferred_worktree_api_request(request(), second_tx, false)); assert!(second_rx.try_recv().is_err()); let event = wait_for_app_event(&mut app); app.handle_internal_event(event); @@ -1345,15 +1158,18 @@ mod tests { let repo = create_committed_repo("api-worktree-open-invalid-cwd-repo"); let mut app = test_app(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - cwd: Some(repo.display().to_string()), - path: Some("/tmp/one".into()), - branch: Some("worktree/one".into()), - ..WorktreeOpenParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + cwd: Some(repo.display().to_string()), + path: Some("/tmp/one".into()), + branch: Some("worktree/one".into()), + ..WorktreeOpenParams::default() + }), + }, + ); let error: ErrorResponse = serde_json::from_str(&response).unwrap(); assert_eq!(error.error.code, "invalid_request"); @@ -1436,15 +1252,19 @@ mod tests { app.state.workspaces.push(child); app.state.ensure_test_terminals(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - branch: Some("worktree/api-open".into()), - focus: true, - ..WorktreeOpenParams::default() - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(workspace_id), + branch: Some("worktree/api-open".into()), + focus: true, + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeOpened { @@ -1532,13 +1352,16 @@ mod tests { app.mark_worktree_membership(&source, 1, checkout.clone(), true, false); let child_membership = app.state.workspaces[1].worktree_space().cloned(); - let response = app.handle_api_request(Request { - id: "list".into(), - method: crate::api::schema::Method::WorktreeList(WorktreeListParams { - workspace_id: Some(parent_id.clone()), - ..WorktreeListParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "list".into(), + method: crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(parent_id.clone()), + ..WorktreeListParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { worktrees, .. } = success.result else { panic!("expected worktree_list response"); @@ -1549,15 +1372,18 @@ mod tests { .unwrap(); assert_eq!(entry.open_workspace_id.as_deref(), Some(child_id.as_str())); - let response = app.handle_api_request(Request { - id: "open".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - workspace_id: Some(parent_id), - branch: Some("worktree/membership".into()), - focus: false, - ..WorktreeOpenParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "open".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(parent_id), + branch: Some("worktree/membership".into()), + focus: false, + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeOpened { workspace, @@ -1616,15 +1442,19 @@ mod tests { app.state.active = Some(0); app.state.selected = 0; - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - branch: Some("worktree/api-open-label".into()), - label: Some("review".into()), - ..WorktreeOpenParams::default() - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(workspace_id), + branch: Some("worktree/api-open-label".into()), + label: Some("review".into()), + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeOpened { @@ -1665,21 +1495,251 @@ mod tests { } #[tokio::test] + async fn deferred_worktree_open_rejects_removed_or_replaced_target() { + for replacement in ["missing", "directory", "repository"] { + let repo = create_committed_repo("api-stale-target-source"); + let checkout = unique_temp_path("api-stale-target"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "stale-target", + checkout.to_str().unwrap(), + ], + ); + let mut app = app_with_parent(&repo); + let (respond_to, response_rx) = response_channel(); + let source_id = app.state.workspaces[0].id.clone(); + app.handle_deferred_worktree_api_request( + Request { + id: "stale-target".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(source_id), + branch: Some("stale-target".into()), + label: Some("must not rename".into()), + focus: true, + ..Default::default() + }), + }, + respond_to, + false, + ); + let completion = wait_for_app_event(&mut app); + run_git(&repo, &["worktree", "remove", checkout.to_str().unwrap()]); + match replacement { + "directory" => std::fs::create_dir_all(&checkout).unwrap(), + "repository" => { + let other_repo = create_committed_repo("api-stale-target-replacement"); + std::fs::rename(other_repo, &checkout).unwrap(); + } + _ => {} + } + let mut unrelated = Workspace::test_new("unrelated"); + unrelated.identity_cwd = checkout.clone(); + app.state.workspaces.push(unrelated); + app.state.ensure_test_terminals(); + app.handle_internal_event(completion); + let response: ErrorResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + assert_eq!(response.error.code, "worktree_not_found", "{replacement}"); + assert_eq!(app.state.workspaces.len(), 2); + assert_eq!(app.state.active, Some(0)); + assert!(app + .state + .workspaces + .iter() + .all(|ws| ws.worktree_space().is_none())); + app.state.assert_invariants_for_test(); + let _ = std::fs::remove_dir_all(checkout); + let _ = std::fs::remove_dir_all(repo); + } + } + + #[tokio::test] + async fn deferred_worktree_reads_bound_running_and_queued_discovery() { + let repo = create_committed_repo("api-read-limit"); + let mut app = app_with_parent(&repo); + app.worktree_read_slots = std::sync::Arc::new(tokio::sync::Semaphore::new(1)); + let source_id = app.state.workspaces[0].id.clone(); + let request = |open| Request { + id: "read-limit".into(), + method: if open { + crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(source_id.clone()), + path: Some(repo.display().to_string()), + ..Default::default() + }) + } else { + crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(source_id.clone()), + ..Default::default() + }) + }, + }; + let (entered, release) = crate::worktree::test_list_gate::block(&repo); + let (respond_to, response_rx) = response_channel(); + app.handle_deferred_worktree_api_request(request(false), respond_to, false); + entered + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + let assert_busy = |app: &mut App| { + for open in [false, true] { + let (respond_to, response_rx) = response_channel(); + app.handle_deferred_worktree_api_request(request(open), respond_to, false); + let response: ErrorResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + assert_eq!(response.error.code, "worktree_busy"); + } + }; + assert_busy(&mut app); + release.send(()).unwrap(); + let completion = wait_for_app_event(&mut app); + assert_busy(&mut app); + app.handle_internal_event(completion); + let _: SuccessResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + assert_eq!(app.worktree_read_slots.available_permits(), 1); + let response = run_deferred_api_request(&mut app, request(false)); + let _: SuccessResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(app.worktree_read_slots.available_permits(), 1); + let _ = std::fs::remove_dir_all(repo); + } + + #[tokio::test] + async fn deferred_worktree_open_rechecks_source_identity_and_existing_target() { + let repo = create_committed_repo("api-worktree-open-pending-source"); + let other_repo = create_committed_repo("api-worktree-open-unrelated-source"); + let checkout = unique_temp_path("api-worktree-open-pending-target"); + run_git( + &repo, + &[ + "worktree", + "add", + "--quiet", + "-b", + "pending-open", + checkout.to_str().unwrap(), + "HEAD", + ], + ); + + for source_change in ["reordered", "removed", "repurposed"] { + let mut app = app_with_parent(&repo); + let source_id = app.state.workspaces[0].id.clone(); + let (respond_to, response_rx) = response_channel(); + assert!(app.handle_deferred_worktree_api_request( + Request { + id: "pending-open".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + workspace_id: Some(source_id.clone()), + branch: Some("pending-open".into()), + focus: false, + ..Default::default() + }), + }, + respond_to, + false + )); + let completed_discovery = wait_for_app_event(&mut app); + assert!(response_rx.try_recv().is_err()); + assert!(app.state.workspaces[0].worktree_space().is_none()); + + let original_source = app.state.workspaces.remove(0); + let mut unrelated = Workspace::test_new("unrelated"); + unrelated.identity_cwd = other_repo.clone(); + if source_change == "repurposed" { + unrelated.id = source_id.clone(); + } + let mut opened_target = Workspace::test_new("opened-during-discovery"); + opened_target.identity_cwd = checkout.clone(); + let target_id = opened_target.id.clone(); + app.state.workspaces = vec![unrelated, opened_target]; + if source_change == "reordered" { + app.state.workspaces.push(original_source); + } + app.state.terminals.clear(); + app.state.ensure_test_terminals(); + app.state.active = Some(0); + app.state.selected = 0; + app.handle_internal_event(completed_discovery); + + let response: SuccessResponse = serde_json::from_str( + &response_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(), + ) + .unwrap(); + let ResponseResult::WorktreeOpened { + workspace, + already_open, + .. + } = response.result + else { + panic!("expected opened response"); + }; + assert!( + already_open, + "target opened during discovery must be reused" + ); + assert_eq!(workspace.workspace_id, target_id); + assert!( + app.state.workspaces[0].worktree_space().is_none(), + "{source_change} source must not mark an unrelated workspace" + ); + assert_eq!( + app.state.active, + Some(0), + "background open must preserve focus" + ); + assert_eq!(app.state.workspaces.len(), 3); + assert_eq!( + crate::worktree::canonical_or_original( + &app.state.workspaces[2].worktree_space().unwrap().repo_root + ), + crate::worktree::canonical_or_original(&repo) + ); + app.state.assert_invariants_for_test(); + } + let remove = crate::worktree::build_worktree_remove_command(&repo, &checkout, false, false); + crate::worktree::run_worktree_command(&remove).unwrap(); + let _ = std::fs::remove_dir_all(repo); + let _ = std::fs::remove_dir_all(other_repo); + } + + #[tokio::test] async fn api_worktree_open_source_checkout_created_by_request_is_not_already_open() { let repo = create_committed_repo("api-worktree-open-source-repo"); let event_hub = crate::api::EventHub::default(); let mut app = test_app_with_event_hub(event_hub.clone()); app.state.default_shell = test_shell().into(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { - cwd: Some(repo.display().to_string()), - path: Some(repo.display().to_string()), - label: Some("source checkout".into()), - ..WorktreeOpenParams::default() - }), - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeOpen(WorktreeOpenParams { + cwd: Some(repo.display().to_string()), + path: Some(repo.display().to_string()), + label: Some("source checkout".into()), + ..WorktreeOpenParams::default() + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap_or_else(|err| { panic!("expected success response, got {response}: {err}"); @@ -1739,14 +1799,18 @@ mod tests { app.state.workspaces.push(child); app.state.ensure_test_terminals(); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeList(WorktreeListParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - cwd: None, - trust_repository: false, - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id), + cwd: None, + trust_repository: false, + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { worktrees, .. } = success.result else { @@ -1803,10 +1867,13 @@ mod tests { trust_repository: false, }), ] { - let response = app.handle_api_request(Request { - id: "req".into(), - method, - }); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method, + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { source, worktrees } = success.result else { panic!("expected worktree_list response"); @@ -1849,14 +1916,18 @@ mod tests { std::fs::remove_dir_all(&checkout).unwrap(); let mut app = app_with_parent(&repo); - let response = app.handle_api_request(Request { - id: "req".into(), - method: crate::api::schema::Method::WorktreeList(WorktreeListParams { - workspace_id: Some(app.state.workspaces[0].id.clone()), - cwd: None, - trust_repository: false, - }), - }); + let workspace_id = app.state.workspaces[0].id.clone(); + let response = run_deferred_api_request( + &mut app, + Request { + id: "req".into(), + method: crate::api::schema::Method::WorktreeList(WorktreeListParams { + workspace_id: Some(workspace_id), + cwd: None, + trust_repository: false, + }), + }, + ); let success: SuccessResponse = serde_json::from_str(&response).unwrap(); let ResponseResult::WorktreeList { worktrees, .. } = success.result else { @@ -2075,6 +2146,7 @@ mod tests { }), }, respond_to, + false, )); assert!(response_rx.try_recv().is_err()); @@ -2149,6 +2221,7 @@ mod tests { }), }, first_tx, + false, )); assert!(app.handle_deferred_worktree_api_request( Request { @@ -2160,6 +2233,7 @@ mod tests { }), }, second_tx, + false, )); let response = second_rx .recv_timeout(std::time::Duration::from_secs(2)) @@ -2220,6 +2294,7 @@ mod tests { }), }, first_tx, + false, )); assert!(app.handle_deferred_worktree_api_request( Request { @@ -2231,6 +2306,7 @@ mod tests { }), }, second_tx, + false, )); let response = second_rx .recv_timeout(std::time::Duration::from_secs(2)) @@ -2267,6 +2343,7 @@ mod tests { }), }, respond_to, + false, )); let response = response_rx @@ -2322,6 +2399,7 @@ mod tests { }), }, respond_to, + false, )); let response = response_rx diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 9adbd70c..0f5ed547 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -16,8 +16,14 @@ impl App { &mut self, request: Request, respond_to: std::sync::mpsc::Sender<String>, + client_local: bool, ) -> bool { match request.method { + crate::api::schema::Method::WorktreeList(_) + | crate::api::schema::Method::WorktreeOpen(_) => { + self.start_api_worktree_read(request, respond_to, client_local); + true + } crate::api::schema::Method::WorktreeCreate(params) => { self.start_api_worktree_create(request.id, params, respond_to); true diff --git a/src/app/api/worktrees/reads.rs b/src/app/api/worktrees/reads.rs new file mode 100644 index 00000000..5024e7f0 --- /dev/null +++ b/src/app/api/worktrees/reads.rs @@ -0,0 +1,339 @@ +use std::path::PathBuf; + +use crate::api::schema::{Method, Request}; +use crate::app::App; +use crate::events::{AppEvent, WorktreeReadData, WorktreeReadResult}; +use crate::workspace::{GitSpaceMetadata, WorktreeSpaceMembership}; + +use super::super::responses::encode_error; +use super::{absolute_user_path, find_worktree_entry, ApiFailure, WorktreeSource}; + +// Capture only source provenance. Filesystem discovery and Git run on the worker; +// workspace indices and the target's open state are resolved again on completion. +struct SourceInput { + workspace_id: Option<String>, + membership: Option<WorktreeSpaceMembership>, + git_space: Option<GitSpaceMetadata>, + cwd: Option<PathBuf>, +} + +impl SourceInput { + fn resolve( + mut self, + allow_linked: bool, + trust_repository: bool, + ) -> Result<(Option<String>, WorktreeSource), ApiFailure> { + let source = if let Some(membership) = self.membership { + if membership.is_linked_worktree && !allow_linked { + return Err(linked_source_error()); + } + let source_checkout_path = if membership.is_linked_worktree { + self.workspace_id = None; + membership.repo_root.clone() + } else { + membership.checkout_path + }; + WorktreeSource { + workspace_idx: None, + source_checkout_path, + source_repo_root: membership.repo_root, + repo_key: membership.key, + repo_name: membership.label, + } + } else { + let space = self + .git_space + .or_else(|| { + self.cwd + .as_deref() + .and_then(crate::workspace::git_space_metadata) + }) + .ok_or_else(|| { + ApiFailure::new( + "not_git_worktree", + if self.workspace_id.is_some() { + "Herdr worktree actions require a workspace inside a Git work tree" + } else { + "Herdr worktree actions require a path inside a Git work tree" + }, + ) + })?; + if space.is_linked_worktree { + if !allow_linked { + return Err(linked_source_error()); + } + self.workspace_id = None; + } + worktree_source_from_space(space, allow_linked, trust_repository) + }; + Ok((self.workspace_id, source)) + } +} + +fn linked_source_error() -> ApiFailure { + ApiFailure::new( + "linked_worktree_source", + "New and open worktree actions start from the repo parent workspace.", + ) +} + +impl App { + fn capture_worktree_read_source( + &self, + workspace_id: &Option<String>, + cwd: &Option<String>, + ) -> Result<SourceInput, ApiFailure> { + if workspace_id.is_some() && cwd.is_some() { + return Err(ApiFailure::new( + "invalid_request", + "only one of workspace_id or cwd may be supplied", + )); + } + if let Some(cwd) = cwd { + return Ok(SourceInput { + workspace_id: None, + membership: None, + git_space: None, + cwd: Some(absolute_user_path(cwd)?), + }); + } + let ws_idx = if let Some(workspace_id) = workspace_id { + self.parse_workspace_id(workspace_id).ok_or_else(|| { + ApiFailure::new( + "workspace_not_found", + format!("workspace {workspace_id} not found"), + ) + })? + } else { + self.state + .active + .or_else(|| { + self.state + .workspaces + .get(self.state.selected) + .map(|_| self.state.selected) + }) + .ok_or_else(|| { + ApiFailure::new( + "invalid_request", + "workspace_id or cwd is required when no workspace is active", + ) + })? + }; + let ws = &self.state.workspaces[ws_idx]; + Ok(SourceInput { + workspace_id: Some(ws.id.clone()), + membership: ws.worktree_space().cloned(), + git_space: ws.git_space().cloned(), + cwd: if ws.worktree_space().is_none() && ws.git_space().is_none() { + ws.resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) + } else { + None + }, + }) + } + + pub(super) fn start_api_worktree_read( + &mut self, + request: Request, + respond_to: std::sync::mpsc::Sender<String>, + client_local: bool, + ) { + let (workspace_id, cwd, allow_linked, trust_repository) = match &request.method { + Method::WorktreeList(params) => ( + ¶ms.workspace_id, + ¶ms.cwd, + true, + params.trust_repository, + ), + Method::WorktreeOpen(params) => { + if params.path.is_some() == params.branch.is_some() { + let _ = respond_to.send(encode_error( + request.id, + "invalid_request", + "exactly one of path or branch is required", + )); + return; + } + ( + ¶ms.workspace_id, + ¶ms.cwd, + false, + params.trust_repository, + ) + } + _ => unreachable!("only worktree list/open use background discovery"), + }; + let input = match self.capture_worktree_read_source(workspace_id, cwd) { + Ok(input) => input, + Err(err) => { + let _ = respond_to.send(encode_error(request.id, err.code, err.message)); + return; + } + }; + let Ok(permit) = self.worktree_read_slots.clone().try_acquire_owned() else { + let _ = respond_to.send(encode_error( + request.id, + "worktree_busy", + "too many worktree checks are pending; retry shortly", + )); + return; + }; + let event_tx = self.event_tx.clone(); + let spawn_error_response = respond_to.clone(); + let request_id = request.id.clone(); + let spawned = std::thread::Builder::new() + .name("worktree-read".into()) + .spawn(move || { + let source_cwd = input.cwd.clone(); + let mut source_workspace_id = None; + let result = input + .resolve(allow_linked, trust_repository) + .and_then(|(workspace_id, source)| { + source_workspace_id = workspace_id; + let mut entries = crate::worktree::list_existing_worktrees( + &source.source_repo_root, + trust_repository, + ) + .map_err(|err| ApiFailure::new("worktree_list_failed", err))?; + if let Method::WorktreeOpen(params) = &request.method { + entries = vec![find_worktree_entry( + entries, + params.path.clone(), + params.branch.clone(), + )?]; + } + Ok(WorktreeReadData { + source_checkout_path: source.source_checkout_path, + source_repo_root: source.source_repo_root, + repo_key: source.repo_key, + repo_name: source.repo_name, + entries, + }) + }) + .map_err(|err| (err.code.to_string(), err.message)); + let _ = event_tx.blocking_send(AppEvent::WorktreeReadFinished(Box::new( + WorktreeReadResult { + _permit: permit, + request, + client_local, + source_workspace_id, + source_cwd, + result, + respond_to, + }, + ))); + }); + if let Err(err) = spawned { + let _ = spawn_error_response.send(encode_error( + request_id, + "worktree_list_failed", + format!("could not start worktree discovery: {err}"), + )); + } + } + + pub(crate) fn handle_api_worktree_read_finished(&mut self, result: WorktreeReadResult) { + let response = match result.result { + Err((code, message)) => encode_error(result.request.id, &code, message), + Ok(data) => { + let mut source = WorktreeSource { + workspace_idx: None, + source_checkout_path: data.source_checkout_path, + source_repo_root: data.source_repo_root, + repo_key: data.repo_key, + repo_name: data.repo_name, + }; + // An index captured before Git ran could now name a different workspace. + source.workspace_idx = result + .source_workspace_id + .as_ref() + .and_then(|id| self.state.workspaces.iter().position(|ws| &ws.id == id)) + .filter(|&idx| { + let ws = &self.state.workspaces[idx]; + if let Some(current) = ws.worktree_space() { + !current.is_linked_worktree + && current.key == source.repo_key + && current.repo_root == source.source_repo_root + && current.checkout_path == source.source_checkout_path + } else if let Some(current) = ws.git_space() { + !current.is_linked_worktree + && current.key == source.repo_key + && current.repo_root == source.source_repo_root + } else { + // Git was already discovered on the worker. A cache miss here + // must only compare the captured source path, not rediscover it. + result.source_cwd.as_ref().is_some_and(|cwd| { + ws.resolved_identity_cwd_from( + &self.state.terminals, + &self.terminal_runtimes, + ) + .as_ref() + == Some(cwd) + }) + } + }) + .or_else(|| self.find_parent_workspace_by_key(&source.repo_key)) + .or_else(|| self.open_workspace_idx_for_checkout(&source.source_checkout_path)); + match result.request.method { + Method::WorktreeList(_) => { + self.finish_worktree_list(result.request.id, source, data.entries) + } + Method::WorktreeOpen(params) => self.finish_worktree_open( + result.request.id, + params, + source, + data.entries + .into_iter() + .next() + .expect("open discovery selects one worktree"), + ), + _ => unreachable!("only worktree list/open use background discovery"), + } + } + }; + let _ = result.respond_to.send(response); + } +} + +fn worktree_source_from_space( + space: crate::workspace::GitSpaceMetadata, + allow_linked: bool, + trust_repository: bool, +) -> WorktreeSource { + let source_checkout_path = if allow_linked { + parent_checkout_path_for_space(&space, trust_repository) + } else { + space.repo_root.clone() + }; + WorktreeSource { + workspace_idx: None, + source_checkout_path: source_checkout_path.clone(), + source_repo_root: source_checkout_path, + repo_key: space.key, + repo_name: space.repo_name, + } +} + +fn parent_checkout_path_for_space( + space: &crate::workspace::GitSpaceMetadata, + trust_repository: bool, +) -> PathBuf { + if !space.is_linked_worktree { + return space.repo_root.clone(); + } + + crate::worktree::list_existing_worktrees(&space.repo_root, trust_repository) + .ok() + .and_then(|entries| { + entries.into_iter().find_map(|entry| { + let entry_space = crate::workspace::git_space_metadata(&entry.path)?; + if entry_space.key == space.key && !entry_space.is_linked_worktree { + Some(entry_space.repo_root) + } else { + None + } + }) + }) + .unwrap_or_else(|| space.repo_root.clone()) +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 7ff3040b..03e89026 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -124,6 +124,7 @@ pub struct App { pub(crate) git_identity_refresh_requested: bool, pub(crate) git_status_cache: HashMap<std::path::PathBuf, crate::workspace::GitStatusCacheEntry>, pub(crate) pending_api_worktree_creates: HashMap<std::path::PathBuf, u64>, + pub(crate) worktree_read_slots: std::sync::Arc<tokio::sync::Semaphore>, pub(crate) pending_api_worktree_removes: HashMap<String, u64>, pub(crate) pending_api_worktree_remove_paths: HashMap<std::path::PathBuf, u64>, pub(crate) pending_worktree_remove_runtime_exits: HashMap<crate::layout::PaneId, usize>, @@ -588,6 +589,7 @@ impl App { git_identity_refresh_requested: false, git_status_cache: HashMap::new(), pending_api_worktree_creates: HashMap::new(), + worktree_read_slots: std::sync::Arc::new(tokio::sync::Semaphore::new(8)), pending_api_worktree_removes: HashMap::new(), pending_api_worktree_remove_paths: HashMap::new(), pending_worktree_remove_runtime_exits: HashMap::new(), diff --git a/src/events.rs b/src/events.rs index 95638991..a614ee0c 100644 --- a/src/events.rs +++ b/src/events.rs @@ -52,6 +52,27 @@ pub struct WorktreeRemoveResult { pub result: Result<(), String>, } +#[derive(Debug)] +pub struct WorktreeReadResult { + // Keep the slot until completion is consumed, including time queued on the app loop. + pub(crate) _permit: tokio::sync::OwnedSemaphorePermit, + pub(crate) client_local: bool, + pub(crate) request: crate::api::schema::Request, + pub(crate) source_workspace_id: Option<String>, + pub(crate) source_cwd: Option<std::path::PathBuf>, + pub(crate) result: Result<WorktreeReadData, (String, String)>, + pub(crate) respond_to: std::sync::mpsc::Sender<String>, +} + +#[derive(Debug)] +pub(crate) struct WorktreeReadData { + pub source_checkout_path: std::path::PathBuf, + pub source_repo_root: std::path::PathBuf, + pub repo_key: String, + pub repo_name: String, + pub entries: Vec<crate::worktree::ExistingWorktree>, +} + /// An event from a background task to the main loop. #[derive(Debug)] pub enum AppEvent { @@ -173,4 +194,6 @@ pub enum AppEvent { WorktreeAddFinished(Box<WorktreeAddResult>), /// Background `git worktree remove` completed. WorktreeRemoveFinished(Box<WorktreeRemoveResult>), + /// Background worktree discovery completed for an API list/open request. + WorktreeReadFinished(Box<WorktreeReadResult>), } 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"); diff --git a/src/worktree.rs b/src/worktree.rs index 72cdb3d2..17f560a4 100644 --- a/src/worktree.rs +++ b/src/worktree.rs @@ -495,6 +495,8 @@ pub(crate) fn list_existing_worktrees( repo_root: &Path, trust_repository: bool, ) -> Result<Vec<ExistingWorktree>, String> { + #[cfg(test)] + test_list_gate::wait(repo_root); let output = repository_git_command(repo_root, trust_repository) .args(["worktree", "list", "--porcelain"]) .output() @@ -513,6 +515,47 @@ pub(crate) fn list_existing_worktrees( }) } +#[cfg(test)] +pub(crate) mod test_list_gate { + use std::path::{Path, PathBuf}; + use std::sync::{mpsc, Mutex}; + + struct Gate { + path: PathBuf, + entered: mpsc::Sender<()>, + release: mpsc::Receiver<()>, + } + + static GATES: Mutex<Vec<Gate>> = Mutex::new(Vec::new()); + + pub(crate) fn block(path: &Path) -> (mpsc::Receiver<()>, mpsc::Sender<()>) { + let (entered, entry_rx) = mpsc::channel(); + let (release_tx, release) = mpsc::channel(); + GATES.lock().unwrap().push(Gate { + path: super::canonical_or_original(path), + entered, + release, + }); + (entry_rx, release_tx) + } + + pub(super) fn wait(path: &Path) { + let gate = { + let mut gates = GATES.lock().unwrap(); + gates + .iter() + .position(|gate| gate.path == super::canonical_or_original(path)) + .map(|index| gates.remove(index)) + }; + if let Some(gate) = gate { + gate.entered.send(()).unwrap(); + gate.release + .recv_timeout(std::time::Duration::from_secs(10)) + .expect("test must release worktree discovery without blocking the server loop"); + } + } +} + fn worktree_list_contains_path( repo_root: &Path, path: &Path, |
