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}