Cargo.lock π
@@ -358,6 +358,7 @@ dependencies = [
"futures 0.3.31",
"fuzzy",
"git",
+ "git_ui",
"gpui",
"gpui_tokio",
"heapless",
Richard Feldman created
- Add cancellable job mechanism to the git worker queue with CancellationToken
- Add kill_on_drop(true) to git worktree add subprocess
- Replace inline 'Creating Worktree...' banner with a status toast with Cancel button
- Add stop button in the agent panel toolbar during worktree creation
- Show spinner on sidebar 'New Thread' entry during worktree creation
- Cancel in-progress worktree creation when Cmd-N starts a new thread
- Clean up partially-created worktrees on cancellation
Cargo.lock | 1
crates/agent_ui/Cargo.toml | 1
crates/agent_ui/src/agent_panel.rs | 218 ++++++++++++++----
crates/git/src/repository.rs | 4
crates/git_ui/src/worktree_picker.rs | 4
crates/project/src/git_store.rs | 341 ++++++++++++++++++++++++++++-
crates/sidebar/src/sidebar.rs | 10
7 files changed, 509 insertions(+), 70 deletions(-)
@@ -358,6 +358,7 @@ dependencies = [
"futures 0.3.31",
"fuzzy",
"git",
+ "git_ui",
"gpui",
"gpui_tokio",
"heapless",
@@ -59,6 +59,7 @@ file_icons.workspace = true
fs.workspace = true
futures.workspace = true
git.workspace = true
+git_ui.workspace = true
fuzzy.workspace = true
gpui.workspace = true
gpui_tokio.workspace = true
@@ -70,6 +70,7 @@ use gpui::{
};
use language::LanguageRegistry;
use language_model::{ConfigurationError, LanguageModelRegistry};
+use notifications::status_toast::{StatusToast, ToastIcon};
use project::project_settings::ProjectSettings;
use project::{Project, ProjectPath, Worktree};
use prompt_store::{PromptBuilder, PromptStore, UserPromptId};
@@ -751,6 +752,12 @@ pub struct AgentPanel {
_thread_view_subscription: Option<Subscription>,
_active_thread_focus_subscription: Option<Subscription>,
_worktree_creation_task: Option<Task<()>>,
+ worktree_creation_tokens: Vec<(
+ Entity<project::git_store::Repository>,
+ PathBuf,
+ project::git_store::CancellationToken,
+ )>,
+ worktree_creation_toast: Option<Entity<StatusToast>>,
show_trust_workspace_message: bool,
last_configuration_error_telemetry: Option<String>,
on_boarding_upsell_dismissed: AtomicBool,
@@ -1084,6 +1091,8 @@ impl AgentPanel {
_thread_view_subscription: None,
_active_thread_focus_subscription: None,
_worktree_creation_task: None,
+ worktree_creation_tokens: Vec::new(),
+ worktree_creation_toast: None,
show_trust_workspace_message: false,
last_configuration_error_telemetry: None,
on_boarding_upsell_dismissed: AtomicBool::new(OnboardingUpsell::dismissed(cx)),
@@ -1179,6 +1188,12 @@ impl AgentPanel {
}
pub fn new_thread(&mut self, _action: &NewThread, window: &mut Window, cx: &mut Context<Self>) {
+ if matches!(
+ self.worktree_creation_status,
+ Some(WorktreeCreationStatus::Creating)
+ ) {
+ self.cancel_worktree_creation(window, cx);
+ }
self.reset_start_thread_in_to_default(cx);
self.external_thread(None, None, None, None, None, true, window, cx);
}
@@ -2632,24 +2647,34 @@ impl AgentPanel {
futures::channel::oneshot::Receiver<Result<()>>,
)>,
Vec<(PathBuf, PathBuf)>,
+ Vec<(
+ Entity<project::git_store::Repository>,
+ PathBuf,
+ project::git_store::CancellationToken,
+ )>,
)> {
let mut creation_infos = Vec::new();
let mut path_remapping = Vec::new();
+ let mut tokens = Vec::new();
for repo in git_repos {
- let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
+ let (work_dir, new_path, receiver, token) = repo.update(cx, |repo, _cx| {
let new_path =
repo.path_for_new_linked_worktree(branch_name, worktree_directory_setting)?;
- let receiver =
- repo.create_worktree(branch_name.to_string(), new_path.clone(), None);
+ let (receiver, token) = repo.create_cancellable_worktree(
+ branch_name.to_string(),
+ new_path.clone(),
+ None,
+ );
let work_dir = repo.work_directory_abs_path.clone();
- anyhow::Ok((work_dir, new_path, receiver))
+ anyhow::Ok((work_dir, new_path, receiver, token))
})?;
path_remapping.push((work_dir.to_path_buf(), new_path.clone()));
+ tokens.push((repo.clone(), new_path.clone(), token));
creation_infos.push((repo.clone(), new_path, receiver));
}
- Ok((creation_infos, path_remapping))
+ Ok((creation_infos, path_remapping, tokens))
}
/// Waits for every in-flight worktree creation to complete. If any
@@ -2730,6 +2755,47 @@ impl AgentPanel {
Err(anyhow!(error_message))
}
+ fn cancel_worktree_creation(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
+ if !matches!(
+ self.worktree_creation_status,
+ Some(WorktreeCreationStatus::Creating)
+ ) {
+ return;
+ }
+
+ self._worktree_creation_task = None;
+
+ let tokens = std::mem::take(&mut self.worktree_creation_tokens);
+ for (_repo, path_for_cleanup, token) in tokens {
+ drop(token.cancel(move |state, cx| {
+ cx.background_spawn(async move {
+ if let project::git_store::RepositoryState::Local(local) = state {
+ local
+ .backend
+ .remove_worktree(path_for_cleanup, true)
+ .await
+ .log_err();
+ }
+ })
+ }));
+ }
+
+ self.worktree_creation_status = None;
+
+ if let Some(toast) = self.worktree_creation_toast.take() {
+ toast.update(cx, |_, cx| cx.emit(DismissEvent));
+ }
+
+ cx.notify();
+ }
+
+ pub fn is_creating_worktree(&self) -> bool {
+ matches!(
+ self.worktree_creation_status,
+ Some(WorktreeCreationStatus::Creating)
+ )
+ }
+
fn set_worktree_creation_error(
&mut self,
message: SharedString,
@@ -2737,6 +2803,10 @@ impl AgentPanel {
cx: &mut Context<Self>,
) {
self.worktree_creation_status = Some(WorktreeCreationStatus::Error(message));
+ self.worktree_creation_tokens.clear();
+ if let Some(toast) = self.worktree_creation_toast.take() {
+ toast.update(cx, |_, cx| cx.emit(DismissEvent));
+ }
if matches!(self.active_view, ActiveView::Uninitialized) {
let selected_agent_type = self.selected_agent_type.clone();
self.new_agent_thread(selected_agent_type, window, cx);
@@ -2760,6 +2830,22 @@ impl AgentPanel {
self.worktree_creation_status = Some(WorktreeCreationStatus::Creating);
cx.notify();
+ if let Some(workspace) = self.workspace.upgrade() {
+ let toast = StatusToast::new("Creating worktree\u{2026}", cx, |this, _cx| {
+ this.icon(ToastIcon::new(ui::IconName::LoadCircle).color(ui::Color::Muted))
+ .action("Cancel", |window, cx| {
+ window.dispatch_action(
+ git_ui::worktree_picker::CancelWorktreeCreation.boxed_clone(),
+ cx,
+ );
+ })
+ });
+ self.worktree_creation_toast = Some(toast.clone());
+ workspace.update(cx, |workspace, cx| {
+ workspace.toggle_status_toast(toast, cx);
+ });
+ }
+
let (git_repos, non_git_paths) = self.classify_worktrees(cx);
if git_repos.is_empty() {
@@ -2835,27 +2921,33 @@ impl AgentPanel {
}
};
- let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| {
- Self::start_worktree_creations(
- &git_repos,
- &branch_name,
- &worktree_directory_setting,
- cx,
- )
- }) {
- Ok(Ok(result)) => result,
- Ok(Err(err)) | Err(err) => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- format!("Failed to validate worktree directory: {err}").into(),
- window,
- cx,
- );
- })
- .log_err();
- return anyhow::Ok(());
- }
- };
+ let (creation_infos, path_remapping, tokens) =
+ match this.update_in(cx, |_this, _window, cx| {
+ Self::start_worktree_creations(
+ &git_repos,
+ &branch_name,
+ &worktree_directory_setting,
+ cx,
+ )
+ }) {
+ Ok(Ok(result)) => result,
+ Ok(Err(err)) | Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ format!("Failed to validate worktree directory: {err}").into(),
+ window,
+ cx,
+ );
+ })
+ .log_err();
+ return anyhow::Ok(());
+ }
+ };
+
+ this.update_in(cx, |this, _window, _cx| {
+ this.worktree_creation_tokens = tokens;
+ })
+ .ok();
let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
{
@@ -2886,6 +2978,14 @@ impl AgentPanel {
}
};
+ this.update_in(cx, |this, _window, cx| {
+ this.worktree_creation_tokens.clear();
+ if let Some(toast) = this.worktree_creation_toast.take() {
+ toast.update(cx, |_, cx| cx.emit(DismissEvent));
+ }
+ })
+ .ok();
+
let this_for_error = this.clone();
if let Err(err) = Self::setup_new_workspace(
this,
@@ -4112,6 +4212,24 @@ impl AgentPanel {
}),
)
})
+ .when(
+ matches!(
+ self.worktree_creation_status,
+ Some(WorktreeCreationStatus::Creating)
+ ),
+ |this| {
+ this.child(
+ IconButton::new("cancel-worktree-creation", IconName::Stop)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Error)
+ .style(ButtonStyle::Tinted(ui::TintColor::Error))
+ .tooltip(Tooltip::text("Cancel worktree creation"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.cancel_worktree_creation(window, cx);
+ })),
+ )
+ },
+ )
.child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
@@ -4178,37 +4296,34 @@ impl AgentPanel {
}),
)
})
+ .when(
+ matches!(
+ self.worktree_creation_status,
+ Some(WorktreeCreationStatus::Creating)
+ ),
+ |this| {
+ this.child(
+ IconButton::new("cancel-worktree-creation", IconName::Stop)
+ .icon_size(IconSize::Small)
+ .icon_color(Color::Error)
+ .style(ButtonStyle::Tinted(ui::TintColor::Error))
+ .tooltip(Tooltip::text("Cancel worktree creation"))
+ .on_click(cx.listener(|this, _, window, cx| {
+ this.cancel_worktree_creation(window, cx);
+ })),
+ )
+ },
+ )
.child(self.render_panel_options_menu(window, cx)),
)
.into_any_element()
}
}
- fn render_worktree_creation_status(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+ fn render_worktree_creation_status(&self, _cx: &mut Context<Self>) -> Option<AnyElement> {
let status = self.worktree_creation_status.as_ref()?;
match status {
- WorktreeCreationStatus::Creating => Some(
- h_flex()
- .absolute()
- .bottom_12()
- .w_full()
- .p_2()
- .gap_1()
- .justify_center()
- .bg(cx.theme().colors().editor_background)
- .child(
- Icon::new(IconName::LoadCircle)
- .size(IconSize::Small)
- .color(Color::Muted)
- .with_rotate_animation(3),
- )
- .child(
- Label::new("Creating Worktreeβ¦")
- .color(Color::Muted)
- .size(LabelSize::Small),
- )
- .into_any_element(),
- ),
+ WorktreeCreationStatus::Creating => None,
WorktreeCreationStatus::Error(message) => Some(
Callout::new()
.icon(IconName::Warning)
@@ -4648,6 +4763,11 @@ impl Render for AgentPanel {
.on_action(cx.listener(Self::decrease_font_size))
.on_action(cx.listener(Self::reset_font_size))
.on_action(cx.listener(Self::toggle_zoom))
+ .on_action(cx.listener(
+ |this, _: &git_ui::worktree_picker::CancelWorktreeCreation, window, cx| {
+ this.cancel_worktree_creation(window, cx);
+ },
+ ))
.on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| {
if let Some(conversation_view) = this.active_conversation_view() {
conversation_view.update(cx, |conversation_view, cx| {
@@ -1660,7 +1660,9 @@ impl GitRepository for RealGitRepository {
.spawn(async move {
std::fs::create_dir_all(path.parent().unwrap_or(&path))?;
let git = git_binary?;
- let output = git.build_command(&args).output().await?;
+ let mut command = git.build_command(&args);
+ command.kill_on_drop(true);
+ let output = command.output().await?;
if output.status.success() {
Ok(())
} else {
@@ -29,7 +29,9 @@ actions!(
[
WorktreeFromDefault,
WorktreeFromDefaultOnWindow,
- DeleteWorktree
+ DeleteWorktree,
+ /// Cancels an in-progress worktree creation.
+ CancelWorktreeCreation,
]
);
@@ -298,6 +298,47 @@ pub struct RepositorySnapshot {
type JobId = u64;
+#[derive(Copy, Clone)]
+pub enum CancelOutcome {
+ /// The job was still queued and was removed before it started.
+ RemovedFromQueue,
+ /// The job was running; the process was killed. Cleanup callback has been awaited.
+ KilledRunning,
+ /// The job had already finished before the cancel arrived.
+ AlreadyFinished,
+}
+
+pub struct CancellationToken {
+ id: JobId,
+ job_sender: mpsc::UnboundedSender<GitWorkerMessage>,
+}
+
+impl CancellationToken {
+ pub fn cancel(
+ self,
+ cleanup: impl FnOnce(RepositoryState, &mut AsyncApp) -> Task<()> + 'static,
+ ) -> oneshot::Receiver<CancelOutcome> {
+ let (result_tx, result_rx) = oneshot::channel();
+ self.job_sender
+ .unbounded_send(GitWorkerMessage::Cancel {
+ id: self.id,
+ cleanup: Some(Box::new(cleanup)),
+ result_tx,
+ })
+ .ok();
+ result_rx
+ }
+}
+
+enum GitWorkerMessage {
+ Job(GitJob),
+ Cancel {
+ id: JobId,
+ cleanup: Option<Box<dyn FnOnce(RepositoryState, &mut AsyncApp) -> Task<()>>>,
+ result_tx: oneshot::Sender<CancelOutcome>,
+ },
+}
+
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JobInfo {
pub start: Instant,
@@ -336,7 +377,7 @@ pub struct Repository {
// For a local repository, holds paths that have had worktree events since the last status scan completed,
// and that should be examined during the next status scan.
paths_needing_status_update: Vec<Vec<RepoPath>>,
- job_sender: mpsc::UnboundedSender<GitJob>,
+ job_sender: mpsc::UnboundedSender<GitWorkerMessage>,
active_jobs: HashMap<JobId, JobInfo>,
pending_ops: SumTree<PendingOps>,
job_id: JobId,
@@ -455,6 +496,7 @@ impl EventEmitter<JobsUpdated> for Repository {}
impl EventEmitter<GitStoreEvent> for GitStore {}
pub struct GitJob {
+ id: JobId,
job: Box<dyn FnOnce(RepositoryState, &mut AsyncApp) -> Task<()>>,
key: Option<GitJobKey>,
}
@@ -4175,6 +4217,57 @@ impl Repository {
self.send_keyed_job(None, status, job)
}
+ pub fn send_cancellable_job<F, Fut, R>(
+ &mut self,
+ status: Option<SharedString>,
+ job: F,
+ ) -> (oneshot::Receiver<R>, CancellationToken)
+ where
+ F: FnOnce(RepositoryState, AsyncApp) -> Fut + 'static,
+ Fut: Future<Output = R> + 'static,
+ R: Send + 'static,
+ {
+ let (result_tx, result_rx) = futures::channel::oneshot::channel();
+ let job_id = post_inc(&mut self.job_id);
+ let this = self.this.clone();
+ let token = CancellationToken {
+ id: job_id,
+ job_sender: self.job_sender.clone(),
+ };
+ self.job_sender
+ .unbounded_send(GitWorkerMessage::Job(GitJob {
+ id: job_id,
+ key: None,
+ job: Box::new(move |state, cx: &mut AsyncApp| {
+ let job = job(state, cx.clone());
+ cx.spawn(async move |cx| {
+ if let Some(s) = status.clone() {
+ this.update(cx, |this, cx| {
+ this.active_jobs.insert(
+ job_id,
+ JobInfo {
+ start: Instant::now(),
+ message: s.clone(),
+ },
+ );
+ cx.notify();
+ })
+ .ok();
+ }
+ let result = job.await;
+ this.update(cx, |this, cx| {
+ this.active_jobs.remove(&job_id);
+ cx.notify();
+ })
+ .ok();
+ result_tx.send(result).ok();
+ })
+ }),
+ }))
+ .ok();
+ (result_rx, token)
+ }
+
fn send_keyed_job<F, Fut, R>(
&mut self,
key: Option<GitJobKey>,
@@ -4190,7 +4283,8 @@ impl Repository {
let job_id = post_inc(&mut self.job_id);
let this = self.this.clone();
self.job_sender
- .unbounded_send(GitJob {
+ .unbounded_send(GitWorkerMessage::Job(GitJob {
+ id: job_id,
key,
job: Box::new(move |state, cx: &mut AsyncApp| {
let job = job(state, cx.clone());
@@ -4220,7 +4314,7 @@ impl Repository {
result_tx.send(result).ok();
})
}),
- })
+ }))
.ok();
result_rx
}
@@ -5882,6 +5976,37 @@ impl Repository {
)
}
+ pub fn create_cancellable_worktree(
+ &mut self,
+ branch_name: String,
+ path: PathBuf,
+ commit: Option<String>,
+ ) -> (oneshot::Receiver<Result<()>>, CancellationToken) {
+ let id = self.id;
+ self.send_cancellable_job(
+ Some(format!("git worktree add: {}", branch_name).into()),
+ move |repo, _cx| async move {
+ match repo {
+ RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
+ backend.create_worktree(branch_name, path, commit).await
+ }
+ RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+ client
+ .request(proto::GitCreateWorktree {
+ project_id: project_id.0,
+ repository_id: id.to_proto(),
+ name: branch_name,
+ directory: path.to_string_lossy().to_string(),
+ commit,
+ })
+ .await?;
+ Ok(())
+ }
+ }
+ },
+ )
+ }
+
pub fn remove_worktree(&mut self, path: PathBuf, force: bool) -> oneshot::Receiver<Result<()>> {
let id = self.id;
self.send_job(
@@ -6405,8 +6530,8 @@ impl Repository {
fn spawn_local_git_worker(
state: Shared<Task<Result<LocalRepositoryState, String>>>,
cx: &mut Context<Self>,
- ) -> mpsc::UnboundedSender<GitJob> {
- let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
+ ) -> mpsc::UnboundedSender<GitWorkerMessage> {
+ let (job_tx, mut job_rx) = mpsc::unbounded::<GitWorkerMessage>();
cx.spawn(async move |_, cx| {
let state = state.await.map_err(|err| anyhow::anyhow!(err))?;
@@ -6420,10 +6545,34 @@ impl Repository {
.await;
}
let state = RepositoryState::Local(state);
- let mut jobs = VecDeque::new();
+ let mut jobs: VecDeque<GitJob> = VecDeque::new();
+ let mut completed_ids: HashSet<JobId> = HashSet::new();
loop {
- while let Ok(Some(next_job)) = job_rx.try_next() {
- jobs.push_back(next_job);
+ while let Ok(Some(msg)) = job_rx.try_next() {
+ match msg {
+ GitWorkerMessage::Job(job) => jobs.push_back(job),
+ GitWorkerMessage::Cancel {
+ id,
+ cleanup,
+ result_tx,
+ } => {
+ let before = jobs.len();
+ jobs.retain(|j| j.id != id);
+ let outcome = if jobs.len() < before {
+ CancelOutcome::RemovedFromQueue
+ } else if completed_ids.contains(&id) {
+ CancelOutcome::AlreadyFinished
+ } else {
+ CancelOutcome::AlreadyFinished
+ };
+ result_tx.send(outcome).ok();
+ if let Some(f) = cleanup {
+ if matches!(outcome, CancelOutcome::RemovedFromQueue) {
+ f(state.clone(), cx).await;
+ }
+ }
+ }
+ }
}
if let Some(job) = jobs.pop_front() {
@@ -6434,9 +6583,74 @@ impl Repository {
{
continue;
}
- (job.job)(state.clone(), cx).await;
- } else if let Some(job) = job_rx.next().await {
- jobs.push_back(job);
+ let running_job_id = job.id;
+ let task = (job.job)(state.clone(), cx);
+ let mut task = std::pin::pin!(task);
+
+ loop {
+ match futures::future::select(task, job_rx.next()).await {
+ futures::future::Either::Left(((), _)) => {
+ completed_ids.insert(running_job_id);
+ if completed_ids.len() > 256 {
+ let to_remove: Vec<_> =
+ completed_ids.iter().copied().take(128).collect();
+ for id in to_remove {
+ completed_ids.remove(&id);
+ }
+ }
+ break;
+ }
+ futures::future::Either::Right((Some(msg), ongoing_task)) => {
+ match msg {
+ GitWorkerMessage::Job(j) => {
+ jobs.push_back(j);
+ task = ongoing_task;
+ }
+ GitWorkerMessage::Cancel {
+ id,
+ cleanup,
+ result_tx,
+ } => {
+ if id == running_job_id {
+ let _ = ongoing_task;
+ if let Some(f) = cleanup {
+ f(state.clone(), cx).await;
+ }
+ result_tx.send(CancelOutcome::KilledRunning).ok();
+ break;
+ } else {
+ let before = jobs.len();
+ jobs.retain(|j| j.id != id);
+ let outcome = if jobs.len() < before {
+ CancelOutcome::RemovedFromQueue
+ } else if completed_ids.contains(&id) {
+ CancelOutcome::AlreadyFinished
+ } else {
+ CancelOutcome::AlreadyFinished
+ };
+ result_tx.send(outcome).ok();
+ task = ongoing_task;
+ }
+ }
+ }
+ }
+ futures::future::Either::Right((None, ongoing_task)) => {
+ ongoing_task.await;
+ break;
+ }
+ }
+ }
+ } else if let Some(msg) = job_rx.next().await {
+ match msg {
+ GitWorkerMessage::Job(job) => jobs.push_back(job),
+ GitWorkerMessage::Cancel {
+ id: _,
+ cleanup: _,
+ result_tx,
+ } => {
+ result_tx.send(CancelOutcome::AlreadyFinished).ok();
+ }
+ }
} else {
break;
}
@@ -6451,15 +6665,39 @@ impl Repository {
fn spawn_remote_git_worker(
state: RemoteRepositoryState,
cx: &mut Context<Self>,
- ) -> mpsc::UnboundedSender<GitJob> {
- let (job_tx, mut job_rx) = mpsc::unbounded::<GitJob>();
+ ) -> mpsc::UnboundedSender<GitWorkerMessage> {
+ let (job_tx, mut job_rx) = mpsc::unbounded::<GitWorkerMessage>();
cx.spawn(async move |_, cx| {
let state = RepositoryState::Remote(state);
- let mut jobs = VecDeque::new();
+ let mut jobs: VecDeque<GitJob> = VecDeque::new();
+ let mut completed_ids: HashSet<JobId> = HashSet::new();
loop {
- while let Ok(Some(next_job)) = job_rx.try_next() {
- jobs.push_back(next_job);
+ while let Ok(Some(msg)) = job_rx.try_next() {
+ match msg {
+ GitWorkerMessage::Job(job) => jobs.push_back(job),
+ GitWorkerMessage::Cancel {
+ id,
+ cleanup,
+ result_tx,
+ } => {
+ let before = jobs.len();
+ jobs.retain(|j| j.id != id);
+ let outcome = if jobs.len() < before {
+ CancelOutcome::RemovedFromQueue
+ } else if completed_ids.contains(&id) {
+ CancelOutcome::AlreadyFinished
+ } else {
+ CancelOutcome::AlreadyFinished
+ };
+ result_tx.send(outcome).ok();
+ if let Some(f) = cleanup {
+ if matches!(outcome, CancelOutcome::RemovedFromQueue) {
+ f(state.clone(), cx).await;
+ }
+ }
+ }
+ }
}
if let Some(job) = jobs.pop_front() {
@@ -6470,9 +6708,74 @@ impl Repository {
{
continue;
}
- (job.job)(state.clone(), cx).await;
- } else if let Some(job) = job_rx.next().await {
- jobs.push_back(job);
+ let running_job_id = job.id;
+ let task = (job.job)(state.clone(), cx);
+ let mut task = std::pin::pin!(task);
+
+ loop {
+ match futures::future::select(task, job_rx.next()).await {
+ futures::future::Either::Left(((), _)) => {
+ completed_ids.insert(running_job_id);
+ if completed_ids.len() > 256 {
+ let to_remove: Vec<_> =
+ completed_ids.iter().copied().take(128).collect();
+ for id in to_remove {
+ completed_ids.remove(&id);
+ }
+ }
+ break;
+ }
+ futures::future::Either::Right((Some(msg), ongoing_task)) => {
+ match msg {
+ GitWorkerMessage::Job(j) => {
+ jobs.push_back(j);
+ task = ongoing_task;
+ }
+ GitWorkerMessage::Cancel {
+ id,
+ cleanup,
+ result_tx,
+ } => {
+ if id == running_job_id {
+ let _ = ongoing_task;
+ if let Some(f) = cleanup {
+ f(state.clone(), cx).await;
+ }
+ result_tx.send(CancelOutcome::KilledRunning).ok();
+ break;
+ } else {
+ let before = jobs.len();
+ jobs.retain(|j| j.id != id);
+ let outcome = if jobs.len() < before {
+ CancelOutcome::RemovedFromQueue
+ } else if completed_ids.contains(&id) {
+ CancelOutcome::AlreadyFinished
+ } else {
+ CancelOutcome::AlreadyFinished
+ };
+ result_tx.send(outcome).ok();
+ task = ongoing_task;
+ }
+ }
+ }
+ }
+ futures::future::Either::Right((None, ongoing_task)) => {
+ ongoing_task.await;
+ break;
+ }
+ }
+ }
+ } else if let Some(msg) = job_rx.next().await {
+ match msg {
+ GitWorkerMessage::Job(job) => jobs.push_back(job),
+ GitWorkerMessage::Cancel {
+ id: _,
+ cleanup: _,
+ result_tx,
+ } => {
+ result_tx.send(CancelOutcome::AlreadyFinished).ok();
+ }
+ }
} else {
break;
}
@@ -300,6 +300,7 @@ pub struct Sidebar {
focused_thread: Option<acp::SessionId>,
agent_panel_visible: bool,
active_thread_is_draft: bool,
+ is_creating_worktree: bool,
hovered_thread_index: Option<usize>,
collapsed_groups: HashSet<PathList>,
expanded_groups: HashMap<PathList, usize>,
@@ -399,6 +400,7 @@ impl Sidebar {
focused_thread: None,
agent_panel_visible: false,
active_thread_is_draft: false,
+ is_creating_worktree: false,
hovered_thread_index: None,
collapsed_groups: HashSet::new(),
expanded_groups: HashMap::new(),
@@ -652,6 +654,11 @@ impl Sidebar {
.and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
.map_or(false, |panel| panel.read(cx).active_thread_is_draft(cx));
+ self.is_creating_worktree = active_workspace
+ .as_ref()
+ .and_then(|ws| ws.read(cx).panel::<AgentPanel>(cx))
+ .map_or(false, |panel| panel.read(cx).is_creating_worktree());
+
// Derive focused_thread from the active workspace's agent panel.
// Only update when the panel gives us a positive signal β if the
// panel returns None (e.g. still loading after a thread activation),
@@ -2934,6 +2941,9 @@ impl Sidebar {
.icon_color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.8)))
.selected(is_active)
.focused(is_selected)
+ .when(self.is_creating_worktree && is_active, |this| {
+ this.status(AgentThreadStatus::Running)
+ })
.when(!is_active, |this| {
this.on_click(cx.listener(move |this, _, window, cx| {
this.selection = None;