From 46a2b259a71828e7fcdeab1f67ef675304e2ca14 Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Fri, 19 Jun 2026 18:59:43 +0300 Subject: fix: defer api worktree operations refs #686 refs #657 refs #662 refs https://github.com/ogulcancelik/herdr/discussions/687 --- src/app/api/worktrees/deferred.rs | 566 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 566 insertions(+) create mode 100644 src/app/api/worktrees/deferred.rs (limited to 'src/app/api/worktrees') diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs new file mode 100644 index 00000000..19794a6e --- /dev/null +++ b/src/app/api/worktrees/deferred.rs @@ -0,0 +1,566 @@ +use std::path::Path; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::api::schema::{ + EventData, EventEnvelope, EventKind, Request, ResponseResult, WorktreeCreateParams, + WorktreeRemoveParams, +}; +use crate::app::App; +use crate::events::{ApiWorktreeAddRequest, ApiWorktreeRemoveRequest, AppEvent}; + +use super::super::responses::{encode_error, encode_success}; +use super::{absolute_user_path, WorktreeSource}; + +impl App { + pub(crate) fn handle_deferred_worktree_api_request( + &mut self, + request: Request, + respond_to: std::sync::mpsc::Sender, + ) -> bool { + match request.method { + crate::api::schema::Method::WorktreeCreate(params) => { + self.start_api_worktree_create(request.id, params, respond_to); + true + } + crate::api::schema::Method::WorktreeRemove(params) => { + self.start_api_worktree_remove(request.id, params, respond_to); + true + } + _ => false, + } + } + + fn send_api_response(respond_to: std::sync::mpsc::Sender, response: String) { + let _ = respond_to.send(response); + } + + fn next_api_worktree_operation_id(&mut self) -> u64 { + let id = self.next_api_worktree_operation_id; + self.next_api_worktree_operation_id = self.next_api_worktree_operation_id.saturating_add(1); + id + } + + fn api_create_source_workspace_idx(&self, api: &ApiWorktreeAddRequest) -> Option { + let Some(source_workspace_id) = api.source_workspace_id.as_ref() else { + return self.find_parent_workspace_by_key(&api.repo_key); + }; + let Some(ws_idx) = self + .state + .workspaces + .iter() + .position(|ws| &ws.id == source_workspace_id) + else { + return self.find_parent_workspace_by_key(&api.repo_key); + }; + let workspace = &self.state.workspaces[ws_idx]; + if let Some(expected) = api.source_existing_membership.as_ref() { + if workspace.worktree_space() == Some(expected) { + return Some(ws_idx); + } + return self.find_parent_workspace_by_key(&api.repo_key); + } + + if let Some(current) = workspace.worktree_space() { + let expected = crate::workspace::WorktreeSpaceMembership { + key: api.repo_key.clone(), + label: api.repo_name.clone(), + repo_root: api.source_repo_root.clone(), + checkout_path: api.source_checkout_path.clone(), + is_linked_worktree: false, + }; + if current == &expected { + return Some(ws_idx); + } + return self.find_parent_workspace_by_key(&api.repo_key); + } + let git_space = workspace.git_space().cloned().or_else(|| { + workspace + .resolved_identity_cwd_from(&self.state.terminals, &self.terminal_runtimes) + .as_deref() + .and_then(crate::workspace::git_space_metadata) + }); + if git_space.is_some_and(|space| { + !space.is_linked_worktree + && space.key == api.repo_key + && crate::worktree::canonical_or_original(&space.repo_root) + == crate::worktree::canonical_or_original(&api.source_repo_root) + }) { + Some(ws_idx) + } else { + self.find_parent_workspace_by_key(&api.repo_key) + } + } + + fn start_api_worktree_create( + &mut self, + id: String, + params: WorktreeCreateParams, + respond_to: std::sync::mpsc::Sender, + ) { + let branch = params + .branch + .unwrap_or_else(|| { + let seed = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_micros().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0); + crate::worktree::generated_branch_slug(seed) + }) + .trim() + .to_string(); + if branch.is_empty() { + Self::send_api_response( + respond_to, + encode_error(id, "invalid_request", "branch is required"), + ); + return; + } + let base = params.base.unwrap_or_else(|| "HEAD".into()); + let source = match self.resolve_worktree_source(params.workspace_id, params.cwd) { + Ok(source) => source, + Err(err) => { + Self::send_api_response(respond_to, encode_error(id, err.code, err.message)); + return; + } + }; + let checkout_path = match params.path { + Some(path) => match absolute_user_path(&path) { + Ok(path) => path, + Err(err) => { + Self::send_api_response(respond_to, encode_error(id, err.code, err.message)); + return; + } + }, + None => crate::worktree::default_checkout_path( + &self.state.worktree_directory, + &source.repo_name, + &branch, + ), + }; + let checkout_key = crate::worktree::canonical_or_original(&checkout_path); + if self + .pending_api_worktree_creates + .contains_key(&checkout_key) + || self + .pending_api_worktree_remove_paths + .contains_key(&checkout_key) + { + Self::send_api_response( + respond_to, + encode_error( + id, + "worktree_operation_in_progress", + "worktree operation is already in progress for this checkout", + ), + ); + return; + } + let operation_id = self.next_api_worktree_operation_id(); + self.pending_api_worktree_creates + .insert(checkout_key.clone(), operation_id); + + let command = crate::worktree::build_worktree_add_new_branch_command( + &source.source_checkout_path, + &checkout_path, + &branch, + &base, + ); + let parent_dir = checkout_path.parent().map(Path::to_path_buf); + let source_workspace_id = source + .workspace_idx + .and_then(|idx| self.state.workspaces.get(idx).map(|ws| ws.id.clone())); + let source_existing_membership = source_workspace_id.as_ref().and_then(|workspace_id| { + self.state + .workspaces + .iter() + .find(|ws| &ws.id == workspace_id) + .and_then(|ws| ws.worktree_space().cloned()) + }); + let api_request = ApiWorktreeAddRequest { + id, + operation_id, + checkout_key, + source_workspace_id, + source_existing_membership, + source_checkout_path: source.source_checkout_path, + source_repo_root: source.source_repo_root, + repo_key: source.repo_key, + repo_name: source.repo_name, + label: params.label, + focus: params.focus, + respond_to, + }; + let path = checkout_path; + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || { + let result = if let Some(parent_dir) = parent_dir { + std::fs::create_dir_all(&parent_dir) + .map_err(|err| err.to_string()) + .and_then(|()| crate::worktree::run_worktree_command(&command)) + } else { + crate::worktree::run_worktree_command(&command) + }; + let _ = event_tx.blocking_send(AppEvent::WorktreeAddFinished(Box::new( + crate::events::WorktreeAddResult { + path, + api_request: Some(api_request), + result, + }, + ))); + }); + } + + fn start_api_worktree_remove( + &mut self, + id: String, + params: WorktreeRemoveParams, + respond_to: std::sync::mpsc::Sender, + ) { + let Some(ws_idx) = self.parse_workspace_id(¶ms.workspace_id) else { + Self::send_api_response( + respond_to, + encode_error( + id, + "workspace_not_found", + format!("workspace {} not found", params.workspace_id), + ), + ); + return; + }; + let Some(space) = self + .state + .workspaces + .get(ws_idx) + .and_then(|ws| ws.worktree_space().cloned()) + else { + Self::send_api_response( + respond_to, + encode_error( + id, + "not_linked_worktree", + "workspace is not a Herdr-managed worktree checkout", + ), + ); + return; + }; + if !space.is_linked_worktree { + Self::send_api_response( + respond_to, + encode_error( + id, + "not_linked_worktree", + "workspace is not a linked worktree checkout", + ), + ); + return; + } + + #[cfg(windows)] + { + if !params.force + && crate::worktree::checkout_has_dirty_files(&space.checkout_path).unwrap_or(false) + { + Self::send_api_response( + respond_to, + encode_error( + id, + "dirty_worktree_requires_force", + crate::worktree::worktree_dirty_remove_message(&space.checkout_path), + ), + ); + return; + } + } + + let workspace_internal_id = self.state.workspaces[ws_idx].id.clone(); + let checkout_key = crate::worktree::canonical_or_original(&space.checkout_path); + if self + .pending_api_worktree_removes + .contains_key(&workspace_internal_id) + || self + .pending_api_worktree_remove_paths + .contains_key(&checkout_key) + || self + .pending_api_worktree_creates + .contains_key(&checkout_key) + { + Self::send_api_response( + respond_to, + encode_error( + id, + "worktree_operation_in_progress", + "worktree operation is already in progress for this checkout", + ), + ); + return; + } + + if Self::should_shutdown_workspace_terminal_runtimes_for_worktree_remove(params.force) { + self.shutdown_workspace_terminal_runtimes_for_worktree_remove(ws_idx); + } + + let operation_id = self.next_api_worktree_operation_id(); + self.pending_api_worktree_removes + .insert(workspace_internal_id.clone(), operation_id); + self.pending_api_worktree_remove_paths + .insert(checkout_key.clone(), operation_id); + let workspace_snapshot = self.workspace_info(ws_idx); + let worktree = self.worktree_info_for_membership(&space, None); + let command = crate::worktree::build_worktree_remove_command( + &space.repo_root, + &space.checkout_path, + params.force, + ); + let api_request = ApiWorktreeRemoveRequest { + id, + operation_id, + checkout_key, + respond_to, + }; + let repo_root = space.repo_root; + let path = space.checkout_path; + let force = params.force; + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || { + let result = crate::worktree::run_worktree_remove_command_with_recovery( + &command, &repo_root, &path, force, + ); + let _ = event_tx.blocking_send(AppEvent::WorktreeRemoveFinished(Box::new( + crate::events::WorktreeRemoveResult { + workspace_id: workspace_internal_id, + path, + workspace: Some(Box::new(workspace_snapshot)), + worktree: Some(Box::new(worktree)), + forced: force, + api_request: Some(api_request), + result, + }, + ))); + }); + } + + pub(crate) fn handle_api_worktree_add_finished( + &mut self, + mut result: crate::events::WorktreeAddResult, + ) { + let Some(api) = result.api_request.take() else { + return; + }; + let checkout_key = api.checkout_key.clone(); + let operation_matches = self + .pending_api_worktree_creates + .get(&checkout_key) + .is_some_and(|operation_id| *operation_id == api.operation_id); + if !operation_matches { + Self::send_api_response( + api.respond_to, + encode_error( + api.id, + "stale_worktree_operation", + "worktree create completed after the operation was superseded", + ), + ); + return; + } + self.pending_api_worktree_creates.remove(&checkout_key); + + if let Err(err) = result.result { + Self::send_api_response( + api.respond_to, + encode_error(api.id, "worktree_create_failed", err), + ); + return; + } + + let source_workspace_idx = self.api_create_source_workspace_idx(&api); + let mut source = WorktreeSource { + workspace_idx: source_workspace_idx, + source_checkout_path: api.source_checkout_path, + source_repo_root: api.source_repo_root, + repo_key: api.repo_key, + repo_name: api.repo_name, + }; + if let Err(err) = self.ensure_source_parent_membership(&mut source, true) { + Self::send_api_response(api.respond_to, encode_error(api.id, err.code, err.message)); + return; + } + + let (ws_idx, created_workspace) = + if let Some(ws_idx) = self.open_workspace_idx_for_checkout(&result.path) { + if api.focus { + self.state.switch_workspace(ws_idx); + } + (ws_idx, false) + } else { + match self.create_workspace_with_options(result.path.clone(), api.focus) { + Ok(ws_idx) => (ws_idx, true), + Err(err) => { + Self::send_api_response( + api.respond_to, + encode_error( + api.id, + "worktree_open_failed", + format!("created worktree but failed to open workspace: {err}"), + ), + ); + return; + } + } + }; + + self.mark_worktree_membership( + &source, + ws_idx, + result.path.clone(), + true, + !created_workspace, + ); + if let Some(label) = api.label { + if let Some(ws) = self.state.workspaces.get_mut(ws_idx) { + ws.set_custom_name(label); + } + } + self.state.mark_session_dirty(); + if created_workspace { + self.emit_workspace_open_events(ws_idx); + } + let Some(worktree) = self.worktree_info_for_workspace(ws_idx) else { + Self::send_api_response( + api.respond_to, + encode_error( + api.id, + "worktree_open_failed", + "created worktree but failed to record workspace membership", + ), + ); + return; + }; + self.emit_worktree_created_event(ws_idx, worktree.clone()); + let tab_idx = self.state.workspaces[ws_idx].active_tab; + let response = encode_success( + api.id, + ResponseResult::WorktreeCreated { + workspace: self.workspace_info(ws_idx), + tab: self + .tab_info(ws_idx, tab_idx) + .expect("created worktree workspace should have an active tab"), + root_pane: self + .root_pane_info(ws_idx, tab_idx) + .expect("created worktree workspace should have an active root pane"), + worktree, + }, + ); + Self::send_api_response(api.respond_to, response); + } + + pub(crate) fn handle_api_worktree_remove_finished( + &mut self, + mut result: crate::events::WorktreeRemoveResult, + ) { + let Some(api) = result.api_request.take() else { + return; + }; + let operation_matches = self + .pending_api_worktree_removes + .get(&result.workspace_id) + .is_some_and(|operation_id| *operation_id == api.operation_id) + && self + .pending_api_worktree_remove_paths + .get(&api.checkout_key) + .is_some_and(|operation_id| *operation_id == api.operation_id); + if !operation_matches { + Self::send_api_response( + api.respond_to, + encode_error( + api.id, + "stale_worktree_operation", + "worktree remove completed after the operation was superseded", + ), + ); + return; + } + self.pending_api_worktree_removes + .remove(&result.workspace_id); + self.pending_api_worktree_remove_paths + .remove(&api.checkout_key); + + if let Err(message) = result.result { + let code = + if !result.forced && crate::worktree::is_dirty_worktree_remove_error(&message) { + "dirty_worktree_requires_force" + } else { + "worktree_remove_failed" + }; + Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); + return; + } + + let mut workspace_id = result.workspace_id.clone(); + let mut workspace_snapshot = result.workspace.as_deref().cloned(); + let mut worktree = result.worktree.as_deref().cloned(); + if let Some(ws_idx) = self + .state + .workspaces + .iter() + .position(|ws| ws.id == result.workspace_id) + { + let current_matches = + self.state.workspaces[ws_idx] + .worktree_space() + .is_some_and(|space| { + space.is_linked_worktree && space.checkout_path == result.path + }); + if current_matches { + workspace_id = self.public_workspace_id(ws_idx); + workspace_snapshot.get_or_insert_with(|| self.workspace_info(ws_idx)); + if worktree.is_none() { + worktree = self.state.workspaces[ws_idx] + .worktree_space() + .cloned() + .map(|space| self.worktree_info_for_membership(&space, None)); + } + self.state.selected = ws_idx; + self.state.close_selected_workspace(); + self.shutdown_detached_terminal_runtimes(); + self.emit_event(EventEnvelope { + event: EventKind::WorkspaceClosed, + data: EventData::WorkspaceClosed { + workspace_id: workspace_id.clone(), + workspace: workspace_snapshot.clone(), + }, + }); + } else if let Some(snapshot) = workspace_snapshot.as_ref() { + workspace_id = snapshot.workspace_id.clone(); + } + } else if let Some(snapshot) = workspace_snapshot.as_ref() { + workspace_id = snapshot.workspace_id.clone(); + } + + let Some(worktree) = worktree else { + Self::send_api_response( + api.respond_to, + encode_error( + api.id, + "worktree_remove_failed", + "removed worktree but lost worktree snapshot", + ), + ); + return; + }; + self.emit_worktree_removed_event( + workspace_id.clone(), + workspace_snapshot, + worktree, + result.forced, + ); + let response = encode_success( + api.id, + ResponseResult::WorktreeRemoved { + workspace_id, + path: result.path.display().to_string(), + forced: result.forced, + }, + ); + Self::send_api_response(api.respond_to, response); + } +} -- cgit v1.2.3