thread_worktree_picker.rs

  1use std::path::PathBuf;
  2use std::sync::Arc;
  3
  4use agent_settings::AgentSettings;
  5use fs::Fs;
  6use fuzzy::StringMatchCandidate;
  7use git::repository::Worktree as GitWorktree;
  8use gpui::{
  9    App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
 10    ParentElement, Render, SharedString, Styled, Task, Window, rems,
 11};
 12use picker::{Picker, PickerDelegate, PickerEditorPosition};
 13use project::{Project, git_store::RepositoryId};
 14use settings::{NewThreadLocation, Settings, update_settings_file};
 15use ui::{
 16    HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, ListItemSpacing, Tooltip,
 17    prelude::*,
 18};
 19use util::ResultExt as _;
 20
 21use crate::ui::HoldForDefault;
 22use crate::{NewWorktreeBranchTarget, StartThreadIn};
 23
 24pub(crate) struct ThreadWorktreePicker {
 25    picker: Entity<Picker<ThreadWorktreePickerDelegate>>,
 26    focus_handle: FocusHandle,
 27    _subscription: gpui::Subscription,
 28}
 29
 30impl ThreadWorktreePicker {
 31    pub fn new(
 32        project: Entity<Project>,
 33        current_target: &StartThreadIn,
 34        fs: Arc<dyn Fs>,
 35        window: &mut Window,
 36        cx: &mut Context<Self>,
 37    ) -> Self {
 38        let project_worktree_paths: Vec<PathBuf> = project
 39            .read(cx)
 40            .visible_worktrees(cx)
 41            .map(|wt| wt.read(cx).abs_path().to_path_buf())
 42            .collect();
 43
 44        let preserved_branch_target = match current_target {
 45            StartThreadIn::NewWorktree { branch_target, .. } => branch_target.clone(),
 46            _ => NewWorktreeBranchTarget::default(),
 47        };
 48
 49        let delegate = ThreadWorktreePickerDelegate {
 50            matches: vec![
 51                ThreadWorktreeEntry::CurrentWorktree,
 52                ThreadWorktreeEntry::NewWorktree,
 53            ],
 54            all_worktrees: project
 55                .read(cx)
 56                .repositories(cx)
 57                .iter()
 58                .map(|(repo_id, repo)| (*repo_id, repo.read(cx).linked_worktrees.clone()))
 59                .collect(),
 60            project_worktree_paths,
 61            selected_index: match current_target {
 62                StartThreadIn::LocalProject => 0,
 63                StartThreadIn::NewWorktree { .. } => 1,
 64                _ => 0,
 65            },
 66            project: project.clone(),
 67            preserved_branch_target,
 68            fs,
 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 subscription = cx.subscribe(&picker, |_, _, _, cx| {
 79            cx.emit(DismissEvent);
 80        });
 81
 82        Self {
 83            focus_handle: picker.focus_handle(cx),
 84            picker,
 85            _subscription: subscription,
 86        }
 87    }
 88}
 89
 90impl Focusable for ThreadWorktreePicker {
 91    fn focus_handle(&self, _cx: &App) -> FocusHandle {
 92        self.focus_handle.clone()
 93    }
 94}
 95
 96impl EventEmitter<DismissEvent> for ThreadWorktreePicker {}
 97
 98impl Render for ThreadWorktreePicker {
 99    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
100        v_flex()
101            .w(rems(20.))
102            .elevation_3(cx)
103            .child(self.picker.clone())
104            .on_mouse_down_out(cx.listener(|_, _, _, cx| {
105                cx.emit(DismissEvent);
106            }))
107    }
108}
109
110#[derive(Clone)]
111enum ThreadWorktreeEntry {
112    CurrentWorktree,
113    NewWorktree,
114    LinkedWorktree {
115        worktree: GitWorktree,
116        positions: Vec<usize>,
117    },
118    CreateNamed {
119        name: String,
120        disabled_reason: Option<String>,
121    },
122}
123
124pub(crate) struct ThreadWorktreePickerDelegate {
125    matches: Vec<ThreadWorktreeEntry>,
126    all_worktrees: Vec<(RepositoryId, Arc<[GitWorktree]>)>,
127    project_worktree_paths: Vec<PathBuf>,
128    selected_index: usize,
129    preserved_branch_target: NewWorktreeBranchTarget,
130    project: Entity<Project>,
131    fs: Arc<dyn Fs>,
132}
133
134impl ThreadWorktreePickerDelegate {
135    fn new_worktree_action(&self, worktree_name: Option<String>) -> StartThreadIn {
136        StartThreadIn::NewWorktree {
137            worktree_name,
138            branch_target: self.preserved_branch_target.clone(),
139        }
140    }
141
142    fn sync_selected_index(&mut self) {
143        if let Some(index) = self
144            .matches
145            .iter()
146            .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { .. }))
147        {
148            self.selected_index = index;
149        } else if let Some(index) = self
150            .matches
151            .iter()
152            .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. }))
153        {
154            self.selected_index = index;
155        } else {
156            self.selected_index = 0;
157        }
158    }
159}
160
161impl PickerDelegate for ThreadWorktreePickerDelegate {
162    type ListItem = ListItem;
163
164    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
165        "Search or create worktrees…".into()
166    }
167
168    fn editor_position(&self) -> PickerEditorPosition {
169        PickerEditorPosition::Start
170    }
171
172    fn match_count(&self) -> usize {
173        self.matches.len()
174    }
175
176    fn selected_index(&self) -> usize {
177        self.selected_index
178    }
179
180    fn set_selected_index(
181        &mut self,
182        ix: usize,
183        _window: &mut Window,
184        _cx: &mut Context<Picker<Self>>,
185    ) {
186        self.selected_index = ix;
187    }
188
189    fn separators_after_indices(&self) -> Vec<usize> {
190        if self.matches.len() > 2 {
191            vec![1]
192        } else {
193            Vec::new()
194        }
195    }
196
197    fn update_matches(
198        &mut self,
199        query: String,
200        window: &mut Window,
201        cx: &mut Context<Picker<Self>>,
202    ) -> Task<()> {
203        let has_multiple_repositories = self.all_worktrees.len() > 1;
204
205        let linked_worktrees: Vec<_> = if has_multiple_repositories {
206            Vec::new()
207        } else {
208            self.all_worktrees
209                .iter()
210                .flat_map(|(_, worktrees)| worktrees.iter())
211                .filter(|worktree| {
212                    !self
213                        .project_worktree_paths
214                        .iter()
215                        .any(|project_path| project_path == &worktree.path)
216                })
217                .cloned()
218                .collect()
219        };
220
221        let normalized_query = query.replace(' ', "-");
222        let has_named_worktree = self.all_worktrees.iter().any(|(_, worktrees)| {
223            worktrees
224                .iter()
225                .any(|worktree| worktree.display_name() == normalized_query)
226        });
227        let create_named_disabled_reason = if has_multiple_repositories {
228            Some("Cannot create a named worktree in a project with multiple repositories".into())
229        } else if has_named_worktree {
230            Some("A worktree with this name already exists".into())
231        } else {
232            None
233        };
234
235        let mut matches = vec![
236            ThreadWorktreeEntry::CurrentWorktree,
237            ThreadWorktreeEntry::NewWorktree,
238        ];
239
240        if query.is_empty() {
241            for worktree in &linked_worktrees {
242                matches.push(ThreadWorktreeEntry::LinkedWorktree {
243                    worktree: worktree.clone(),
244                    positions: Vec::new(),
245                });
246            }
247        } else if linked_worktrees.is_empty() {
248            matches.push(ThreadWorktreeEntry::CreateNamed {
249                name: normalized_query,
250                disabled_reason: create_named_disabled_reason,
251            });
252        } else {
253            let candidates: Vec<_> = linked_worktrees
254                .iter()
255                .enumerate()
256                .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.display_name()))
257                .collect();
258
259            let executor = cx.background_executor().clone();
260            let query_clone = query.clone();
261
262            let task = cx.background_executor().spawn(async move {
263                fuzzy::match_strings(
264                    &candidates,
265                    &query_clone,
266                    true,
267                    true,
268                    10000,
269                    &Default::default(),
270                    executor,
271                )
272                .await
273            });
274
275            let linked_worktrees_clone = linked_worktrees;
276            return cx.spawn_in(window, async move |picker, cx| {
277                let fuzzy_matches = task.await;
278
279                picker
280                    .update_in(cx, |picker, _window, cx| {
281                        let mut new_matches = vec![
282                            ThreadWorktreeEntry::CurrentWorktree,
283                            ThreadWorktreeEntry::NewWorktree,
284                        ];
285
286                        for candidate in &fuzzy_matches {
287                            new_matches.push(ThreadWorktreeEntry::LinkedWorktree {
288                                worktree: linked_worktrees_clone[candidate.candidate_id].clone(),
289                                positions: candidate.positions.clone(),
290                            });
291                        }
292
293                        let has_exact_match = linked_worktrees_clone
294                            .iter()
295                            .any(|worktree| worktree.display_name() == query);
296
297                        if !has_exact_match {
298                            new_matches.push(ThreadWorktreeEntry::CreateNamed {
299                                name: normalized_query.clone(),
300                                disabled_reason: create_named_disabled_reason.clone(),
301                            });
302                        }
303
304                        picker.delegate.matches = new_matches;
305                        picker.delegate.sync_selected_index();
306
307                        cx.notify();
308                    })
309                    .log_err();
310            });
311        }
312
313        self.matches = matches;
314        self.sync_selected_index();
315
316        Task::ready(())
317    }
318
319    fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
320        let Some(entry) = self.matches.get(self.selected_index) else {
321            return;
322        };
323
324        match entry {
325            ThreadWorktreeEntry::CurrentWorktree => {
326                if secondary {
327                    update_settings_file(self.fs.clone(), cx, |settings, _| {
328                        settings
329                            .agent
330                            .get_or_insert_default()
331                            .set_new_thread_location(NewThreadLocation::LocalProject);
332                    });
333                }
334                window.dispatch_action(Box::new(StartThreadIn::LocalProject), cx);
335            }
336            ThreadWorktreeEntry::NewWorktree => {
337                if secondary {
338                    update_settings_file(self.fs.clone(), cx, |settings, _| {
339                        settings
340                            .agent
341                            .get_or_insert_default()
342                            .set_new_thread_location(NewThreadLocation::NewWorktree);
343                    });
344                }
345                window.dispatch_action(Box::new(self.new_worktree_action(None)), cx);
346            }
347            ThreadWorktreeEntry::LinkedWorktree { worktree, .. } => {
348                window.dispatch_action(
349                    Box::new(StartThreadIn::LinkedWorktree {
350                        path: worktree.path.clone(),
351                        display_name: worktree.display_name().to_string(),
352                    }),
353                    cx,
354                );
355            }
356            ThreadWorktreeEntry::CreateNamed {
357                name,
358                disabled_reason: None,
359            } => {
360                window.dispatch_action(Box::new(self.new_worktree_action(Some(name.clone()))), cx);
361            }
362            ThreadWorktreeEntry::CreateNamed {
363                disabled_reason: Some(_),
364                ..
365            } => {
366                return;
367            }
368        }
369
370        cx.emit(DismissEvent);
371    }
372
373    fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
374
375    fn render_match(
376        &self,
377        ix: usize,
378        selected: bool,
379        _window: &mut Window,
380        cx: &mut Context<Picker<Self>>,
381    ) -> Option<Self::ListItem> {
382        let entry = self.matches.get(ix)?;
383        let project = self.project.read(cx);
384        let is_new_worktree_disabled =
385            project.repositories(cx).is_empty() || project.is_via_collab();
386        let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
387        let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
388        let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
389
390        match entry {
391            ThreadWorktreeEntry::CurrentWorktree => Some(
392                ListItem::new("current-worktree")
393                    .inset(true)
394                    .spacing(ListItemSpacing::Sparse)
395                    .toggle_state(selected)
396                    .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
397                    .child(Label::new("Current Worktree"))
398                    .end_slot(HoldForDefault::new(is_local_default).more_content(false))
399                    .tooltip(Tooltip::text("Use the current project worktree")),
400            ),
401            ThreadWorktreeEntry::NewWorktree => {
402                let item = ListItem::new("new-worktree")
403                    .inset(true)
404                    .spacing(ListItemSpacing::Sparse)
405                    .toggle_state(selected)
406                    .disabled(is_new_worktree_disabled)
407                    .start_slot(
408                        Icon::new(IconName::Plus).color(if is_new_worktree_disabled {
409                            Color::Disabled
410                        } else {
411                            Color::Muted
412                        }),
413                    )
414                    .child(
415                        Label::new("New Git Worktree").color(if is_new_worktree_disabled {
416                            Color::Disabled
417                        } else {
418                            Color::Default
419                        }),
420                    );
421
422                Some(if is_new_worktree_disabled {
423                    item.tooltip(Tooltip::text("Requires a Git repository in the project"))
424                } else {
425                    item.end_slot(HoldForDefault::new(is_new_worktree_default).more_content(false))
426                        .tooltip(Tooltip::text("Start a thread in a new Git worktree"))
427                })
428            }
429            ThreadWorktreeEntry::LinkedWorktree {
430                worktree,
431                positions,
432            } => {
433                let display_name = worktree.display_name();
434                let first_line = display_name.lines().next().unwrap_or(display_name);
435                let positions: Vec<_> = positions
436                    .iter()
437                    .copied()
438                    .filter(|&pos| pos < first_line.len())
439                    .collect();
440
441                Some(
442                    ListItem::new(SharedString::from(format!("linked-worktree-{ix}")))
443                        .inset(true)
444                        .spacing(ListItemSpacing::Sparse)
445                        .toggle_state(selected)
446                        .start_slot(Icon::new(IconName::GitWorktree).color(Color::Muted))
447                        .child(HighlightedLabel::new(first_line.to_owned(), positions).truncate()),
448                )
449            }
450            ThreadWorktreeEntry::CreateNamed {
451                name,
452                disabled_reason,
453            } => {
454                let is_disabled = disabled_reason.is_some();
455                let item = ListItem::new("create-named-worktree")
456                    .inset(true)
457                    .spacing(ListItemSpacing::Sparse)
458                    .toggle_state(selected)
459                    .disabled(is_disabled)
460                    .start_slot(Icon::new(IconName::Plus).color(if is_disabled {
461                        Color::Disabled
462                    } else {
463                        Color::Accent
464                    }))
465                    .child(Label::new(format!("Create Worktree: \"{name}\"")).color(
466                        if is_disabled {
467                            Color::Disabled
468                        } else {
469                            Color::Default
470                        },
471                    ));
472
473                Some(if let Some(reason) = disabled_reason.clone() {
474                    item.tooltip(Tooltip::text(reason))
475                } else {
476                    item
477                })
478            }
479        }
480    }
481
482    fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
483        None
484    }
485}