aboutsummaryrefslogtreecommitdiff
path: root/src/app/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/api')
-rw-r--r--src/app/api/worktrees.rs133
-rw-r--r--src/app/api/worktrees/deferred.rs104
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
}
}