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}