diff options
Diffstat (limited to 'src/app/api')
| -rw-r--r-- | src/app/api/worktrees.rs | 133 | ||||
| -rw-r--r-- | src/app/api/worktrees/deferred.rs | 104 |
2 files changed, 224 insertions, 13 deletions
diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index cf4ca3f9..fd7bb0fb 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -2232,6 +2232,112 @@ mod tests { let _ = std::fs::remove_dir_all(repo); } + #[tokio::test] + async fn failed_worktree_remove_preserves_and_restores_shutdown_pane() { + let checkout = unique_temp_path("api-worktree-remove-failure-checkout"); + std::fs::create_dir_all(&checkout).unwrap(); + let mut app = test_app(); + app.state.default_shell = test_shell().into(); + let mut workspace = Workspace::test_new("worktree"); + workspace.identity_cwd = checkout.clone(); + workspace.worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "repo".into(), + repo_root: checkout.clone(), + checkout_path: checkout.clone(), + is_linked_worktree: true, + }); + let workspace_id = workspace.id.clone(); + let pane_id = workspace.tabs[0].root_pane; + let terminal_id = workspace.terminal_id(pane_id).cloned().unwrap(); + let foreground = Workspace::test_new("foreground"); + let foreground_id = foreground.id.clone(); + app.state.workspaces = vec![workspace, foreground]; + app.state.active = Some(1); + app.state.selected = 1; + app.state.ensure_test_terminals(); + app.state + .terminals + .get_mut(&terminal_id) + .unwrap() + .set_detected_state( + Some(crate::detect::Agent::Codex), + crate::detect::AgentState::Working, + ); + app.state.toast_config.delay_seconds = 1; + app.handle_internal_event(AppEvent::StateChanged { + pane_id, + agent: Some(crate::detect::Agent::Codex), + state: crate::detect::AgentState::Blocked, + visible_blocker: true, + visible_working: false, + process_exited: false, + observed_at: std::time::Instant::now(), + }); + assert!(app.state.pending_agent_notifications.contains_key(&pane_id)); + let (runtime, _input_rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 24); + app.terminal_runtimes.insert(terminal_id.clone(), runtime); + + let shutdown_panes = app.shutdown_workspace_terminal_runtimes_for_worktree_remove(0); + assert_eq!(shutdown_panes, vec![pane_id]); + + let checkout_key = crate::worktree::canonical_or_original(&checkout); + app.pending_api_worktree_removes + .insert(workspace_id.clone(), 7); + app.pending_api_worktree_remove_paths + .insert(checkout_key.clone(), 7); + let workspace_snapshot = app.workspace_info(0); + let worktree_snapshot = app + .worktree_info_for_membership(app.state.workspaces[0].worktree_space().unwrap(), None); + let (respond_to, response_rx) = response_channel(); + let pane_updates = app.handle_api_worktree_remove_finished(WorktreeRemoveResult { + workspace_id, + path: checkout.clone(), + workspace: Some(Box::new(workspace_snapshot)), + worktree: Some(Box::new(worktree_snapshot)), + forced: false, + api_request: Some(ApiWorktreeRemoveRequest { + id: "req".into(), + operation_id: 7, + checkout_key, + shutdown_panes, + respond_to, + }), + result: Err("simulated remove failure".into()), + }); + assert!(pane_updates.is_empty()); + + let response = response_rx + .recv_timeout(std::time::Duration::from_secs(2)) + .unwrap(); + let error: ErrorResponse = serde_json::from_str(&response).unwrap(); + assert_eq!(error.error.code, "worktree_remove_failed"); + assert!(app.find_pane(pane_id).is_some()); + assert!(app.terminal_runtimes.get(&terminal_id).is_none()); + assert!(app + .pending_worktree_remove_runtime_restores + .contains_key(&pane_id)); + + let pane_updates = + app.handle_internal_event_with_pane_updates(AppEvent::PaneDied { pane_id }); + assert!(matches!( + pane_updates.as_slice(), + [update] if update.agent_released && update.suppress_completion + )); + assert!(app.pending_worktree_remove_runtime_exits.is_empty()); + assert!(app.pending_worktree_remove_runtime_restores.is_empty()); + assert!(app.state.pending_agent_notifications.is_empty()); + assert!(app.terminal_runtimes.get(&terminal_id).is_some()); + assert_eq!( + app.state.active.map(|idx| &app.state.workspaces[idx].id), + Some(&foreground_id) + ); + assert_eq!(app.state.workspaces[app.state.selected].id, foreground_id); + + crate::app::api::test_support::shutdown_test_runtimes(&mut app); + let _ = std::fs::remove_dir_all(checkout); + } + #[test] fn deferred_api_background_worktree_remove_preserves_focused_workspace() { let mut app = test_app(); @@ -2253,10 +2359,15 @@ mod tests { child.worktree_space = Some(membership.clone()); let foreground = Workspace::test_new("foreground"); let child_id = child.id.clone(); + let child_pane_id = child.tabs[0].root_pane; let foreground_id = foreground.id.clone(); app.state.workspaces = vec![parent, child, foreground]; app.state.active = Some(2); app.state.selected = 2; + app.pending_worktree_remove_runtime_exits + .insert(child_pane_id, 1); + app.pending_worktree_remove_runtime_restores + .insert(child_pane_id, 7); let workspace_snapshot = app.workspace_info(1); let worktree_snapshot = app.worktree_info_for_membership(&membership, None); app.pending_api_worktree_removes.insert(child_id.clone(), 7); @@ -2264,7 +2375,7 @@ mod tests { .insert(crate::worktree::canonical_or_original(&checkout), 7); let (respond_to, response_rx) = response_channel(); - app.handle_api_worktree_remove_finished(WorktreeRemoveResult { + let _ = app.handle_api_worktree_remove_finished(WorktreeRemoveResult { workspace_id: child_id, path: checkout.clone(), workspace: Some(Box::new(workspace_snapshot)), @@ -2274,6 +2385,7 @@ mod tests { id: "req".into(), operation_id: 7, checkout_key: crate::worktree::canonical_or_original(&checkout), + shutdown_panes: vec![child_pane_id], respond_to, }), result: Ok(()), @@ -2293,10 +2405,12 @@ mod tests { Some(&foreground_id) ); assert_eq!(app.state.workspaces[app.state.selected].id, foreground_id); + assert!(app.pending_worktree_remove_runtime_exits.is_empty()); + assert!(app.pending_worktree_remove_runtime_restores.is_empty()); } - #[test] - fn deferred_api_worktree_remove_emits_removed_after_workspace_changes() { + #[tokio::test] + async fn deferred_api_worktree_remove_emits_removed_after_workspace_changes() { let event_hub = crate::api::EventHub::default(); let mut app = test_app_with_event_hub(event_hub.clone()); let checkout = PathBuf::from("/repo/herdr-issue"); @@ -2309,13 +2423,19 @@ mod tests { is_linked_worktree: true, }); let child_id = child.id.clone(); + let child_pane_id = child.tabs[0].root_pane; + let child_terminal_id = child.terminal_id(child_pane_id).cloned().unwrap(); app.state.workspaces.push(child); + app.state.ensure_test_terminals(); + app.state.terminals.get_mut(&child_terminal_id).unwrap().cwd = checkout.clone(); let workspace_snapshot = app.workspace_info(0); let worktree_snapshot = app .worktree_info_for_membership(app.state.workspaces[0].worktree_space().unwrap(), None); app.pending_api_worktree_removes.insert(child_id.clone(), 7); app.pending_api_worktree_remove_paths .insert(crate::worktree::canonical_or_original(&checkout), 7); + app.pending_worktree_remove_runtime_exits + .insert(child_pane_id, 1); app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), @@ -2325,7 +2445,7 @@ mod tests { }); let (respond_to, response_rx) = response_channel(); - app.handle_api_worktree_remove_finished(WorktreeRemoveResult { + let _ = app.handle_api_worktree_remove_finished(WorktreeRemoveResult { workspace_id: child_id, path: checkout.clone(), workspace: Some(Box::new(workspace_snapshot)), @@ -2335,6 +2455,7 @@ mod tests { id: "req".into(), operation_id: 7, checkout_key: crate::worktree::canonical_or_original(&checkout), + shutdown_panes: vec![child_pane_id], respond_to, }), result: Ok(()), @@ -2363,5 +2484,9 @@ mod tests { .map(|membership| membership.checkout_path.as_path()), Some(Path::new("/repo/other")) ); + assert_eq!( + app.state.terminals[&child_terminal_id].cwd, + PathBuf::from("/repo/other") + ); } } diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 110f00c9..9adbd70c 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -1,5 +1,5 @@ use std::path::Path; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use crate::api::schema::{ EventData, EventEnvelope, EventKind, Request, ResponseResult, WorktreeCreateParams, @@ -289,6 +289,14 @@ impl App { || self .pending_api_worktree_creates .contains_key(&checkout_key) + || self + .state + .pane_ids_for_workspace(ws_idx) + .iter() + .any(|pane_id| { + self.pending_worktree_remove_runtime_restores + .contains_key(pane_id) + }) { Self::send_api_response( respond_to, @@ -301,9 +309,12 @@ impl App { return; } - if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(params.force) { - self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx); - } + let shutdown_panes = + if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(params.force) { + self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx) + } else { + Vec::new() + }; let operation_id = self.next_api_worktree_operation_id(); self.pending_api_worktree_removes @@ -322,6 +333,7 @@ impl App { id, operation_id, checkout_key, + shutdown_panes, respond_to, }; let repo_root = space.repo_root; @@ -468,9 +480,9 @@ impl App { pub(crate) fn handle_api_worktree_remove_finished( &mut self, mut result: crate::events::WorktreeRemoveResult, - ) { + ) -> Vec<crate::app::actions::PaneStateUpdate> { let Some(api) = result.api_request.take() else { - return; + return Vec::new(); }; let operation_matches = self .pending_api_worktree_removes @@ -489,7 +501,7 @@ impl App { "worktree remove completed after the operation was superseded", ), ); - return; + return Vec::new(); } self.pending_api_worktree_removes .remove(&result.workspace_id); @@ -497,6 +509,11 @@ impl App { .remove(&api.checkout_key); if let Err(message) = result.result { + let pane_updates = self.restore_shutdown_worktree_panes( + &api.shutdown_panes, + api.operation_id, + &result.path, + ); let code = if !result.forced && crate::worktree::is_dirty_worktree_remove_error(&message) { "dirty_worktree_requires_force" @@ -504,7 +521,7 @@ impl App { "worktree_remove_failed" }; Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); - return; + return pane_updates; } let mut workspace_id = result.workspace_id.clone(); @@ -546,6 +563,11 @@ impl App { } else if let Some(snapshot) = workspace_snapshot.as_ref() { workspace_id = snapshot.workspace_id.clone(); } + let pane_updates = self.restore_shutdown_worktree_panes( + &api.shutdown_panes, + api.operation_id, + &result.path, + ); let Some(worktree) = worktree else { Self::send_api_response( @@ -556,7 +578,7 @@ impl App { "removed worktree but lost worktree snapshot", ), ); - return; + return pane_updates; }; self.emit_worktree_removed_event( workspace_id.clone(), @@ -573,5 +595,69 @@ impl App { }, ); Self::send_api_response(api.respond_to, response); + pane_updates + } + + fn restore_shutdown_worktree_panes( + &mut self, + shutdown_panes: &[crate::layout::PaneId], + operation_id: u64, + removed_checkout: &std::path::Path, + ) -> Vec<crate::app::actions::PaneStateUpdate> { + let mut pane_updates = Vec::new(); + let removed_checkout = crate::worktree::canonical_or_original(removed_checkout); + for &pane_id in shutdown_panes { + let Some((ws_idx, terminal_id)) = self + .find_pane(pane_id) + .map(|(ws_idx, pane)| (ws_idx, pane.attached_terminal_id.clone())) + else { + self.pending_worktree_remove_runtime_exits.remove(&pane_id); + self.pending_worktree_remove_runtime_restores + .remove(&pane_id); + continue; + }; + let runtime_missing = self.terminal_runtimes.get(&terminal_id).is_none(); + if runtime_missing { + let workspace = &self.state.workspaces[ws_idx]; + let current_checkout = workspace + .worktree_space() + .map(|space| &space.checkout_path) + .unwrap_or(&workspace.identity_cwd); + if crate::worktree::canonical_or_original(current_checkout) != removed_checkout { + if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) { + terminal.cwd = current_checkout.clone(); + } + } + if self + .pending_worktree_remove_runtime_exits + .contains_key(&pane_id) + { + if self + .pending_worktree_remove_runtime_restores + .insert(pane_id, operation_id) + .is_none() + { + let event_tx = self.event_tx.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(1)).await; + let _ = event_tx + .send(AppEvent::WorktreeRuntimeRestoreFailed { + pane_id, + operation_id, + }) + .await; + }); + } + } else { + pane_updates.extend(self.publish_worktree_runtime_agent_release(pane_id)); + if !self.respawn_shell_for_launch_pane(pane_id, false) { + self.pending_worktree_remove_runtime_restores + .insert(pane_id, operation_id); + self.queue_worktree_runtime_restore_failed(pane_id, operation_id); + } + } + } + } + pane_updates } } |
