1use anyhow::Context as _;
2use collections::HashSet;
3use fuzzy::StringMatchCandidate;
4
5use git::repository::Worktree as GitWorktree;
6use gpui::{
7 Action, App, AsyncApp, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
8 InteractiveElement, IntoElement, Modifiers, ModifiersChangedEvent, ParentElement,
9 PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window,
10 actions, rems,
11};
12use picker::{Picker, PickerDelegate, PickerEditorPosition};
13use project::{
14 DirectoryLister,
15 git_store::Repository,
16 trusted_worktrees::{PathTrust, TrustedWorktrees},
17};
18use recent_projects::{RemoteConnectionModal, connect};
19use remote::{RemoteConnectionOptions, remote_client::ConnectionIdentifier};
20use std::{path::PathBuf, sync::Arc};
21use ui::{HighlightedLabel, KeyBinding, ListItem, ListItemSpacing, prelude::*};
22use util::ResultExt;
23use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
24
25actions!(git, [WorktreeFromDefault, WorktreeFromDefaultOnWindow]);
26
27pub fn open(
28 workspace: &mut Workspace,
29 _: &zed_actions::git::Worktree,
30 window: &mut Window,
31 cx: &mut Context<Workspace>,
32) {
33 let repository = workspace.project().read(cx).active_repository(cx);
34 let workspace_handle = workspace.weak_handle();
35 workspace.toggle_modal(window, cx, |window, cx| {
36 WorktreeList::new(repository, workspace_handle, rems(34.), window, cx)
37 })
38}
39
40pub fn create_embedded(
41 repository: Option<Entity<Repository>>,
42 workspace: WeakEntity<Workspace>,
43 width: Rems,
44 window: &mut Window,
45 cx: &mut Context<WorktreeList>,
46) -> WorktreeList {
47 WorktreeList::new_embedded(repository, workspace, width, window, cx)
48}
49
50pub struct WorktreeList {
51 width: Rems,
52 pub picker: Entity<Picker<WorktreeListDelegate>>,
53 picker_focus_handle: FocusHandle,
54 _subscription: Option<Subscription>,
55 embedded: bool,
56}
57
58impl WorktreeList {
59 fn new(
60 repository: Option<Entity<Repository>>,
61 workspace: WeakEntity<Workspace>,
62 width: Rems,
63 window: &mut Window,
64 cx: &mut Context<Self>,
65 ) -> Self {
66 let mut this = Self::new_inner(repository, workspace, width, false, window, cx);
67 this._subscription = Some(cx.subscribe(&this.picker, |_, _, _, cx| {
68 cx.emit(DismissEvent);
69 }));
70 this
71 }
72
73 fn new_inner(
74 repository: Option<Entity<Repository>>,
75 workspace: WeakEntity<Workspace>,
76 width: Rems,
77 embedded: bool,
78 window: &mut Window,
79 cx: &mut Context<Self>,
80 ) -> Self {
81 let all_worktrees_request = repository
82 .clone()
83 .map(|repository| repository.update(cx, |repository, _| repository.worktrees()));
84
85 let default_branch_request = repository.clone().map(|repository| {
86 repository.update(cx, |repository, _| repository.default_branch(false))
87 });
88
89 cx.spawn_in(window, async move |this, cx| {
90 let all_worktrees = all_worktrees_request
91 .context("No active repository")?
92 .await??;
93
94 let default_branch = default_branch_request
95 .context("No active repository")?
96 .await
97 .map(Result::ok)
98 .ok()
99 .flatten()
100 .flatten();
101
102 this.update_in(cx, |this, window, cx| {
103 this.picker.update(cx, |picker, cx| {
104 picker.delegate.all_worktrees = Some(all_worktrees);
105 picker.delegate.default_branch = default_branch;
106 picker.refresh(window, cx);
107 })
108 })?;
109
110 anyhow::Ok(())
111 })
112 .detach_and_log_err(cx);
113
114 let delegate = WorktreeListDelegate::new(workspace, repository, window, cx);
115 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(!embedded));
116 let picker_focus_handle = picker.focus_handle(cx);
117 picker.update(cx, |picker, _| {
118 picker.delegate.focus_handle = picker_focus_handle.clone();
119 });
120
121 Self {
122 picker,
123 picker_focus_handle,
124 width,
125 _subscription: None,
126 embedded,
127 }
128 }
129
130 fn new_embedded(
131 repository: Option<Entity<Repository>>,
132 workspace: WeakEntity<Workspace>,
133 width: Rems,
134 window: &mut Window,
135 cx: &mut Context<Self>,
136 ) -> Self {
137 let mut this = Self::new_inner(repository, workspace, width, true, window, cx);
138 this._subscription = Some(cx.subscribe(&this.picker, |_, _, _, cx| {
139 cx.emit(DismissEvent);
140 }));
141 this
142 }
143
144 pub fn handle_modifiers_changed(
145 &mut self,
146 ev: &ModifiersChangedEvent,
147 _: &mut Window,
148 cx: &mut Context<Self>,
149 ) {
150 self.picker
151 .update(cx, |picker, _| picker.delegate.modifiers = ev.modifiers)
152 }
153
154 pub fn handle_new_worktree(
155 &mut self,
156 replace_current_window: bool,
157 window: &mut Window,
158 cx: &mut Context<Self>,
159 ) {
160 self.picker.update(cx, |picker, cx| {
161 let ix = picker.delegate.selected_index();
162 let Some(entry) = picker.delegate.matches.get(ix) else {
163 return;
164 };
165 let Some(default_branch) = picker.delegate.default_branch.clone() else {
166 return;
167 };
168 if !entry.is_new {
169 return;
170 }
171 picker.delegate.create_worktree(
172 entry.worktree.branch(),
173 replace_current_window,
174 Some(default_branch.into()),
175 window,
176 cx,
177 );
178 })
179 }
180}
181impl ModalView for WorktreeList {}
182impl EventEmitter<DismissEvent> for WorktreeList {}
183
184impl Focusable for WorktreeList {
185 fn focus_handle(&self, _: &App) -> FocusHandle {
186 self.picker_focus_handle.clone()
187 }
188}
189
190impl Render for WorktreeList {
191 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
192 v_flex()
193 .key_context("GitWorktreeSelector")
194 .w(self.width)
195 .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed))
196 .on_action(cx.listener(|this, _: &WorktreeFromDefault, w, cx| {
197 this.handle_new_worktree(false, w, cx)
198 }))
199 .on_action(cx.listener(|this, _: &WorktreeFromDefaultOnWindow, w, cx| {
200 this.handle_new_worktree(true, w, cx)
201 }))
202 .child(self.picker.clone())
203 .when(!self.embedded, |el| {
204 el.on_mouse_down_out({
205 cx.listener(move |this, _, window, cx| {
206 this.picker.update(cx, |this, cx| {
207 this.cancel(&Default::default(), window, cx);
208 })
209 })
210 })
211 })
212 }
213}
214
215#[derive(Debug, Clone)]
216struct WorktreeEntry {
217 worktree: GitWorktree,
218 positions: Vec<usize>,
219 is_new: bool,
220}
221
222pub struct WorktreeListDelegate {
223 matches: Vec<WorktreeEntry>,
224 all_worktrees: Option<Vec<GitWorktree>>,
225 workspace: WeakEntity<Workspace>,
226 repo: Option<Entity<Repository>>,
227 selected_index: usize,
228 last_query: String,
229 modifiers: Modifiers,
230 focus_handle: FocusHandle,
231 default_branch: Option<SharedString>,
232}
233
234impl WorktreeListDelegate {
235 fn new(
236 workspace: WeakEntity<Workspace>,
237 repo: Option<Entity<Repository>>,
238 _window: &mut Window,
239 cx: &mut Context<WorktreeList>,
240 ) -> Self {
241 Self {
242 matches: vec![],
243 all_worktrees: None,
244 workspace,
245 selected_index: 0,
246 repo,
247 last_query: Default::default(),
248 modifiers: Default::default(),
249 focus_handle: cx.focus_handle(),
250 default_branch: None,
251 }
252 }
253
254 fn create_worktree(
255 &self,
256 worktree_branch: &str,
257 replace_current_window: bool,
258 commit: Option<String>,
259 window: &mut Window,
260 cx: &mut Context<Picker<Self>>,
261 ) {
262 let Some(repo) = self.repo.clone() else {
263 return;
264 };
265
266 let worktree_path = self
267 .workspace
268 .clone()
269 .update(cx, |this, cx| {
270 this.prompt_for_open_path(
271 PathPromptOptions {
272 files: false,
273 directories: true,
274 multiple: false,
275 prompt: Some("Select directory for new worktree".into()),
276 },
277 DirectoryLister::Project(this.project().clone()),
278 window,
279 cx,
280 )
281 })
282 .log_err();
283 let Some(worktree_path) = worktree_path else {
284 return;
285 };
286
287 let branch = worktree_branch.to_string();
288 let window_handle = window.window_handle();
289 let workspace = self.workspace.clone();
290 cx.spawn_in(window, async move |_, cx| {
291 let Some(paths) = worktree_path.await? else {
292 return anyhow::Ok(());
293 };
294 let path = paths.get(0).cloned().context("No path selected")?;
295
296 repo.update(cx, |repo, _| {
297 repo.create_worktree(branch.clone(), path.clone(), commit)
298 })
299 .await??;
300 let new_worktree_path = path.join(branch);
301
302 workspace.update(cx, |workspace, cx| {
303 if let Some(trusted_worktrees) = TrustedWorktrees::try_get_global(cx) {
304 let repo_path = &repo.read(cx).snapshot().work_directory_abs_path;
305 let project = workspace.project();
306 if let Some((parent_worktree, _)) =
307 project.read(cx).find_worktree(repo_path, cx)
308 {
309 let worktree_store = project.read(cx).worktree_store();
310 trusted_worktrees.update(cx, |trusted_worktrees, cx| {
311 if trusted_worktrees.can_trust(
312 &worktree_store,
313 parent_worktree.read(cx).id(),
314 cx,
315 ) {
316 trusted_worktrees.trust(
317 &worktree_store,
318 HashSet::from_iter([PathTrust::AbsPath(
319 new_worktree_path.clone(),
320 )]),
321 cx,
322 );
323 }
324 });
325 }
326 }
327 })?;
328
329 let (connection_options, app_state, is_local) =
330 workspace.update(cx, |workspace, cx| {
331 let project = workspace.project().clone();
332 let connection_options = project.read(cx).remote_connection_options(cx);
333 let app_state = workspace.app_state().clone();
334 let is_local = project.read(cx).is_local();
335 (connection_options, app_state, is_local)
336 })?;
337
338 if is_local {
339 workspace
340 .update_in(cx, |workspace, window, cx| {
341 workspace.open_workspace_for_paths(
342 replace_current_window,
343 vec![new_worktree_path],
344 window,
345 cx,
346 )
347 })?
348 .await?;
349 } else if let Some(connection_options) = connection_options {
350 open_remote_worktree(
351 connection_options,
352 vec![new_worktree_path],
353 app_state,
354 window_handle,
355 replace_current_window,
356 cx,
357 )
358 .await?;
359 }
360
361 anyhow::Ok(())
362 })
363 .detach_and_prompt_err("Failed to create worktree", window, cx, |e, _, _| {
364 Some(e.to_string())
365 });
366 }
367
368 fn open_worktree(
369 &self,
370 worktree_path: &PathBuf,
371 replace_current_window: bool,
372 window: &mut Window,
373 cx: &mut Context<Picker<Self>>,
374 ) {
375 let workspace = self.workspace.clone();
376 let path = worktree_path.clone();
377
378 let Some((connection_options, app_state, is_local)) = workspace
379 .update(cx, |workspace, cx| {
380 let project = workspace.project().clone();
381 let connection_options = project.read(cx).remote_connection_options(cx);
382 let app_state = workspace.app_state().clone();
383 let is_local = project.read(cx).is_local();
384 (connection_options, app_state, is_local)
385 })
386 .log_err()
387 else {
388 return;
389 };
390
391 if is_local {
392 let open_task = workspace.update(cx, |workspace, cx| {
393 workspace.open_workspace_for_paths(replace_current_window, vec![path], window, cx)
394 });
395 cx.spawn(async move |_, _| {
396 open_task?.await?;
397 anyhow::Ok(())
398 })
399 .detach_and_prompt_err(
400 "Failed to open worktree",
401 window,
402 cx,
403 |e, _, _| Some(e.to_string()),
404 );
405 } else if let Some(connection_options) = connection_options {
406 let window_handle = window.window_handle();
407 cx.spawn_in(window, async move |_, cx| {
408 open_remote_worktree(
409 connection_options,
410 vec![path],
411 app_state,
412 window_handle,
413 replace_current_window,
414 cx,
415 )
416 .await
417 })
418 .detach_and_prompt_err(
419 "Failed to open worktree",
420 window,
421 cx,
422 |e, _, _| Some(e.to_string()),
423 );
424 }
425
426 cx.emit(DismissEvent);
427 }
428
429 fn base_branch<'a>(&'a self, cx: &'a mut Context<Picker<Self>>) -> Option<&'a str> {
430 self.repo
431 .as_ref()
432 .and_then(|repo| repo.read(cx).branch.as_ref().map(|b| b.name()))
433 }
434}
435
436async fn open_remote_worktree(
437 connection_options: RemoteConnectionOptions,
438 paths: Vec<PathBuf>,
439 app_state: Arc<workspace::AppState>,
440 window: gpui::AnyWindowHandle,
441 replace_current_window: bool,
442 cx: &mut AsyncApp,
443) -> anyhow::Result<()> {
444 let workspace_window = window
445 .downcast::<Workspace>()
446 .ok_or_else(|| anyhow::anyhow!("Window is not a Workspace window"))?;
447
448 let connect_task = workspace_window.update(cx, |workspace, window, cx| {
449 workspace.toggle_modal(window, cx, |window, cx| {
450 RemoteConnectionModal::new(&connection_options, Vec::new(), window, cx)
451 });
452
453 let prompt = workspace
454 .active_modal::<RemoteConnectionModal>(cx)
455 .expect("Modal just created")
456 .read(cx)
457 .prompt
458 .clone();
459
460 connect(
461 ConnectionIdentifier::setup(),
462 connection_options.clone(),
463 prompt,
464 window,
465 cx,
466 )
467 .prompt_err("Failed to connect", window, cx, |_, _, _| None)
468 })?;
469
470 let session = connect_task.await;
471
472 workspace_window.update(cx, |workspace, _window, cx| {
473 if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
474 prompt.update(cx, |prompt, cx| prompt.finished(cx))
475 }
476 })?;
477
478 let Some(Some(session)) = session else {
479 return Ok(());
480 };
481
482 let new_project: Entity<project::Project> = cx.update(|cx| {
483 project::Project::remote(
484 session,
485 app_state.client.clone(),
486 app_state.node_runtime.clone(),
487 app_state.user_store.clone(),
488 app_state.languages.clone(),
489 app_state.fs.clone(),
490 true,
491 cx,
492 )
493 });
494
495 let window_to_use = if replace_current_window {
496 workspace_window
497 } else {
498 let workspace_position = cx
499 .update(|cx| {
500 workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
501 })
502 .await
503 .context("fetching workspace position from db")?;
504
505 let mut options =
506 cx.update(|cx| (app_state.build_window_options)(workspace_position.display, cx));
507 options.window_bounds = workspace_position.window_bounds;
508
509 cx.open_window(options, |window, cx| {
510 cx.new(|cx| {
511 let mut workspace =
512 Workspace::new(None, new_project.clone(), app_state.clone(), window, cx);
513 workspace.centered_layout = workspace_position.centered_layout;
514 workspace
515 })
516 })?
517 };
518
519 workspace::open_remote_project_with_existing_connection(
520 connection_options,
521 new_project,
522 paths,
523 app_state,
524 window_to_use,
525 cx,
526 )
527 .await?;
528
529 Ok(())
530}
531
532impl PickerDelegate for WorktreeListDelegate {
533 type ListItem = ListItem;
534
535 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
536 "Select worktree…".into()
537 }
538
539 fn editor_position(&self) -> PickerEditorPosition {
540 PickerEditorPosition::Start
541 }
542
543 fn match_count(&self) -> usize {
544 self.matches.len()
545 }
546
547 fn selected_index(&self) -> usize {
548 self.selected_index
549 }
550
551 fn set_selected_index(
552 &mut self,
553 ix: usize,
554 _window: &mut Window,
555 _: &mut Context<Picker<Self>>,
556 ) {
557 self.selected_index = ix;
558 }
559
560 fn update_matches(
561 &mut self,
562 query: String,
563 window: &mut Window,
564 cx: &mut Context<Picker<Self>>,
565 ) -> Task<()> {
566 let Some(all_worktrees) = self.all_worktrees.clone() else {
567 return Task::ready(());
568 };
569
570 cx.spawn_in(window, async move |picker, cx| {
571 let mut matches: Vec<WorktreeEntry> = if query.is_empty() {
572 all_worktrees
573 .into_iter()
574 .map(|worktree| WorktreeEntry {
575 worktree,
576 positions: Vec::new(),
577 is_new: false,
578 })
579 .collect()
580 } else {
581 let candidates = all_worktrees
582 .iter()
583 .enumerate()
584 .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.branch()))
585 .collect::<Vec<StringMatchCandidate>>();
586 fuzzy::match_strings(
587 &candidates,
588 &query,
589 true,
590 true,
591 10000,
592 &Default::default(),
593 cx.background_executor().clone(),
594 )
595 .await
596 .into_iter()
597 .map(|candidate| WorktreeEntry {
598 worktree: all_worktrees[candidate.candidate_id].clone(),
599 positions: candidate.positions,
600 is_new: false,
601 })
602 .collect()
603 };
604 picker
605 .update(cx, |picker, _| {
606 if !query.is_empty()
607 && !matches
608 .first()
609 .is_some_and(|entry| entry.worktree.branch() == query)
610 {
611 let query = query.replace(' ', "-");
612 matches.push(WorktreeEntry {
613 worktree: GitWorktree {
614 path: Default::default(),
615 ref_name: format!("refs/heads/{query}").into(),
616 sha: Default::default(),
617 },
618 positions: Vec::new(),
619 is_new: true,
620 })
621 }
622 let delegate = &mut picker.delegate;
623 delegate.matches = matches;
624 if delegate.matches.is_empty() {
625 delegate.selected_index = 0;
626 } else {
627 delegate.selected_index =
628 core::cmp::min(delegate.selected_index, delegate.matches.len() - 1);
629 }
630 delegate.last_query = query;
631 })
632 .log_err();
633 })
634 }
635
636 fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
637 let Some(entry) = self.matches.get(self.selected_index()) else {
638 return;
639 };
640 if entry.is_new {
641 self.create_worktree(&entry.worktree.branch(), secondary, None, window, cx);
642 } else {
643 self.open_worktree(&entry.worktree.path, secondary, window, cx);
644 }
645
646 cx.emit(DismissEvent);
647 }
648
649 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
650 cx.emit(DismissEvent);
651 }
652
653 fn render_match(
654 &self,
655 ix: usize,
656 selected: bool,
657 _window: &mut Window,
658 cx: &mut Context<Picker<Self>>,
659 ) -> Option<Self::ListItem> {
660 let entry = &self.matches.get(ix)?;
661 let path = entry.worktree.path.to_string_lossy().to_string();
662 let sha = entry
663 .worktree
664 .sha
665 .clone()
666 .chars()
667 .take(7)
668 .collect::<String>();
669
670 let (branch_name, sublabel) = if entry.is_new {
671 (
672 Label::new(format!("Create Worktree: \"{}\"…", entry.worktree.branch()))
673 .truncate()
674 .into_any_element(),
675 format!(
676 "based off {}",
677 self.base_branch(cx).unwrap_or("the current branch")
678 ),
679 )
680 } else {
681 let branch = entry.worktree.branch();
682 let branch_first_line = branch.lines().next().unwrap_or(branch);
683 let positions: Vec<_> = entry
684 .positions
685 .iter()
686 .copied()
687 .filter(|&pos| pos < branch_first_line.len())
688 .collect();
689
690 (
691 HighlightedLabel::new(branch_first_line.to_owned(), positions)
692 .truncate()
693 .into_any_element(),
694 path,
695 )
696 };
697
698 Some(
699 ListItem::new(format!("worktree-menu-{ix}"))
700 .inset(true)
701 .spacing(ListItemSpacing::Sparse)
702 .toggle_state(selected)
703 .child(
704 v_flex()
705 .w_full()
706 .child(
707 h_flex()
708 .gap_2()
709 .justify_between()
710 .overflow_x_hidden()
711 .child(branch_name)
712 .when(!entry.is_new, |this| {
713 this.child(
714 Label::new(sha)
715 .size(LabelSize::Small)
716 .color(Color::Muted)
717 .buffer_font(cx)
718 .into_element(),
719 )
720 }),
721 )
722 .child(
723 Label::new(sublabel)
724 .size(LabelSize::Small)
725 .color(Color::Muted)
726 .truncate()
727 .into_any_element(),
728 ),
729 ),
730 )
731 }
732
733 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
734 Some("No worktrees found".into())
735 }
736
737 fn render_footer(&self, _: &mut Window, cx: &mut Context<Picker<Self>>) -> Option<AnyElement> {
738 let focus_handle = self.focus_handle.clone();
739 let selected_entry = self.matches.get(self.selected_index);
740 let is_creating = selected_entry.is_some_and(|entry| entry.is_new);
741
742 let footer_container = h_flex()
743 .w_full()
744 .p_1p5()
745 .gap_0p5()
746 .justify_end()
747 .border_t_1()
748 .border_color(cx.theme().colors().border_variant);
749
750 if is_creating {
751 let from_default_button = self.default_branch.as_ref().map(|default_branch| {
752 Button::new(
753 "worktree-from-default",
754 format!("Create from: {default_branch}"),
755 )
756 .key_binding(
757 KeyBinding::for_action_in(&WorktreeFromDefault, &focus_handle, cx)
758 .map(|kb| kb.size(rems_from_px(12.))),
759 )
760 .on_click(|_, window, cx| {
761 window.dispatch_action(WorktreeFromDefault.boxed_clone(), cx)
762 })
763 });
764
765 let current_branch = self.base_branch(cx).unwrap_or("current branch");
766
767 Some(
768 footer_container
769 .when_some(from_default_button, |this, button| this.child(button))
770 .child(
771 Button::new(
772 "worktree-from-current",
773 format!("Create from: {current_branch}"),
774 )
775 .key_binding(
776 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
777 .map(|kb| kb.size(rems_from_px(12.))),
778 )
779 .on_click(|_, window, cx| {
780 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
781 }),
782 )
783 .into_any(),
784 )
785 } else {
786 Some(
787 footer_container
788 .child(
789 Button::new("open-in-new-window", "Open in New Window")
790 .key_binding(
791 KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx)
792 .map(|kb| kb.size(rems_from_px(12.))),
793 )
794 .on_click(|_, window, cx| {
795 window.dispatch_action(menu::Confirm.boxed_clone(), cx)
796 }),
797 )
798 .child(
799 Button::new("open-in-window", "Open")
800 .key_binding(
801 KeyBinding::for_action_in(
802 &menu::SecondaryConfirm,
803 &focus_handle,
804 cx,
805 )
806 .map(|kb| kb.size(rems_from_px(12.))),
807 )
808 .on_click(|_, window, cx| {
809 window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx)
810 }),
811 )
812 .into_any(),
813 )
814 }
815 }
816}