thread_worktree_picker.rs

   1use std::path::PathBuf;
   2use std::sync::Arc;
   3
   4use collections::HashSet;
   5use fuzzy::StringMatchCandidate;
   6use git::repository::Worktree as GitWorktree;
   7use gpui::{
   8    AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
   9    IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Task, Window, rems,
  10};
  11use picker::{Picker, PickerDelegate, PickerEditorPosition};
  12use project::Project;
  13use project::git_store::RepositoryEvent;
  14use ui::{Divider, HighlightedLabel, ListItem, ListItemSpacing, Tooltip, prelude::*};
  15use util::ResultExt as _;
  16use util::paths::PathExt;
  17
  18use crate::{CreateWorktree, NewWorktreeBranchTarget, SwitchWorktree};
  19
  20pub(crate) struct ThreadWorktreePicker {
  21    picker: Entity<Picker<ThreadWorktreePickerDelegate>>,
  22    focus_handle: FocusHandle,
  23    _subscriptions: Vec<Subscription>,
  24}
  25
  26impl ThreadWorktreePicker {
  27    pub fn new(project: Entity<Project>, window: &mut Window, cx: &mut Context<Self>) -> Self {
  28        let project_worktree_paths: HashSet<PathBuf> = project
  29            .read(cx)
  30            .visible_worktrees(cx)
  31            .map(|wt| wt.read(cx).abs_path().to_path_buf())
  32            .collect();
  33
  34        let has_multiple_repositories = project.read(cx).repositories(cx).len() > 1;
  35
  36        let current_branch_name = project.read(cx).active_repository(cx).and_then(|repo| {
  37            repo.read(cx)
  38                .branch
  39                .as_ref()
  40                .map(|branch| branch.name().to_string())
  41        });
  42
  43        let repository = if has_multiple_repositories {
  44            None
  45        } else {
  46            project.read(cx).active_repository(cx)
  47        };
  48
  49        // Fetch worktrees from the git backend (includes main + all linked)
  50        let all_worktrees_request = repository
  51            .clone()
  52            .map(|repo| repo.update(cx, |repo, _| repo.worktrees()));
  53
  54        let default_branch_request = repository
  55            .clone()
  56            .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false)));
  57
  58        let initial_matches = vec![ThreadWorktreeEntry::CreateFromCurrentBranch];
  59
  60        let delegate = ThreadWorktreePickerDelegate {
  61            matches: initial_matches,
  62            all_worktrees: Vec::new(),
  63            project_worktree_paths,
  64            selected_index: 0,
  65            project,
  66            current_branch_name,
  67            default_branch_name: None,
  68            has_multiple_repositories,
  69        };
  70
  71        let picker = cx.new(|cx| {
  72            Picker::list(delegate, window, cx)
  73                .list_measure_all()
  74                .modal(false)
  75                .max_height(Some(rems(20.).into()))
  76        });
  77
  78        let mut subscriptions = Vec::new();
  79
  80        // Fetch worktrees and default branch asynchronously
  81        {
  82            let picker_handle = picker.downgrade();
  83            cx.spawn_in(window, async move |_this, cx| {
  84                let all_worktrees: Vec<_> = match all_worktrees_request {
  85                    Some(req) => match req.await {
  86                        Ok(Ok(worktrees)) => {
  87                            worktrees.into_iter().filter(|wt| !wt.is_bare).collect()
  88                        }
  89                        Ok(Err(err)) => {
  90                            log::warn!("ThreadWorktreePicker: git worktree list failed: {err}");
  91                            return anyhow::Ok(());
  92                        }
  93                        Err(_) => {
  94                            log::warn!("ThreadWorktreePicker: worktree request was cancelled");
  95                            return anyhow::Ok(());
  96                        }
  97                    },
  98                    None => Vec::new(),
  99                };
 100
 101                let default_branch = match default_branch_request {
 102                    Some(req) => req.await.ok().and_then(Result::ok).flatten(),
 103                    None => None,
 104                };
 105
 106                picker_handle.update_in(cx, |picker, window, cx| {
 107                    picker.delegate.all_worktrees = all_worktrees;
 108                    picker.delegate.default_branch_name =
 109                        default_branch.map(|branch| branch.to_string());
 110                    picker.refresh(window, cx);
 111                })?;
 112
 113                anyhow::Ok(())
 114            })
 115            .detach_and_log_err(cx);
 116        }
 117
 118        // Subscribe to repository events to live-update the worktree list
 119        if let Some(repo) = &repository {
 120            let picker_entity = picker.downgrade();
 121            subscriptions.push(cx.subscribe_in(
 122                repo,
 123                window,
 124                move |_this, repo, event: &RepositoryEvent, window, cx| {
 125                    if matches!(event, RepositoryEvent::GitWorktreeListChanged) {
 126                        let worktrees_request = repo.update(cx, |repo, _| repo.worktrees());
 127                        let picker = picker_entity.clone();
 128                        cx.spawn_in(window, async move |_, cx| {
 129                            let all_worktrees: Vec<_> = worktrees_request
 130                                .await??
 131                                .into_iter()
 132                                .filter(|wt| !wt.is_bare)
 133                                .collect();
 134                            picker.update_in(cx, |picker, window, cx| {
 135                                picker.delegate.all_worktrees = all_worktrees;
 136                                picker.refresh(window, cx);
 137                            })?;
 138                            anyhow::Ok(())
 139                        })
 140                        .detach_and_log_err(cx);
 141                    }
 142                },
 143            ));
 144        }
 145
 146        subscriptions.push(cx.subscribe(&picker, |_, _, _, cx| {
 147            cx.emit(DismissEvent);
 148        }));
 149
 150        Self {
 151            focus_handle: picker.focus_handle(cx),
 152            picker,
 153            _subscriptions: subscriptions,
 154        }
 155    }
 156}
 157
 158impl Focusable for ThreadWorktreePicker {
 159    fn focus_handle(&self, _cx: &App) -> FocusHandle {
 160        self.focus_handle.clone()
 161    }
 162}
 163
 164impl EventEmitter<DismissEvent> for ThreadWorktreePicker {}
 165
 166impl Render for ThreadWorktreePicker {
 167    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
 168        v_flex()
 169            .w(rems(34.))
 170            .elevation_3(cx)
 171            .child(self.picker.clone())
 172            .on_mouse_down_out(cx.listener(|_, _, _, cx| {
 173                cx.emit(DismissEvent);
 174            }))
 175    }
 176}
 177
 178#[derive(Clone)]
 179enum ThreadWorktreeEntry {
 180    CreateFromCurrentBranch,
 181    CreateFromDefaultBranch {
 182        default_branch_name: String,
 183    },
 184    Separator,
 185    Worktree {
 186        worktree: GitWorktree,
 187        positions: Vec<usize>,
 188    },
 189    CreateNamed {
 190        name: String,
 191        /// When Some, create from this branch name (e.g. "main"). When None, create from current branch.
 192        from_branch: Option<String>,
 193        disabled_reason: Option<String>,
 194    },
 195}
 196
 197pub(crate) struct ThreadWorktreePickerDelegate {
 198    matches: Vec<ThreadWorktreeEntry>,
 199    all_worktrees: Vec<GitWorktree>,
 200    project_worktree_paths: HashSet<PathBuf>,
 201    selected_index: usize,
 202    project: Entity<Project>,
 203    current_branch_name: Option<String>,
 204    default_branch_name: Option<String>,
 205    has_multiple_repositories: bool,
 206}
 207
 208impl ThreadWorktreePickerDelegate {
 209    fn build_fixed_entries(&self) -> Vec<ThreadWorktreeEntry> {
 210        let mut entries = Vec::new();
 211
 212        entries.push(ThreadWorktreeEntry::CreateFromCurrentBranch);
 213
 214        if !self.has_multiple_repositories {
 215            if let Some(ref default_branch) = self.default_branch_name {
 216                let is_different = self
 217                    .current_branch_name
 218                    .as_ref()
 219                    .is_none_or(|current| current != default_branch);
 220                if is_different {
 221                    entries.push(ThreadWorktreeEntry::CreateFromDefaultBranch {
 222                        default_branch_name: default_branch.clone(),
 223                    });
 224                }
 225            }
 226        }
 227
 228        entries
 229    }
 230
 231    fn all_repo_worktrees(&self) -> &[GitWorktree] {
 232        if self.has_multiple_repositories {
 233            &[]
 234        } else {
 235            &self.all_worktrees
 236        }
 237    }
 238
 239    fn sync_selected_index(&mut self, has_query: bool) {
 240        if !has_query {
 241            return;
 242        }
 243
 244        // When filtering, prefer selecting the first worktree match
 245        if let Some(index) = self
 246            .matches
 247            .iter()
 248            .position(|entry| matches!(entry, ThreadWorktreeEntry::Worktree { .. }))
 249        {
 250            self.selected_index = index;
 251        } else if let Some(index) = self
 252            .matches
 253            .iter()
 254            .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. }))
 255        {
 256            self.selected_index = index;
 257        } else {
 258            self.selected_index = 0;
 259        }
 260    }
 261}
 262
 263impl PickerDelegate for ThreadWorktreePickerDelegate {
 264    type ListItem = AnyElement;
 265
 266    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
 267        "Select a worktree for this thread…".into()
 268    }
 269
 270    fn editor_position(&self) -> PickerEditorPosition {
 271        PickerEditorPosition::Start
 272    }
 273
 274    fn match_count(&self) -> usize {
 275        self.matches.len()
 276    }
 277
 278    fn selected_index(&self) -> usize {
 279        self.selected_index
 280    }
 281
 282    fn set_selected_index(
 283        &mut self,
 284        ix: usize,
 285        _window: &mut Window,
 286        _cx: &mut Context<Picker<Self>>,
 287    ) {
 288        self.selected_index = ix;
 289    }
 290
 291    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
 292        !matches!(self.matches.get(ix), Some(ThreadWorktreeEntry::Separator))
 293    }
 294
 295    fn update_matches(
 296        &mut self,
 297        query: String,
 298        window: &mut Window,
 299        cx: &mut Context<Picker<Self>>,
 300    ) -> Task<()> {
 301        let repo_worktrees = self.all_repo_worktrees().to_vec();
 302
 303        let normalized_query = query.replace(' ', "-");
 304        let main_worktree_path = self
 305            .all_worktrees
 306            .iter()
 307            .find(|wt| wt.is_main)
 308            .map(|wt| wt.path.clone());
 309        let has_named_worktree = self.all_worktrees.iter().any(|worktree| {
 310            worktree.directory_name(main_worktree_path.as_deref()) == normalized_query
 311        });
 312        let create_named_disabled_reason: Option<String> = if self.has_multiple_repositories {
 313            Some("Cannot create a named worktree in a project with multiple repositories".into())
 314        } else if has_named_worktree {
 315            Some("A worktree with this name already exists".into())
 316        } else {
 317            None
 318        };
 319
 320        let show_default_branch_create = !self.has_multiple_repositories
 321            && self.default_branch_name.as_ref().is_some_and(|default| {
 322                self.current_branch_name
 323                    .as_ref()
 324                    .is_none_or(|current| current != default)
 325            });
 326        let default_branch_name = self.default_branch_name.clone();
 327
 328        if query.is_empty() {
 329            let mut matches = self.build_fixed_entries();
 330
 331            if !repo_worktrees.is_empty() {
 332                let main_worktree_path = repo_worktrees
 333                    .iter()
 334                    .find(|wt| wt.is_main)
 335                    .map(|wt| wt.path.clone());
 336
 337                let mut sorted = repo_worktrees;
 338                let project_paths = &self.project_worktree_paths;
 339
 340                sorted.sort_by(|a, b| {
 341                    let a_is_current = project_paths.contains(&a.path);
 342                    let b_is_current = project_paths.contains(&b.path);
 343                    b_is_current.cmp(&a_is_current).then_with(|| {
 344                        a.directory_name(main_worktree_path.as_deref())
 345                            .cmp(&b.directory_name(main_worktree_path.as_deref()))
 346                    })
 347                });
 348
 349                matches.push(ThreadWorktreeEntry::Separator);
 350                for worktree in sorted {
 351                    matches.push(ThreadWorktreeEntry::Worktree {
 352                        worktree,
 353                        positions: Vec::new(),
 354                    });
 355                }
 356            }
 357
 358            self.matches = matches;
 359            self.sync_selected_index(false);
 360            return Task::ready(());
 361        }
 362
 363        // When the user is typing, fuzzy-match worktree names using display_name
 364        // For the main worktree, also match against "main"
 365        let main_worktree_path = repo_worktrees
 366            .iter()
 367            .find(|wt| wt.is_main)
 368            .map(|wt| wt.path.clone());
 369        let candidates: Vec<_> = repo_worktrees
 370            .iter()
 371            .enumerate()
 372            .map(|(ix, worktree)| {
 373                StringMatchCandidate::new(
 374                    ix,
 375                    &worktree.directory_name(main_worktree_path.as_deref()),
 376                )
 377            })
 378            .collect();
 379
 380        let executor = cx.background_executor().clone();
 381
 382        let task = cx.background_executor().spawn(async move {
 383            fuzzy::match_strings(
 384                &candidates,
 385                &query,
 386                true,
 387                true,
 388                10000,
 389                &Default::default(),
 390                executor,
 391            )
 392            .await
 393        });
 394
 395        let repo_worktrees_clone = repo_worktrees;
 396        cx.spawn_in(window, async move |picker, cx| {
 397            let fuzzy_matches = task.await;
 398
 399            picker
 400                .update_in(cx, |picker, _window, cx| {
 401                    let mut new_matches: Vec<ThreadWorktreeEntry> = Vec::new();
 402
 403                    for candidate in &fuzzy_matches {
 404                        new_matches.push(ThreadWorktreeEntry::Worktree {
 405                            worktree: repo_worktrees_clone[candidate.candidate_id].clone(),
 406                            positions: candidate.positions.clone(),
 407                        });
 408                    }
 409
 410                    if !new_matches.is_empty() {
 411                        new_matches.push(ThreadWorktreeEntry::Separator);
 412                    }
 413                    new_matches.push(ThreadWorktreeEntry::CreateNamed {
 414                        name: normalized_query.clone(),
 415                        from_branch: None,
 416                        disabled_reason: create_named_disabled_reason.clone(),
 417                    });
 418                    if show_default_branch_create {
 419                        if let Some(ref default_branch) = default_branch_name {
 420                            new_matches.push(ThreadWorktreeEntry::CreateNamed {
 421                                name: normalized_query.clone(),
 422                                from_branch: Some(default_branch.clone()),
 423                                disabled_reason: create_named_disabled_reason.clone(),
 424                            });
 425                        }
 426                    }
 427
 428                    picker.delegate.matches = new_matches;
 429                    picker.delegate.sync_selected_index(true);
 430
 431                    cx.notify();
 432                })
 433                .log_err();
 434        })
 435    }
 436
 437    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
 438        let Some(entry) = self.matches.get(self.selected_index) else {
 439            return;
 440        };
 441
 442        match entry {
 443            ThreadWorktreeEntry::Separator => return,
 444
 445            ThreadWorktreeEntry::CreateFromCurrentBranch => {
 446                window.dispatch_action(
 447                    Box::new(CreateWorktree {
 448                        worktree_name: None,
 449                        branch_target: NewWorktreeBranchTarget::CurrentBranch,
 450                    }),
 451                    cx,
 452                );
 453            }
 454
 455            ThreadWorktreeEntry::CreateFromDefaultBranch {
 456                default_branch_name,
 457            } => {
 458                window.dispatch_action(
 459                    Box::new(CreateWorktree {
 460                        worktree_name: None,
 461                        branch_target: NewWorktreeBranchTarget::ExistingBranch {
 462                            name: default_branch_name.clone(),
 463                        },
 464                    }),
 465                    cx,
 466                );
 467            }
 468
 469            ThreadWorktreeEntry::Worktree { worktree, .. } => {
 470                let is_current = self.project_worktree_paths.contains(&worktree.path);
 471
 472                if is_current {
 473                    // Already in this worktree — just dismiss
 474                } else {
 475                    let main_worktree_path = self
 476                        .all_worktrees
 477                        .iter()
 478                        .find(|wt| wt.is_main)
 479                        .map(|wt| wt.path.as_path());
 480                    window.dispatch_action(
 481                        Box::new(SwitchWorktree {
 482                            path: worktree.path.clone(),
 483                            display_name: worktree.directory_name(main_worktree_path),
 484                        }),
 485                        cx,
 486                    );
 487                }
 488            }
 489
 490            ThreadWorktreeEntry::CreateNamed {
 491                name,
 492                from_branch,
 493                disabled_reason: None,
 494            } => {
 495                let branch_target = match from_branch {
 496                    Some(branch) => NewWorktreeBranchTarget::ExistingBranch {
 497                        name: branch.clone(),
 498                    },
 499                    None => NewWorktreeBranchTarget::CurrentBranch,
 500                };
 501                window.dispatch_action(
 502                    Box::new(CreateWorktree {
 503                        worktree_name: Some(name.clone()),
 504                        branch_target,
 505                    }),
 506                    cx,
 507                );
 508            }
 509
 510            ThreadWorktreeEntry::CreateNamed {
 511                disabled_reason: Some(_),
 512                ..
 513            } => {
 514                return;
 515            }
 516        }
 517
 518        cx.emit(DismissEvent);
 519    }
 520
 521    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
 522
 523    fn render_match(
 524        &self,
 525        ix: usize,
 526        selected: bool,
 527        _window: &mut Window,
 528        cx: &mut Context<Picker<Self>>,
 529    ) -> Option<Self::ListItem> {
 530        let entry = self.matches.get(ix)?;
 531        let project = self.project.read(cx);
 532        let is_create_disabled = project.repositories(cx).is_empty() || project.is_via_collab();
 533
 534        let no_git_reason: SharedString = "Requires a Git repository in the project".into();
 535
 536        let create_new_list_item = |id: SharedString,
 537                                    label: SharedString,
 538                                    disabled_tooltip: Option<SharedString>,
 539                                    selected: bool| {
 540            let is_disabled = disabled_tooltip.is_some();
 541            ListItem::new(id)
 542                .inset(true)
 543                .spacing(ListItemSpacing::Sparse)
 544                .toggle_state(selected)
 545                .child(
 546                    h_flex()
 547                        .w_full()
 548                        .gap_2p5()
 549                        .child(
 550                            Icon::new(IconName::Plus)
 551                                .map(|this| {
 552                                    if is_disabled {
 553                                        this.color(Color::Disabled)
 554                                    } else {
 555                                        this.color(Color::Muted)
 556                                    }
 557                                })
 558                                .size(IconSize::Small),
 559                        )
 560                        .child(
 561                            Label::new(label).when(is_disabled, |this| this.color(Color::Disabled)),
 562                        ),
 563                )
 564                .when_some(disabled_tooltip, |this, reason| {
 565                    this.tooltip(Tooltip::text(reason))
 566                })
 567                .into_any_element()
 568        };
 569
 570        match entry {
 571            ThreadWorktreeEntry::Separator => Some(
 572                div()
 573                    .py(DynamicSpacing::Base04.rems(cx))
 574                    .child(Divider::horizontal())
 575                    .into_any_element(),
 576            ),
 577
 578            ThreadWorktreeEntry::CreateFromCurrentBranch => {
 579                let branch_label = if self.has_multiple_repositories {
 580                    "current branches".to_string()
 581                } else {
 582                    self.current_branch_name
 583                        .clone()
 584                        .unwrap_or_else(|| "HEAD".to_string())
 585                };
 586
 587                let label = format!("Create new worktree based on {branch_label}");
 588
 589                let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
 590
 591                let item = create_new_list_item(
 592                    "create-from-current".to_string().into(),
 593                    label.into(),
 594                    disabled_tooltip,
 595                    selected,
 596                );
 597
 598                Some(item.into_any_element())
 599            }
 600
 601            ThreadWorktreeEntry::CreateFromDefaultBranch {
 602                default_branch_name,
 603            } => {
 604                let label = format!("Create new worktree based on {default_branch_name}");
 605
 606                let disabled_tooltip = is_create_disabled.then(|| no_git_reason.clone());
 607
 608                let item = create_new_list_item(
 609                    "create-from-main".to_string().into(),
 610                    label.into(),
 611                    disabled_tooltip,
 612                    selected,
 613                );
 614
 615                Some(item.into_any_element())
 616            }
 617
 618            ThreadWorktreeEntry::Worktree {
 619                worktree,
 620                positions,
 621            } => {
 622                let main_worktree_path = self
 623                    .all_worktrees
 624                    .iter()
 625                    .find(|wt| wt.is_main)
 626                    .map(|wt| wt.path.as_path());
 627                let display_name = worktree.directory_name(main_worktree_path);
 628                let first_line = display_name.lines().next().unwrap_or(&display_name);
 629                let positions: Vec<_> = positions
 630                    .iter()
 631                    .copied()
 632                    .filter(|&pos| pos < first_line.len())
 633                    .collect();
 634                let path = worktree.path.compact().to_string_lossy().to_string();
 635                let sha = worktree.sha.chars().take(7).collect::<String>();
 636
 637                let is_current = self.project_worktree_paths.contains(&worktree.path);
 638
 639                let entry_icon = if is_current {
 640                    IconName::Check
 641                } else {
 642                    IconName::GitWorktree
 643                };
 644
 645                Some(
 646                    ListItem::new(SharedString::from(format!("worktree-{ix}")))
 647                        .inset(true)
 648                        .spacing(ListItemSpacing::Sparse)
 649                        .toggle_state(selected)
 650                        .child(
 651                            h_flex()
 652                                .w_full()
 653                                .gap_2p5()
 654                                .child(
 655                                    Icon::new(entry_icon)
 656                                        .color(if is_current {
 657                                            Color::Accent
 658                                        } else {
 659                                            Color::Muted
 660                                        })
 661                                        .size(IconSize::Small),
 662                                )
 663                                .child(
 664                                    v_flex()
 665                                        .w_full()
 666                                        .min_w_0()
 667                                        .child(
 668                                            HighlightedLabel::new(first_line.to_owned(), positions)
 669                                                .truncate(),
 670                                        )
 671                                        .child(
 672                                            h_flex()
 673                                                .w_full()
 674                                                .min_w_0()
 675                                                .gap_1p5()
 676                                                .when_some(
 677                                                    worktree.branch_name().map(|b| b.to_string()),
 678                                                    |this, branch| {
 679                                                        this.child(
 680                                                            Label::new(branch)
 681                                                                .size(LabelSize::Small)
 682                                                                .color(Color::Muted),
 683                                                        )
 684                                                        .child(
 685                                                            Label::new("\u{2022}")
 686                                                                .alpha(0.5)
 687                                                                .color(Color::Muted)
 688                                                                .size(LabelSize::Small),
 689                                                        )
 690                                                    },
 691                                                )
 692                                                .when(!sha.is_empty(), |this| {
 693                                                    this.child(
 694                                                        Label::new(sha)
 695                                                            .size(LabelSize::Small)
 696                                                            .color(Color::Muted),
 697                                                    )
 698                                                    .child(
 699                                                        Label::new("\u{2022}")
 700                                                            .alpha(0.5)
 701                                                            .color(Color::Muted)
 702                                                            .size(LabelSize::Small),
 703                                                    )
 704                                                })
 705                                                .child(
 706                                                    Label::new(path)
 707                                                        .truncate_start()
 708                                                        .color(Color::Muted)
 709                                                        .size(LabelSize::Small)
 710                                                        .flex_1(),
 711                                                ),
 712                                        ),
 713                                ),
 714                        )
 715                        .into_any_element(),
 716                )
 717            }
 718
 719            ThreadWorktreeEntry::CreateNamed {
 720                name,
 721                from_branch,
 722                disabled_reason,
 723            } => {
 724                let branch_label = from_branch
 725                    .as_deref()
 726                    .unwrap_or(self.current_branch_name.as_deref().unwrap_or("HEAD"));
 727                let label = format!("Create \"{name}\" based on {branch_label}");
 728                let element_id = match from_branch {
 729                    Some(branch) => format!("create-named-from-{branch}"),
 730                    None => "create-named-from-current".to_string(),
 731                };
 732
 733                let item = create_new_list_item(
 734                    element_id.into(),
 735                    label.into(),
 736                    disabled_reason.clone().map(SharedString::from),
 737                    selected,
 738                );
 739
 740                Some(item.into_any_element())
 741            }
 742        }
 743    }
 744}
 745
 746#[cfg(test)]
 747mod tests {
 748    use super::*;
 749    use fs::FakeFs;
 750    use gpui::TestAppContext;
 751    use project::Project;
 752    use settings::SettingsStore;
 753
 754    fn init_test(cx: &mut TestAppContext) {
 755        cx.update(|cx| {
 756            let settings_store = SettingsStore::test(cx);
 757            cx.set_global(settings_store);
 758            theme_settings::init(theme::LoadThemes::JustBase, cx);
 759            editor::init(cx);
 760            release_channel::init("0.0.0".parse().unwrap(), cx);
 761            crate::agent_panel::init(cx);
 762        });
 763    }
 764
 765    fn make_worktree(path: &str, branch: &str, is_main: bool) -> GitWorktree {
 766        GitWorktree {
 767            path: PathBuf::from(path),
 768            ref_name: Some(format!("refs/heads/{branch}").into()),
 769            sha: "abc1234".into(),
 770            is_main,
 771            is_bare: false,
 772        }
 773    }
 774
 775    fn build_delegate(
 776        project: Entity<Project>,
 777        all_worktrees: Vec<GitWorktree>,
 778        project_worktree_paths: HashSet<PathBuf>,
 779        current_branch_name: Option<String>,
 780        default_branch_name: Option<String>,
 781        has_multiple_repositories: bool,
 782    ) -> ThreadWorktreePickerDelegate {
 783        ThreadWorktreePickerDelegate {
 784            matches: vec![ThreadWorktreeEntry::CreateFromCurrentBranch],
 785            all_worktrees,
 786            project_worktree_paths,
 787            selected_index: 0,
 788            project,
 789            current_branch_name,
 790            default_branch_name,
 791            has_multiple_repositories,
 792        }
 793    }
 794
 795    fn entry_names(delegate: &ThreadWorktreePickerDelegate) -> Vec<String> {
 796        delegate
 797            .matches
 798            .iter()
 799            .map(|entry| match entry {
 800                ThreadWorktreeEntry::CreateFromCurrentBranch => {
 801                    "CreateFromCurrentBranch".to_string()
 802                }
 803                ThreadWorktreeEntry::CreateFromDefaultBranch {
 804                    default_branch_name,
 805                } => format!("CreateFromDefaultBranch({default_branch_name})"),
 806                ThreadWorktreeEntry::Separator => "---".to_string(),
 807                ThreadWorktreeEntry::Worktree { worktree, .. } => {
 808                    format!("Worktree({})", worktree.path.display())
 809                }
 810                ThreadWorktreeEntry::CreateNamed {
 811                    name,
 812                    from_branch,
 813                    disabled_reason,
 814                } => {
 815                    let branch = from_branch
 816                        .as_deref()
 817                        .map(|b| format!("from {b}"))
 818                        .unwrap_or_else(|| "from current".to_string());
 819                    if disabled_reason.is_some() {
 820                        format!("CreateNamed({name}, {branch}, disabled)")
 821                    } else {
 822                        format!("CreateNamed({name}, {branch})")
 823                    }
 824                }
 825            })
 826            .collect()
 827    }
 828
 829    type PickerWindow = gpui::WindowHandle<Picker<ThreadWorktreePickerDelegate>>;
 830
 831    async fn make_picker(
 832        cx: &mut TestAppContext,
 833        all_worktrees: Vec<GitWorktree>,
 834        project_worktree_paths: HashSet<PathBuf>,
 835        current_branch_name: Option<String>,
 836        default_branch_name: Option<String>,
 837        has_multiple_repositories: bool,
 838    ) -> PickerWindow {
 839        let fs = FakeFs::new(cx.executor());
 840        let project = Project::test(fs, [], cx).await;
 841
 842        cx.add_window(|window, cx| {
 843            let delegate = build_delegate(
 844                project,
 845                all_worktrees,
 846                project_worktree_paths,
 847                current_branch_name,
 848                default_branch_name,
 849                has_multiple_repositories,
 850            );
 851            Picker::list(delegate, window, cx)
 852                .list_measure_all()
 853                .modal(false)
 854        })
 855    }
 856
 857    #[gpui::test]
 858    async fn test_empty_query_entries(cx: &mut TestAppContext) {
 859        init_test(cx);
 860
 861        // When on `main` with default branch also `main`, only CreateFromCurrentBranch
 862        // is shown as a fixed entry. Worktrees are listed with the current one first.
 863        let worktrees = vec![
 864            make_worktree("/repo", "main", true),
 865            make_worktree("/repo-feature", "feature", false),
 866            make_worktree("/repo-bugfix", "bugfix", false),
 867        ];
 868        let project_paths: HashSet<PathBuf> = [PathBuf::from("/repo")].into_iter().collect();
 869
 870        let picker = make_picker(
 871            cx,
 872            worktrees,
 873            project_paths,
 874            Some("main".into()),
 875            Some("main".into()),
 876            false,
 877        )
 878        .await;
 879
 880        picker
 881            .update(cx, |picker, window, cx| picker.refresh(window, cx))
 882            .unwrap();
 883        cx.run_until_parked();
 884
 885        let names = picker
 886            .read_with(cx, |picker, _| entry_names(&picker.delegate))
 887            .unwrap();
 888
 889        assert_eq!(
 890            names,
 891            vec![
 892                "CreateFromCurrentBranch",
 893                "---",
 894                "Worktree(/repo)",
 895                "Worktree(/repo-bugfix)",
 896                "Worktree(/repo-feature)",
 897            ]
 898        );
 899
 900        // When current branch differs from default, CreateFromDefaultBranch appears.
 901        picker
 902            .update(cx, |picker, _window, cx| {
 903                picker.delegate.current_branch_name = Some("feature".into());
 904                picker.delegate.default_branch_name = Some("main".into());
 905                cx.notify();
 906            })
 907            .unwrap();
 908        picker
 909            .update(cx, |picker, window, cx| picker.refresh(window, cx))
 910            .unwrap();
 911        cx.run_until_parked();
 912
 913        let names = picker
 914            .read_with(cx, |picker, _| entry_names(&picker.delegate))
 915            .unwrap();
 916
 917        assert!(names.contains(&"CreateFromDefaultBranch(main)".to_string()));
 918    }
 919
 920    #[gpui::test]
 921    async fn test_query_filtering_and_create_entries(cx: &mut TestAppContext) {
 922        init_test(cx);
 923
 924        let picker = make_picker(
 925            cx,
 926            vec![
 927                make_worktree("/repo", "main", true),
 928                make_worktree("/repo-feature", "feature", false),
 929                make_worktree("/repo-bugfix", "bugfix", false),
 930                make_worktree("/my-worktree", "experiment", false),
 931            ],
 932            HashSet::default(),
 933            Some("dev".into()),
 934            Some("main".into()),
 935            false,
 936        )
 937        .await;
 938
 939        // Partial match filters to matching worktrees and offers to create
 940        // from both current branch and default branch.
 941        picker
 942            .update(cx, |picker, window, cx| {
 943                picker.set_query("feat", window, cx)
 944            })
 945            .unwrap();
 946        cx.run_until_parked();
 947
 948        let names = picker
 949            .read_with(cx, |picker, _| entry_names(&picker.delegate))
 950            .unwrap();
 951        assert!(names.contains(&"Worktree(/repo-feature)".to_string()));
 952        assert!(
 953            names.contains(&"CreateNamed(feat, from current)".to_string()),
 954            "should offer to create from current branch, got: {names:?}"
 955        );
 956        assert!(
 957            names.contains(&"CreateNamed(feat, from main)".to_string()),
 958            "should offer to create from default branch, got: {names:?}"
 959        );
 960        assert!(!names.contains(&"Worktree(/repo-bugfix)".to_string()));
 961
 962        // Exact match: both create entries appear but are disabled.
 963        picker
 964            .update(cx, |picker, window, cx| {
 965                picker.set_query("repo-feature", window, cx)
 966            })
 967            .unwrap();
 968        cx.run_until_parked();
 969
 970        let names = picker
 971            .read_with(cx, |picker, _| entry_names(&picker.delegate))
 972            .unwrap();
 973        assert!(
 974            names.contains(&"CreateNamed(repo-feature, from current, disabled)".to_string()),
 975            "exact name match should show disabled create entries, got: {names:?}"
 976        );
 977
 978        // Spaces are normalized to hyphens: "my worktree" matches "my-worktree".
 979        picker
 980            .update(cx, |picker, window, cx| {
 981                picker.set_query("my worktree", window, cx)
 982            })
 983            .unwrap();
 984        cx.run_until_parked();
 985
 986        let names = picker
 987            .read_with(cx, |picker, _| entry_names(&picker.delegate))
 988            .unwrap();
 989        assert!(
 990            names.contains(&"CreateNamed(my-worktree, from current, disabled)".to_string()),
 991            "spaces should normalize to hyphens and detect existing worktree, got: {names:?}"
 992        );
 993    }
 994
 995    #[gpui::test]
 996    async fn test_multi_repo_hides_worktrees_and_disables_create_named(cx: &mut TestAppContext) {
 997        init_test(cx);
 998
 999        let picker = make_picker(
1000            cx,
1001            vec![
1002                make_worktree("/repo", "main", true),
1003                make_worktree("/repo-feature", "feature", false),
1004            ],
1005            HashSet::default(),
1006            Some("main".into()),
1007            Some("main".into()),
1008            true,
1009        )
1010        .await;
1011
1012        picker
1013            .update(cx, |picker, window, cx| picker.refresh(window, cx))
1014            .unwrap();
1015        cx.run_until_parked();
1016
1017        let names = picker
1018            .read_with(cx, |picker, _| entry_names(&picker.delegate))
1019            .unwrap();
1020        assert_eq!(names, vec!["CreateFromCurrentBranch"]);
1021
1022        picker
1023            .update(cx, |picker, window, cx| {
1024                picker.set_query("new-thing", window, cx)
1025            })
1026            .unwrap();
1027        cx.run_until_parked();
1028
1029        let names = picker
1030            .read_with(cx, |picker, _| entry_names(&picker.delegate))
1031            .unwrap();
1032        assert!(
1033            names.contains(&"CreateNamed(new-thing, from current, disabled)".to_string()),
1034            "multi-repo should disable create named, got: {names:?}"
1035        );
1036    }
1037}