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