From 6eadaf07da926a99884ac423ac8f7d29fe2d9af8 Mon Sep 17 00:00:00 2001 From: theMackabu Date: Tue, 4 Aug 2026 16:57:07 -0700 Subject: [PATCH 1/2] fix: keep worktree removal responsive --- .../src/content/docs/configuration.mdx | 2 +- .../src/content/docs/ja/configuration.mdx | 2 +- .../src/content/docs/zh-cn/configuration.mdx | 2 +- src/app/api/worktrees.rs | 14 ++ src/app/api/worktrees/deferred.rs | 12 +- src/app/input/mouse.rs | 2 + src/app/mod.rs | 2 + src/app/runtime.rs | 72 +++++++++- src/app/state.rs | 1 + src/app/worktrees.rs | 123 ++++++++++++++++-- src/ui/sidebar.rs | 113 +++++++++++++++- 11 files changed, 319 insertions(+), 26 deletions(-) diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 2aa445e08c..10f1fd5d0e 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -94,7 +94,7 @@ Worktree actions are available from Git workspace rows. `New worktree` creates a Grouped worktrees still behave like normal Herdr workspaces: they can be focused, renamed, closed, and contain their own tabs and panes. The parent row is the original workspace. Closing the parent row closes the whole Herdr group, but it does not delete checkout folders or branches. -Deleting a worktree checkout is explicit. Use `Delete worktree checkout...` on a grouped child workspace to run `git worktree remove`. Herdr first asks Git to remove safely. If Git refuses because the checkout has modified or untracked files, Herdr asks again before running the forced remove. Branches are not deleted. +Deleting a worktree checkout is explicit. Use `Delete worktree checkout...` on a grouped child workspace to run `git worktree remove`. Removal continues in the background, with progress shown on the workspace row, so the rest of Herdr remains usable while large checkouts are deleted. Herdr first asks Git to remove safely. If Git refuses because the checkout has modified or untracked files, Herdr asks again before running the forced remove. Branches are not deleted. ## Remote attach diff --git a/docs/next/website/src/content/docs/ja/configuration.mdx b/docs/next/website/src/content/docs/ja/configuration.mdx index 3069ebeff6..0af44346aa 100644 --- a/docs/next/website/src/content/docs/ja/configuration.mdx +++ b/docs/next/website/src/content/docs/ja/configuration.mdx @@ -94,7 +94,7 @@ worktree アクションは Git ワークスペースの行から使えます。 グループ化された worktree も通常の Herdr ワークスペースと同様に動作します。フォーカス、名前変更、クローズができ、それぞれ独自のタブとペインを持ちます。親の行は元のワークスペースです。親の行を閉じると Herdr のグループ全体が閉じますが、チェックアウトのフォルダーやブランチは削除されません。 -worktree チェックアウトの削除は明示的に行います。グループ化された子ワークスペースで `Delete worktree checkout...` を使うと `git worktree remove` が実行されます。Herdr はまず Git に安全な削除を要求します。チェックアウトに変更済みまたは未追跡のファイルがあり Git が拒否した場合、Herdr は強制削除を実行する前にもう一度確認します。ブランチは削除されません。 +worktree チェックアウトの削除は明示的に行います。グループ化された子ワークスペースで `Delete worktree checkout...` を使うと `git worktree remove` が実行されます。削除はバックグラウンドで続行され、ワークスペースの行に進行状況が表示されるため、大きなチェックアウトの削除中も Herdr を使い続けられます。Herdr はまず Git に安全な削除を要求します。チェックアウトに変更済みまたは未追跡のファイルがあり Git が拒否した場合、Herdr は強制削除を実行する前にもう一度確認します。ブランチは削除されません。 ## リモートアタッチ diff --git a/docs/next/website/src/content/docs/zh-cn/configuration.mdx b/docs/next/website/src/content/docs/zh-cn/configuration.mdx index 257cb1270e..2ae537e698 100644 --- a/docs/next/website/src/content/docs/zh-cn/configuration.mdx +++ b/docs/next/website/src/content/docs/zh-cn/configuration.mdx @@ -94,7 +94,7 @@ Git 工作区行提供 worktree 操作。`New worktree` 会创建检出:如果 归组的 worktree 仍像普通 Herdr 工作区一样工作:可以聚焦、重命名和关闭,也可以拥有自己的标签页和窗格。父行是原始工作区。关闭父行会关闭整个 Herdr 组,但不会删除检出目录或分支。 -删除 worktree 检出需要显式操作。在归组的子工作区上使用 `Delete worktree checkout...` 来运行 `git worktree remove`。Herdr 会先请求 Git 安全删除。如果 Git 因检出中存在已修改或未跟踪的文件而拒绝,Herdr 会在强制删除前再次确认。分支不会被删除。 +删除 worktree 检出需要显式操作。在归组的子工作区上使用 `Delete worktree checkout...` 来运行 `git worktree remove`。删除会在后台继续,并在工作区行上显示进度,因此删除大型检出时仍可继续使用 Herdr。Herdr 会先请求 Git 安全删除。如果 Git 因检出中存在已修改或未跟踪的文件而拒绝,Herdr 会在强制删除前再次确认。分支不会被删除。 ## 远程连接 diff --git a/src/app/api/worktrees.rs b/src/app/api/worktrees.rs index 7aa8c00f36..5b0f3bd33f 100644 --- a/src/app/api/worktrees.rs +++ b/src/app/api/worktrees.rs @@ -2201,6 +2201,17 @@ mod tests { 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.state.worktree_remove = Some(crate::app::state::WorktreeRemoveState { + workspace_id: child_id.clone(), + repo_root: "/repo/herdr".into(), + path: checkout.clone(), + error: None, + removing: true, + spinner_frame: 4, + force_confirmation: true, + }); + app.state.mode = crate::app::Mode::Settings; + app.start_worktree_remove_spinner(); app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { key: "repo-key".into(), label: "herdr".into(), @@ -2248,5 +2259,8 @@ mod tests { .map(|membership| membership.checkout_path.as_path()), Some(Path::new("/repo/other")) ); + assert!(app.state.worktree_remove.is_none()); + assert!(app.worktree_remove_spinner_deadline.is_none()); + assert_eq!(app.state.mode, crate::app::Mode::Settings); } } diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index 3cd5e9f1f5..b4a47d21d4 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -520,6 +520,12 @@ impl App { } } } + if self.state.worktree_remove.as_ref().is_some_and(|remove| { + remove.workspace_id == result.workspace_id && remove.path == result.path + }) { + self.stop_worktree_remove_spinner(); + self.state.mode = crate::app::Mode::ConfirmRemoveWorktree; + } Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); return; } @@ -584,12 +590,8 @@ impl App { if self.state.worktree_remove.as_ref().is_some_and(|remove| { remove.workspace_id == result.workspace_id && remove.path == result.path }) { + self.stop_worktree_remove_spinner(); self.state.worktree_remove = None; - self.state.mode = if self.state.active.is_some() { - crate::app::Mode::Terminal - } else { - crate::app::Mode::Navigate - }; } let response = encode_success( api.id, diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index d43dcfc429..e09794b5c4 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -2815,6 +2815,7 @@ mod tests { path: "/repo/herdr-issue".into(), error: None, removing: false, + spinner_frame: 0, force_confirmation: false, }); let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); @@ -2843,6 +2844,7 @@ mod tests { path: "/repo/herdr-issue".into(), error: None, removing: false, + spinner_frame: 0, force_confirmation: false, }); let popup = crate::ui::remove_worktree_popup_rect(app.state.screen_rect()).unwrap(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 5e3c86de0f..9cbd629e8d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -118,6 +118,7 @@ pub struct App { pub(crate) pending_api_worktree_removes: HashMap, pub(crate) pending_api_worktree_remove_paths: HashMap, pub(crate) next_api_worktree_operation_id: u64, + pub(crate) worktree_remove_spinner_deadline: Option, pub(crate) last_sidebar_divider_click: Option, pub(crate) last_pane_click: Option, pub(crate) pending_url_click_sources: HashSet, @@ -738,6 +739,7 @@ impl App { pending_api_worktree_removes: HashMap::new(), pending_api_worktree_remove_paths: HashMap::new(), next_api_worktree_operation_id: 1, + worktree_remove_spinner_deadline: None, last_sidebar_divider_click: None, last_pane_click: None, pending_url_click_sources: HashSet::new(), diff --git a/src/app/runtime.rs b/src/app/runtime.rs index e05b95998e..a71d9af3a7 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -1,7 +1,4 @@ -use std::time::Instant; - -#[cfg(test)] -use std::time::Duration; +use std::time::{Duration, Instant}; use crossterm::terminal; @@ -9,6 +6,8 @@ use super::{ background_update_check_enabled, App, AUTO_UPDATE_CHECK_INTERVAL, MIN_RENDER_INTERVAL, RESIZE_POLL_INTERVAL, SELECTION_AUTOSCROLL_INTERVAL, }; + +const WORKTREE_REMOVE_SPINNER_INTERVAL: Duration = Duration::from_millis(100); fn retain_custom_command_after_wait( pid: u32, result: std::io::Result>, @@ -25,6 +24,18 @@ fn retain_custom_command_after_wait( } impl App { + pub(crate) fn start_worktree_remove_spinner(&mut self) { + if let Some(remove) = &mut self.state.worktree_remove { + remove.spinner_frame = 0; + } + self.worktree_remove_spinner_deadline = + Some(Instant::now() + WORKTREE_REMOVE_SPINNER_INTERVAL); + } + + pub(crate) fn stop_worktree_remove_spinner(&mut self) { + self.worktree_remove_spinner_deadline = None; + } + pub(crate) fn reap_finished_custom_commands(&mut self) { self.detached_custom_command_children .retain_mut(|child| retain_custom_command_after_wait(child.id(), child.try_wait())); @@ -302,6 +313,25 @@ impl App { changed = true; } + if self + .worktree_remove_spinner_deadline + .is_some_and(|deadline| now >= deadline) + { + if let Some(remove) = self + .state + .worktree_remove + .as_mut() + .filter(|remove| remove.removing) + { + remove.spinner_frame = remove.spinner_frame.wrapping_add(1); + self.worktree_remove_spinner_deadline = + Some(now + WORKTREE_REMOVE_SPINNER_INTERVAL); + changed = true; + } else { + self.worktree_remove_spinner_deadline = None; + } + } + if self .state .next_pending_agent_notification_deadline() @@ -600,6 +630,7 @@ impl App { self.session_save_deadline, self.selection_autoscroll_deadline, self.selection_highlight_clear_deadline, + self.worktree_remove_spinner_deadline, render_deadline, ] .into_iter() @@ -720,6 +751,39 @@ mod tests { assert!(app.selection_autoscroll_deadline.is_none()); } + #[test] + fn scheduled_tasks_advance_worktree_remove_spinner() { + let (mut app, _) = test_app_with_pane(); + let workspace_id = app.state.workspaces[0].id.clone(); + app.state.worktree_remove = Some(state::WorktreeRemoveState { + workspace_id, + repo_root: "/repo/herdr".into(), + path: "/repo/herdr-issue".into(), + error: None, + removing: true, + spinner_frame: 0, + force_confirmation: false, + }); + + app.start_worktree_remove_spinner(); + let deadline = app + .worktree_remove_spinner_deadline + .expect("spinner should schedule a frame"); + + assert!(app.handle_scheduled_tasks(deadline, false)); + assert_eq!( + app.state + .worktree_remove + .as_ref() + .map(|remove| remove.spinner_frame), + Some(1) + ); + assert!(app.worktree_remove_spinner_deadline.is_some()); + + app.stop_worktree_remove_spinner(); + assert!(app.worktree_remove_spinner_deadline.is_none()); + } + #[test] fn tick_selection_autoscroll_stops_when_selection_cleared() { let (mut app, _pane_id) = test_app_with_pane(); diff --git a/src/app/state.rs b/src/app/state.rs index 7a8837eee0..45661f4b09 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -644,6 +644,7 @@ pub struct WorktreeRemoveState { pub path: std::path::PathBuf, pub error: Option, pub removing: bool, + pub spinner_frame: u8, pub force_confirmation: bool, } diff --git a/src/app/worktrees.rs b/src/app/worktrees.rs index 0087c8fe94..b50c25f1d2 100644 --- a/src/app/worktrees.rs +++ b/src/app/worktrees.rs @@ -125,6 +125,16 @@ impl App { } pub(crate) fn open_remove_linked_worktree_confirmation(&mut self, ws_idx: usize) { + if self + .state + .worktree_remove + .as_ref() + .is_some_and(|remove| remove.removing) + { + self.state.config_diagnostic = + Some("A worktree checkout is already being removed.".into()); + return; + } let Some(ws) = self.state.workspaces.get(ws_idx) else { return; }; @@ -146,6 +156,7 @@ impl App { path: space.checkout_path, error: None, removing: false, + spinner_frame: 0, force_confirmation: false, }); self.state.mode = Mode::ConfirmRemoveWorktree; @@ -774,6 +785,13 @@ impl App { remove.removing = false; remove.error = Some(message); } + } else { + self.start_worktree_remove_spinner(); + self.state.mode = if self.state.active.is_some() { + Mode::Terminal + } else { + Mode::Navigate + }; } } @@ -880,12 +898,12 @@ impl App { self.handle_api_worktree_remove_finished(result); return; } - let Some(remove) = &mut self.state.worktree_remove else { - return; - }; - if remove.workspace_id != result.workspace_id || remove.path != result.path { + if self.state.worktree_remove.as_ref().is_none_or(|remove| { + remove.workspace_id != result.workspace_id || remove.path != result.path + }) { return; } + self.stop_worktree_remove_spinner(); match result.result { Ok(()) => { @@ -936,16 +954,14 @@ impl App { forced, ); } - self.state.mode = if self.state.active.is_some() { - Mode::Terminal - } else { - Mode::Navigate - }; self.render_dirty.request_generic(); self.render_notify.notify_one(); } Err(message) => { tracing::warn!(workspace_id = %result.workspace_id, path = %result.path.display(), error = %message, "git worktree remove failed"); + let Some(remove) = &mut self.state.worktree_remove else { + return; + }; remove.removing = false; if !remove.force_confirmation && crate::worktree::is_dirty_worktree_remove_error(&message) @@ -955,6 +971,7 @@ impl App { } else { remove.error = Some(message); } + self.state.mode = Mode::ConfirmRemoveWorktree; self.render_dirty.request_generic(); self.render_notify.notify_one(); } @@ -968,6 +985,7 @@ impl App { } pub(crate) fn close_removed_linked_worktree_workspace(&mut self, ws_idx: usize) { + let was_active = self.state.active == Some(ws_idx); let parent_key = self .state .workspaces @@ -979,7 +997,7 @@ impl App { self.state.selected = ws_idx; self.state.close_selected_workspace(); - let Some(parent_key) = parent_key else { + let Some(parent_key) = parent_key.filter(|_| was_active) else { return; }; let Some(parent_idx) = self.state.workspaces.iter().position(|workspace| { @@ -1690,6 +1708,31 @@ mod tests { ); } + #[test] + fn accepted_worktree_remove_returns_to_terminal_while_it_runs() { + let mut app = app_for_worktree_tests(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("issue")]; + app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr-issue".into(), + is_linked_worktree: true, + }); + app.state.active = Some(0); + app.open_remove_linked_worktree_confirmation(0); + + app.handle_worktree_remove_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())); + + assert_eq!(app.state.mode, Mode::Terminal); + assert!(app + .state + .worktree_remove + .as_ref() + .is_some_and(|remove| remove.removing)); + assert!(app.worktree_remove_spinner_deadline.is_some()); + } + #[tokio::test] async fn ui_worktree_create_emits_api_parity_events_after_membership_is_committed() { let repo = create_committed_repo("app-worktree-create-event-repo"); @@ -2111,6 +2154,7 @@ mod tests { path: path.clone(), error: None, removing: true, + spinner_frame: 0, force_confirmation: false, }); @@ -2143,6 +2187,7 @@ mod tests { path: path.clone(), error: None, removing: true, + spinner_frame: 0, force_confirmation: false, }); @@ -2163,6 +2208,7 @@ mod tests { remove.error, Some("fatal: '/w/herdr/missing' is not a working tree".into()) ); + assert_eq!(app.state.mode, Mode::ConfirmRemoveWorktree); } #[test] @@ -2205,6 +2251,7 @@ mod tests { path: checkout.clone(), error: None, removing: true, + spinner_frame: 0, force_confirmation: false, }); @@ -2226,6 +2273,61 @@ mod tests { assert!(app.state.worktree_remove.is_none()); } + #[test] + fn background_worktree_remove_completion_preserves_current_workspace() { + let mut app = app_for_worktree_tests(); + let checkout = std::path::PathBuf::from("/repo/herdr-issue"); + app.state.workspaces = vec![ + crate::workspace::Workspace::test_new("parent"), + crate::workspace::Workspace::test_new("issue"), + crate::workspace::Workspace::test_new("notes"), + ]; + app.state.workspaces[0].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: "/repo/herdr".into(), + is_linked_worktree: false, + }); + app.state.workspaces[1].worktree_space = Some(crate::workspace::WorktreeSpaceMembership { + key: "repo-key".into(), + label: "herdr".into(), + repo_root: "/repo/herdr".into(), + checkout_path: checkout.clone(), + is_linked_worktree: true, + }); + let child_id = app.state.workspaces[1].id.clone(); + let notes_id = app.state.workspaces[2].id.clone(); + app.state.active = Some(2); + app.state.selected = 2; + app.state.mode = Mode::Settings; + app.state.worktree_remove = Some(WorktreeRemoveState { + workspace_id: child_id.clone(), + repo_root: "/repo/herdr".into(), + path: checkout.clone(), + error: None, + removing: true, + spinner_frame: 0, + force_confirmation: false, + }); + + app.handle_worktree_remove_finished(WorktreeRemoveResult { + workspace_id: child_id, + path: checkout, + workspace: None, + worktree: None, + forced: false, + api_request: None, + result: Ok(()), + }); + + assert_eq!(app.state.workspaces.len(), 2); + assert_eq!(app.state.active, Some(1)); + assert_eq!(app.state.selected, 1); + assert_eq!(app.state.workspaces[1].id, notes_id); + assert_eq!(app.state.mode, Mode::Settings); + } + #[test] fn worktree_remove_finished_emits_removed_event_from_snapshot_after_workspace_closed() { let event_hub = crate::api::EventHub::default(); @@ -2257,6 +2359,7 @@ mod tests { path: checkout.clone(), error: None, removing: true, + spinner_frame: 0, force_confirmation: true, }); app.state.workspaces.clear(); diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 50796e81b0..3af4216629 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -19,6 +19,36 @@ use crate::terminal::TerminalRuntimeRegistry; const WORKSPACE_SECTION_HEADER_ROWS: u16 = 2; const AGENT_PANEL_HEADER_ROWS: u16 = 3; +const WORKTREE_REMOVE_SPINNER_FRAMES: [&str; 10] = + ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +fn worktree_remove_spinner(app: &AppState, ws_idx: usize) -> Option<&'static str> { + let remove = app + .worktree_remove + .as_ref() + .filter(|remove| remove.removing)?; + let workspace = app.workspaces.get(ws_idx)?; + (workspace.id == remove.workspace_id).then(|| { + WORKTREE_REMOVE_SPINNER_FRAMES + [usize::from(remove.spinner_frame) % WORKTREE_REMOVE_SPINNER_FRAMES.len()] + }) +} + +fn worktree_group_remove_spinner(app: &AppState, key: &str) -> Option<&'static str> { + let remove = app + .worktree_remove + .as_ref() + .filter(|remove| remove.removing)?; + app.workspaces + .iter() + .find(|workspace| workspace.id == remove.workspace_id) + .and_then(|workspace| workspace.worktree_space()) + .filter(|space| space.key == key) + .map(|_| { + WORKTREE_REMOVE_SPINNER_FRAMES + [usize::from(remove.spinner_frame) % WORKTREE_REMOVE_SPINNER_FRAMES.len()] + }) +} pub(crate) struct AgentPanelEntry { pub ws_idx: usize, @@ -781,7 +811,14 @@ pub(super) fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: break; } let (agg_state, agg_seen) = ws.aggregate_state(&app.terminals); - let (icon, icon_style) = state_icon(agg_state, agg_seen, app.status_indicators, p); + let (icon, icon_style) = worktree_remove_spinner(app, visible_idx) + .map(|spinner| { + ( + spinner, + Style::default().fg(p.accent).add_modifier(Modifier::BOLD), + ) + }) + .unwrap_or_else(|| state_icon(agg_state, agg_seen, app.status_indicators, p)); let is_selected = visible_idx == app.selected && is_navigating; let is_active = Some(visible_idx) == app.active; let row_style = if is_selected { @@ -1276,9 +1313,30 @@ fn render_workspace_list( .filter(|(_, collapsed)| *collapsed) .map(|(key, _)| space_aggregate_state(app, key)) .unwrap_or((agg_state, agg_seen)); - let state_icon = state_icon(display_state, display_seen, app.status_indicators, p); + let removal_spinner = parent_group + .as_ref() + .filter(|(_, collapsed)| *collapsed) + .and_then(|(key, _)| worktree_group_remove_spinner(app, key)) + .or_else(|| worktree_remove_spinner(app, i)); + let state_icon = removal_spinner + .map(|spinner| { + ( + spinner, + Style::default().fg(p.accent).add_modifier(Modifier::BOLD), + ) + }) + .unwrap_or_else(|| state_icon(display_state, display_seen, app.status_indicators, p)); + let state_text = if removal_spinner.is_some() { + "removing" + } else { + state_label(display_state, display_seen) + }; let state_text_style = Style::default() - .fg(state_label_color(display_state, display_seen, p)) + .fg(if removal_spinner.is_some() { + p.accent + } else { + state_label_color(display_state, display_seen, p) + }) .add_modifier(Modifier::DIM); let branch_style = Style::default().fg(if selected || is_active { p.mauve @@ -1291,7 +1349,7 @@ fn render_workspace_list( SpaceTokenContext { workspace: &display_label, branch: ws.branch().as_deref(), - state_text: state_label(display_state, display_seen), + state_text, ahead_behind: ws.git_ahead_behind(), tokens: &token_values, suppress_git_details: card.indented, @@ -2483,6 +2541,53 @@ rows = [[{ token = "git_status", fg = "#123456" }]] ); } + #[test] + fn workspace_row_shows_spinner_while_worktree_is_removing() { + let mut app = AppState::test_new(); + app.workspaces = vec![workspace_with_worktree_space( + "issue", + Some("repo-key"), + "/repo/herdr-issue", + )]; + let workspace_id = app.workspaces[0].id.clone(); + app.worktree_remove = Some(crate::app::state::WorktreeRemoveState { + workspace_id, + repo_root: "/repo/herdr".into(), + path: "/repo/herdr-issue".into(), + error: None, + removing: true, + spinner_frame: 3, + force_confirmation: false, + }); + app.sidebar_spaces.rows = vec![vec![ + crate::config::SpaceSidebarToken::StateIcon, + crate::config::SpaceSidebarToken::Workspace, + ]]; + let area = Rect::new(0, 0, 24, 8); + app.view.workspace_card_areas = compute_workspace_card_areas(&app, area); + let list_area = workspace_list_rect(area, app.sidebar_section_split); + let mut terminal = Terminal::new(TestBackend::new(area.width, area.height)).unwrap(); + + terminal + .draw(|frame| { + render_workspace_list( + &app, + &TerminalRuntimeRegistry::new(), + frame, + list_area, + false, + ) + }) + .unwrap(); + + let buffer = terminal.backend().buffer(); + let card = &app.view.workspace_card_areas[0]; + assert_eq!( + buffer[(card.rect.x + 1, card.rect.y)].symbol(), + WORKTREE_REMOVE_SPINNER_FRAMES[3] + ); + } + #[test] fn desktop_worktree_connector_uses_full_list_at_viewport_boundary() { let mut app = AppState::test_new(); From 422e592a07b4468e2dabb02f452626f26ec6ea12 Mon Sep 17 00:00:00 2001 From: theMackabu Date: Tue, 4 Aug 2026 17:32:40 -0700 Subject: [PATCH 2/2] address coderabbit wet code nit --- src/app/api/worktrees/deferred.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/app/api/worktrees/deferred.rs b/src/app/api/worktrees/deferred.rs index b4a47d21d4..07aec02b48 100644 --- a/src/app/api/worktrees/deferred.rs +++ b/src/app/api/worktrees/deferred.rs @@ -518,14 +518,10 @@ impl App { } else { remove.error = Some(message.clone()); } + self.stop_worktree_remove_spinner(); + self.state.mode = crate::app::Mode::ConfirmRemoveWorktree; } } - if self.state.worktree_remove.as_ref().is_some_and(|remove| { - remove.workspace_id == result.workspace_id && remove.path == result.path - }) { - self.stop_worktree_remove_spinner(); - self.state.mode = crate::app::Mode::ConfirmRemoveWorktree; - } Self::send_api_response(api.respond_to, encode_error(api.id, code, message)); return; }