thread_worktree_picker.rs

  1use std::path::PathBuf;
  2use std::rc::Rc;
  3use std::sync::Arc;
  4
  5use agent_settings::AgentSettings;
  6use fs::Fs;
  7use fuzzy::StringMatchCandidate;
  8use git::repository::Worktree as GitWorktree;
  9use gpui::{
 10    AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
 11    IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, rems,
 12};
 13use picker::{Picker, PickerDelegate, PickerEditorPosition};
 14use project::{Project, git_store::RepositoryId};
 15use settings::{NewThreadLocation, Settings, update_settings_file};
 16use ui::{
 17    Divider, DocumentationAside, HighlightedLabel, Label, LabelCommon, ListItem, ListItemSpacing,
 18    Tooltip, prelude::*,
 19};
 20use util::ResultExt as _;
 21use util::paths::PathExt;
 22
 23use crate::ui::HoldForDefault;
 24use crate::{NewWorktreeBranchTarget, StartThreadIn};
 25
 26pub(crate) struct ThreadWorktreePicker {
 27    picker: Entity<Picker<ThreadWorktreePickerDelegate>>,
 28    focus_handle: FocusHandle,
 29    _subscription: gpui::Subscription,
 30}
 31
 32impl ThreadWorktreePicker {
 33    pub fn new(
 34        project: Entity<Project>,
 35        current_target: &StartThreadIn,
 36        fs: Arc<dyn Fs>,
 37        window: &mut Window,
 38        cx: &mut Context<Self>,
 39    ) -> Self {
 40        let project_worktree_paths: Vec<PathBuf> = project
 41            .read(cx)
 42            .visible_worktrees(cx)
 43            .map(|wt| wt.read(cx).abs_path().to_path_buf())
 44            .collect();
 45
 46        let preserved_branch_target = match current_target {
 47            StartThreadIn::NewWorktree { branch_target, .. } => branch_target.clone(),
 48            _ => NewWorktreeBranchTarget::default(),
 49        };
 50
 51        let all_worktrees: Vec<_> = project
 52            .read(cx)
 53            .repositories(cx)
 54            .iter()
 55            .map(|(repo_id, repo)| (*repo_id, repo.read(cx).linked_worktrees.clone()))
 56            .collect();
 57
 58        let has_multiple_repositories = all_worktrees.len() > 1;
 59
 60        let linked_worktrees: Vec<_> = if has_multiple_repositories {
 61            Vec::new()
 62        } else {
 63            all_worktrees
 64                .iter()
 65                .flat_map(|(_, worktrees)| worktrees.iter())
 66                .filter(|worktree| {
 67                    !project_worktree_paths
 68                        .iter()
 69                        .any(|project_path| project_path == &worktree.path)
 70                })
 71                .cloned()
 72                .collect()
 73        };
 74
 75        let mut initial_matches = vec![
 76            ThreadWorktreeEntry::CurrentWorktree,
 77            ThreadWorktreeEntry::NewWorktree,
 78        ];
 79
 80        if !linked_worktrees.is_empty() {
 81            initial_matches.push(ThreadWorktreeEntry::Separator);
 82            for worktree in &linked_worktrees {
 83                initial_matches.push(ThreadWorktreeEntry::LinkedWorktree {
 84                    worktree: worktree.clone(),
 85                    positions: Vec::new(),
 86                });
 87            }
 88        }
 89
 90        let selected_index = match current_target {
 91            StartThreadIn::LocalProject => 0,
 92            StartThreadIn::NewWorktree { .. } => 1,
 93            StartThreadIn::LinkedWorktree { path, .. } => initial_matches
 94                .iter()
 95                .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { worktree, .. } if worktree.path == *path))
 96                .unwrap_or(0),
 97        };
 98
 99        let delegate = ThreadWorktreePickerDelegate {
100            matches: initial_matches,
101            all_worktrees,
102            project_worktree_paths,
103            selected_index,
104            project,
105            preserved_branch_target,
106            fs,
107        };
108
109        let picker = cx.new(|cx| {
110            Picker::list(delegate, window, cx)
111                .list_measure_all()
112                .modal(false)
113                .max_height(Some(rems(20.).into()))
114        });
115
116        let subscription = cx.subscribe(&picker, |_, _, _, cx| {
117            cx.emit(DismissEvent);
118        });
119
120        Self {
121            focus_handle: picker.focus_handle(cx),
122            picker,
123            _subscription: subscription,
124        }
125    }
126}
127
128impl Focusable for ThreadWorktreePicker {
129    fn focus_handle(&self, _cx: &App) -> FocusHandle {
130        self.focus_handle.clone()
131    }
132}
133
134impl EventEmitter<DismissEvent> for ThreadWorktreePicker {}
135
136impl Render for ThreadWorktreePicker {
137    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
138        v_flex()
139            .w(rems(20.))
140            .elevation_3(cx)
141            .child(self.picker.clone())
142            .on_mouse_down_out(cx.listener(|_, _, _, cx| {
143                cx.emit(DismissEvent);
144            }))
145    }
146}
147
148#[derive(Clone)]
149enum ThreadWorktreeEntry {
150    CurrentWorktree,
151    NewWorktree,
152    Separator,
153    LinkedWorktree {
154        worktree: GitWorktree,
155        positions: Vec<usize>,
156    },
157    CreateNamed {
158        name: String,
159        disabled_reason: Option<String>,
160    },
161}
162
163pub(crate) struct ThreadWorktreePickerDelegate {
164    matches: Vec<ThreadWorktreeEntry>,
165    all_worktrees: Vec<(RepositoryId, Arc<[GitWorktree]>)>,
166    project_worktree_paths: Vec<PathBuf>,
167    selected_index: usize,
168    preserved_branch_target: NewWorktreeBranchTarget,
169    project: Entity<Project>,
170    fs: Arc<dyn Fs>,
171}
172
173impl ThreadWorktreePickerDelegate {
174    fn new_worktree_action(&self, worktree_name: Option<String>) -> StartThreadIn {
175        StartThreadIn::NewWorktree {
176            worktree_name,
177            branch_target: self.preserved_branch_target.clone(),
178        }
179    }
180
181    fn sync_selected_index(&mut self, has_query: bool) {
182        if !has_query {
183            return;
184        }
185
186        if let Some(index) = self
187            .matches
188            .iter()
189            .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { .. }))
190        {
191            self.selected_index = index;
192        } else if let Some(index) = self
193            .matches
194            .iter()
195            .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. }))
196        {
197            self.selected_index = index;
198        } else {
199            self.selected_index = 0;
200        }
201    }
202}
203
204impl PickerDelegate for ThreadWorktreePickerDelegate {
205    type ListItem = AnyElement;
206
207    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
208        "Search or create worktrees…".into()
209    }
210
211    fn editor_position(&self) -> PickerEditorPosition {
212        PickerEditorPosition::Start
213    }
214
215    fn match_count(&self) -> usize {
216        self.matches.len()
217    }
218
219    fn selected_index(&self) -> usize {
220        self.selected_index
221    }
222
223    fn set_selected_index(
224        &mut self,
225        ix: usize,
226        _window: &mut Window,
227        _cx: &mut Context<Picker<Self>>,
228    ) {
229        self.selected_index = ix;
230    }
231
232    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
233        !matches!(self.matches.get(ix), Some(ThreadWorktreeEntry::Separator))
234    }
235
236    fn update_matches(
237        &mut self,
238        query: String,
239        window: &mut Window,
240        cx: &mut Context<Picker<Self>>,
241    ) -> Task<()> {
242        let has_multiple_repositories = self.all_worktrees.len() > 1;
243
244        let linked_worktrees: Vec<_> = if has_multiple_repositories {
245            Vec::new()
246        } else {
247            self.all_worktrees
248                .iter()
249                .flat_map(|(_, worktrees)| worktrees.iter())
250                .filter(|worktree| {
251                    !self
252                        .project_worktree_paths
253                        .iter()
254                        .any(|project_path| project_path == &worktree.path)
255                })
256                .cloned()
257                .collect()
258        };
259
260        let normalized_query = query.replace(' ', "-");
261        let has_named_worktree = self.all_worktrees.iter().any(|(_, worktrees)| {
262            worktrees
263                .iter()
264                .any(|worktree| worktree.display_name() == normalized_query)
265        });
266        let create_named_disabled_reason = if has_multiple_repositories {
267            Some("Cannot create a named worktree in a project with multiple repositories".into())
268        } else if has_named_worktree {
269            Some("A worktree with this name already exists".into())
270        } else {
271            None
272        };
273
274        let mut matches = vec![
275            ThreadWorktreeEntry::CurrentWorktree,
276            ThreadWorktreeEntry::NewWorktree,
277        ];
278
279        if query.is_empty() {
280            if !linked_worktrees.is_empty() {
281                matches.push(ThreadWorktreeEntry::Separator);
282            }
283            for worktree in &linked_worktrees {
284                matches.push(ThreadWorktreeEntry::LinkedWorktree {
285                    worktree: worktree.clone(),
286                    positions: Vec::new(),
287                });
288            }
289        } else if linked_worktrees.is_empty() {
290            matches.push(ThreadWorktreeEntry::Separator);
291            matches.push(ThreadWorktreeEntry::CreateNamed {
292                name: normalized_query,
293                disabled_reason: create_named_disabled_reason,
294            });
295        } else {
296            let candidates: Vec<_> = linked_worktrees
297                .iter()
298                .enumerate()
299                .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.display_name()))
300                .collect();
301
302            let executor = cx.background_executor().clone();
303            let query_clone = query.clone();
304
305            let task = cx.background_executor().spawn(async move {
306                fuzzy::match_strings(
307                    &candidates,
308                    &query_clone,
309                    true,
310                    true,
311                    10000,
312                    &Default::default(),
313                    executor,
314                )
315                .await
316            });
317
318            let linked_worktrees_clone = linked_worktrees;
319            return cx.spawn_in(window, async move |picker, cx| {
320                let fuzzy_matches = task.await;
321
322                picker
323                    .update_in(cx, |picker, _window, cx| {
324                        let mut new_matches = vec![
325                            ThreadWorktreeEntry::CurrentWorktree,
326                            ThreadWorktreeEntry::NewWorktree,
327                        ];
328
329                        let has_extra_entries = !fuzzy_matches.is_empty();
330
331                        if has_extra_entries {
332                            new_matches.push(ThreadWorktreeEntry::Separator);
333                        }
334
335                        for candidate in &fuzzy_matches {
336                            new_matches.push(ThreadWorktreeEntry::LinkedWorktree {
337                                worktree: linked_worktrees_clone[candidate.candidate_id].clone(),
338                                positions: candidate.positions.clone(),
339                            });
340                        }
341
342                        let has_exact_match = linked_worktrees_clone
343                            .iter()
344                            .any(|worktree| worktree.display_name() == query);
345
346                        if !has_exact_match {
347                            if !has_extra_entries {
348                                new_matches.push(ThreadWorktreeEntry::Separator);
349                            }
350                            new_matches.push(ThreadWorktreeEntry::CreateNamed {
351                                name: normalized_query.clone(),
352                                disabled_reason: create_named_disabled_reason.clone(),
353                            });
354                        }
355
356                        picker.delegate.matches = new_matches;
357                        picker.delegate.sync_selected_index(true);
358
359                        cx.notify();
360                    })
361                    .log_err();
362            });
363        }
364
365        self.matches = matches;
366        self.sync_selected_index(!query.is_empty());
367
368        Task::ready(())
369    }
370
371    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
372        let Some(entry) = self.matches.get(self.selected_index) else {
373            return;
374        };
375
376        match entry {
377            ThreadWorktreeEntry::Separator => return,
378            ThreadWorktreeEntry::CurrentWorktree => {
379                if secondary {
380                    update_settings_file(self.fs.clone(), cx, |settings, _| {
381                        settings
382                            .agent
383                            .get_or_insert_default()
384                            .set_new_thread_location(NewThreadLocation::LocalProject);
385                    });
386                }
387                window.dispatch_action(Box::new(StartThreadIn::LocalProject), cx);
388            }
389            ThreadWorktreeEntry::NewWorktree => {
390                if secondary {
391                    update_settings_file(self.fs.clone(), cx, |settings, _| {
392                        settings
393                            .agent
394                            .get_or_insert_default()
395                            .set_new_thread_location(NewThreadLocation::NewWorktree);
396                    });
397                }
398                window.dispatch_action(Box::new(self.new_worktree_action(None)), cx);
399            }
400            ThreadWorktreeEntry::LinkedWorktree { worktree, .. } => {
401                window.dispatch_action(
402                    Box::new(StartThreadIn::LinkedWorktree {
403                        path: worktree.path.clone(),
404                        display_name: worktree.display_name().to_string(),
405                    }),
406                    cx,
407                );
408            }
409            ThreadWorktreeEntry::CreateNamed {
410                name,
411                disabled_reason: None,
412            } => {
413                window.dispatch_action(Box::new(self.new_worktree_action(Some(name.clone()))), cx);
414            }
415            ThreadWorktreeEntry::CreateNamed {
416                disabled_reason: Some(_),
417                ..
418            } => {
419                return;
420            }
421        }
422
423        cx.emit(DismissEvent);
424    }
425
426    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
427
428    fn render_match(
429        &self,
430        ix: usize,
431        selected: bool,
432        _window: &mut Window,
433        cx: &mut Context<Picker<Self>>,
434    ) -> Option<Self::ListItem> {
435        let entry = self.matches.get(ix)?;
436        let project = self.project.read(cx);
437        let is_new_worktree_disabled =
438            project.repositories(cx).is_empty() || project.is_via_collab();
439
440        match entry {
441            ThreadWorktreeEntry::Separator => Some(
442                div()
443                    .py(DynamicSpacing::Base04.rems(cx))
444                    .child(Divider::horizontal())
445                    .into_any_element(),
446            ),
447            ThreadWorktreeEntry::CurrentWorktree => {
448                let path_label = project.active_repository(cx).map(|repo| {
449                    let path = repo.read(cx).work_directory_abs_path.clone();
450                    path.compact().to_string_lossy().to_string()
451                });
452
453                Some(
454                    ListItem::new("current-worktree")
455                        .inset(true)
456                        .spacing(ListItemSpacing::Sparse)
457                        .toggle_state(selected)
458                        .child(
459                            v_flex()
460                                .min_w_0()
461                                .overflow_hidden()
462                                .child(Label::new("Current Worktree"))
463                                .when_some(path_label, |this, path| {
464                                    this.child(
465                                        Label::new(path)
466                                            .size(LabelSize::Small)
467                                            .color(Color::Muted)
468                                            .truncate_start(),
469                                    )
470                                }),
471                        )
472                        .into_any_element(),
473                )
474            }
475            ThreadWorktreeEntry::NewWorktree => {
476                let item = ListItem::new("new-worktree")
477                    .inset(true)
478                    .spacing(ListItemSpacing::Sparse)
479                    .toggle_state(selected)
480                    .disabled(is_new_worktree_disabled)
481                    .child(
482                        v_flex()
483                            .min_w_0()
484                            .overflow_hidden()
485                            .child(
486                                Label::new("New Git Worktree")
487                                    .when(is_new_worktree_disabled, |this| {
488                                        this.color(Color::Disabled)
489                                    }),
490                            )
491                            .child(
492                                Label::new("Get a fresh new worktree")
493                                    .size(LabelSize::Small)
494                                    .color(Color::Muted),
495                            ),
496                    );
497
498                Some(
499                    if is_new_worktree_disabled {
500                        item.tooltip(Tooltip::text("Requires a Git repository in the project"))
501                    } else {
502                        item
503                    }
504                    .into_any_element(),
505                )
506            }
507            ThreadWorktreeEntry::LinkedWorktree {
508                worktree,
509                positions,
510            } => {
511                let display_name = worktree.display_name();
512                let first_line = display_name.lines().next().unwrap_or(display_name);
513                let positions: Vec<_> = positions
514                    .iter()
515                    .copied()
516                    .filter(|&pos| pos < first_line.len())
517                    .collect();
518                let path = worktree.path.compact();
519
520                Some(
521                    ListItem::new(SharedString::from(format!("linked-worktree-{ix}")))
522                        .inset(true)
523                        .spacing(ListItemSpacing::Sparse)
524                        .toggle_state(selected)
525                        .child(
526                            v_flex()
527                                .min_w_0()
528                                .overflow_hidden()
529                                .child(
530                                    HighlightedLabel::new(first_line.to_owned(), positions)
531                                        .truncate(),
532                                )
533                                .child(
534                                    Label::new(path.to_string_lossy().to_string())
535                                        .size(LabelSize::Small)
536                                        .color(Color::Muted)
537                                        .truncate_start(),
538                                ),
539                        )
540                        .into_any_element(),
541                )
542            }
543            ThreadWorktreeEntry::CreateNamed {
544                name,
545                disabled_reason,
546            } => {
547                let is_disabled = disabled_reason.is_some();
548                let item = ListItem::new("create-named-worktree")
549                    .inset(true)
550                    .spacing(ListItemSpacing::Sparse)
551                    .toggle_state(selected)
552                    .disabled(is_disabled)
553                    .child(Label::new(format!("Create Worktree: \"{name}\"")).color(
554                        if is_disabled {
555                            Color::Disabled
556                        } else {
557                            Color::Default
558                        },
559                    ));
560
561                Some(
562                    if let Some(reason) = disabled_reason.clone() {
563                        item.tooltip(Tooltip::text(reason))
564                    } else {
565                        item
566                    }
567                    .into_any_element(),
568                )
569            }
570        }
571    }
572
573    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
574        None
575    }
576
577    fn documentation_aside(
578        &self,
579        _window: &mut Window,
580        cx: &mut Context<Picker<Self>>,
581    ) -> Option<DocumentationAside> {
582        let entry = self.matches.get(self.selected_index)?;
583        let is_default = match entry {
584            ThreadWorktreeEntry::CurrentWorktree => {
585                let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
586                Some(new_thread_location == NewThreadLocation::LocalProject)
587            }
588            ThreadWorktreeEntry::NewWorktree => {
589                let project = self.project.read(cx);
590                let is_disabled = project.repositories(cx).is_empty() || project.is_via_collab();
591                if is_disabled {
592                    None
593                } else {
594                    let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
595                    Some(new_thread_location == NewThreadLocation::NewWorktree)
596                }
597            }
598            _ => None,
599        }?;
600
601        let side = crate::ui::documentation_aside_side(cx);
602
603        Some(DocumentationAside::new(
604            side,
605            Rc::new(move |_| {
606                HoldForDefault::new(is_default)
607                    .more_content(false)
608                    .into_any_element()
609            }),
610        ))
611    }
612
613    fn documentation_aside_index(&self) -> Option<usize> {
614        match self.matches.get(self.selected_index) {
615            Some(ThreadWorktreeEntry::CurrentWorktree | ThreadWorktreeEntry::NewWorktree) => {
616                Some(self.selected_index)
617            }
618            _ => None,
619        }
620    }
621}