Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/next/website/src/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/next/website/src/content/docs/ja/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 は強制削除を実行する前にもう一度確認します。ブランチは削除されません。

## リモートアタッチ

Expand Down
2 changes: 1 addition & 1 deletion docs/next/website/src/content/docs/zh-cn/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 会在强制删除前再次确认。分支不会被删除。

## 远程连接

Expand Down
14 changes: 14 additions & 0 deletions src/app/api/worktrees.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
}
}
8 changes: 3 additions & 5 deletions src/app/api/worktrees/deferred.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,8 @@ impl App {
} else {
remove.error = Some(message.clone());
}
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));
Expand Down Expand Up @@ -584,12 +586,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,
Expand Down
2 changes: 2 additions & 0 deletions src/app/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ pub struct App {
pub(crate) pending_api_worktree_removes: HashMap<String, u64>,
pub(crate) pending_api_worktree_remove_paths: HashMap<std::path::PathBuf, u64>,
pub(crate) next_api_worktree_operation_id: u64,
pub(crate) worktree_remove_spinner_deadline: Option<Instant>,
pub(crate) last_sidebar_divider_click: Option<Instant>,
pub(crate) last_pane_click: Option<PaneClickState>,
pub(crate) pending_url_click_sources: HashSet<InputSourceId>,
Expand Down Expand Up @@ -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(),
Expand Down
72 changes: 68 additions & 4 deletions src/app/runtime.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
use std::time::Instant;

#[cfg(test)]
use std::time::Duration;
use std::time::{Duration, Instant};

use crossterm::terminal;

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<Option<std::process::ExitStatus>>,
Expand All @@ -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()));
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions src/app/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,7 @@ pub struct WorktreeRemoveState {
pub path: std::path::PathBuf,
pub error: Option<String>,
pub removing: bool,
pub spinner_frame: u8,
pub force_confirmation: bool,
}

Expand Down
Loading
Loading