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