1use anyhow::anyhow;
2use commit_modal::CommitModal;
3use editor::{Editor, actions::DiffClipboardWithSelectionData};
4
5use project::ProjectPath;
6use ui::{
7 Color, Headline, HeadlineSize, Icon, IconName, IconSize, IntoElement, ParentElement, Render,
8 Styled, StyledExt, div, h_flex, rems, v_flex,
9};
10use workspace::{Toast, notifications::NotificationId};
11
12mod blame_ui;
13pub mod clone;
14
15use git::{
16 repository::{Branch, CommitDetails, Upstream, UpstreamTracking, UpstreamTrackingStatus},
17 status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
18};
19use gpui::{
20 App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, SharedString,
21 Subscription, Task, Window,
22};
23use menu::{Cancel, Confirm};
24use project::git_store::Repository;
25use project_diff::ProjectDiff;
26use time::OffsetDateTime;
27use ui::prelude::*;
28use workspace::{ModalView, Workspace, notifications::DetachAndPromptErr};
29use zed_actions;
30
31use crate::{commit_view::CommitView, git_panel::GitPanel, text_diff_view::TextDiffView};
32
33mod askpass_modal;
34pub mod branch_picker;
35mod commit_modal;
36pub mod commit_tooltip;
37pub mod commit_view;
38mod conflict_view;
39pub mod file_diff_view;
40pub mod file_history_view;
41pub mod git_panel;
42mod git_panel_settings;
43pub mod git_picker;
44pub mod multi_diff_view;
45pub mod picker_prompt;
46pub mod project_diff;
47pub(crate) mod remote_output;
48pub mod repository_selector;
49pub mod stash_picker;
50pub mod text_diff_view;
51pub mod worktree_picker;
52
53pub use conflict_view::MergeConflictIndicator;
54
55pub fn init(cx: &mut App) {
56 editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
57 commit_view::init(cx);
58
59 cx.observe_new(|editor: &mut Editor, _, cx| {
60 conflict_view::register_editor(editor, editor.buffer().clone(), cx);
61 })
62 .detach();
63
64 cx.observe_new(|workspace: &mut Workspace, _, cx| {
65 ProjectDiff::register(workspace, cx);
66 CommitModal::register(workspace);
67 git_panel::register(workspace);
68 repository_selector::register(workspace);
69 git_picker::register(workspace);
70
71 let project = workspace.project().read(cx);
72 if project.is_read_only(cx) {
73 return;
74 }
75 if !project.is_via_collab() {
76 workspace.register_action(
77 |workspace, _: &zed_actions::git::CreatePullRequest, window, cx| {
78 if let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) {
79 panel.update(cx, |panel, cx| {
80 panel.create_pull_request(window, cx);
81 });
82 }
83 },
84 );
85 workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
86 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
87 return;
88 };
89 panel.update(cx, |panel, cx| {
90 panel.fetch(true, window, cx);
91 });
92 });
93 workspace.register_action(|workspace, _: &git::FetchFrom, window, cx| {
94 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
95 return;
96 };
97 panel.update(cx, |panel, cx| {
98 panel.fetch(false, window, cx);
99 });
100 });
101 workspace.register_action(|workspace, _: &git::Push, window, cx| {
102 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
103 return;
104 };
105 panel.update(cx, |panel, cx| {
106 panel.push(false, false, window, cx);
107 });
108 });
109 workspace.register_action(|workspace, _: &git::PushTo, window, cx| {
110 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
111 return;
112 };
113 panel.update(cx, |panel, cx| {
114 panel.push(false, true, window, cx);
115 });
116 });
117 workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
118 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
119 return;
120 };
121 panel.update(cx, |panel, cx| {
122 panel.push(true, false, window, cx);
123 });
124 });
125 workspace.register_action(|workspace, _: &git::Pull, window, cx| {
126 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
127 return;
128 };
129 panel.update(cx, |panel, cx| {
130 panel.pull(false, window, cx);
131 });
132 });
133 workspace.register_action(|workspace, _: &git::PullRebase, window, cx| {
134 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
135 return;
136 };
137 panel.update(cx, |panel, cx| {
138 panel.pull(true, window, cx);
139 });
140 });
141 }
142 workspace.register_action(|workspace, action: &git::StashAll, window, cx| {
143 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
144 return;
145 };
146 panel.update(cx, |panel, cx| {
147 panel.stash_all(action, window, cx);
148 });
149 });
150 workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
151 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
152 return;
153 };
154 panel.update(cx, |panel, cx| {
155 panel.stash_pop(action, window, cx);
156 });
157 });
158 workspace.register_action(|workspace, action: &git::StashApply, window, cx| {
159 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
160 return;
161 };
162 panel.update(cx, |panel, cx| {
163 panel.stash_apply(action, window, cx);
164 });
165 });
166 workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
167 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
168 return;
169 };
170 panel.update(cx, |panel, cx| {
171 panel.stage_all(action, window, cx);
172 });
173 });
174 workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
175 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
176 return;
177 };
178 panel.update(cx, |panel, cx| {
179 panel.unstage_all(action, window, cx);
180 });
181 });
182 workspace.register_action(|workspace, _: &git::Uncommit, window, cx| {
183 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
184 return;
185 };
186 panel.update(cx, |panel, cx| {
187 panel.uncommit(window, cx);
188 })
189 });
190 workspace.register_action(|workspace, _action: &git::Init, window, cx| {
191 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
192 return;
193 };
194 panel.update(cx, |panel, cx| {
195 panel.git_init(window, cx);
196 });
197 });
198 workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
199 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
200 return;
201 };
202
203 workspace.toggle_modal(window, cx, |window, cx| {
204 GitCloneModal::show(panel, window, cx)
205 });
206 });
207 workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
208 open_modified_files(workspace, window, cx);
209 });
210 workspace.register_action(|workspace, _: &git::RenameBranch, window, cx| {
211 rename_current_branch(workspace, window, cx);
212 });
213 workspace.register_action(show_ref_picker);
214 workspace.register_action(
215 |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
216 if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
217 task.detach();
218 };
219 },
220 );
221 workspace.register_action(|workspace, _: &git::FileHistory, window, cx| {
222 let Some(active_item) = workspace.active_item(cx) else {
223 return;
224 };
225 let Some(editor) = active_item.downcast::<Editor>() else {
226 return;
227 };
228 let Some(buffer) = editor.read(cx).buffer().read(cx).as_singleton() else {
229 return;
230 };
231 let Some(file) = buffer.read(cx).file() else {
232 return;
233 };
234 let worktree_id = file.worktree_id(cx);
235 let project_path = ProjectPath {
236 worktree_id,
237 path: file.path().clone(),
238 };
239 let project = workspace.project();
240 let git_store = project.read(cx).git_store();
241 let Some((repo, repo_path)) = git_store
242 .read(cx)
243 .repository_and_path_for_project_path(&project_path, cx)
244 else {
245 return;
246 };
247 file_history_view::FileHistoryView::open(
248 repo_path,
249 git_store.downgrade(),
250 repo.downgrade(),
251 workspace.weak_handle(),
252 window,
253 cx,
254 );
255 });
256 })
257 .detach();
258}
259
260fn open_modified_files(
261 workspace: &mut Workspace,
262 window: &mut Window,
263 cx: &mut Context<Workspace>,
264) {
265 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
266 return;
267 };
268 let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
269 let Some(repo) = panel.active_repository.as_ref() else {
270 return Vec::new();
271 };
272 let repo = repo.read(cx);
273 repo.cached_status()
274 .filter_map(|entry| {
275 if entry.status.is_modified() {
276 repo.repo_path_to_project_path(&entry.repo_path, cx)
277 } else {
278 None
279 }
280 })
281 .collect()
282 });
283 for path in modified_paths {
284 workspace.open_path(path, None, true, window, cx).detach();
285 }
286}
287
288pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
289 GitStatusIcon::new(status)
290}
291
292struct RenameBranchModal {
293 current_branch: SharedString,
294 editor: Entity<Editor>,
295 repo: Entity<Repository>,
296}
297
298impl RenameBranchModal {
299 fn new(
300 current_branch: String,
301 repo: Entity<Repository>,
302 window: &mut Window,
303 cx: &mut Context<Self>,
304 ) -> Self {
305 let editor = cx.new(|cx| {
306 let mut editor = Editor::single_line(window, cx);
307 editor.set_text(current_branch.clone(), window, cx);
308 editor
309 });
310 Self {
311 current_branch: current_branch.into(),
312 editor,
313 repo,
314 }
315 }
316
317 fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
318 cx.emit(DismissEvent);
319 }
320
321 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
322 let new_name = self.editor.read(cx).text(cx);
323 if new_name.is_empty() || new_name == self.current_branch.as_ref() {
324 cx.emit(DismissEvent);
325 return;
326 }
327
328 let repo = self.repo.clone();
329 let current_branch = self.current_branch.to_string();
330 cx.spawn(async move |_, cx| {
331 match repo
332 .update(cx, |repo, _| {
333 repo.rename_branch(current_branch, new_name.clone())
334 })
335 .await
336 {
337 Ok(Ok(_)) => Ok(()),
338 Ok(Err(error)) => Err(error),
339 Err(_) => Err(anyhow!("Operation was canceled")),
340 }
341 })
342 .detach_and_prompt_err("Failed to rename branch", window, cx, |_, _, _| None);
343 cx.emit(DismissEvent);
344 }
345}
346
347impl EventEmitter<DismissEvent> for RenameBranchModal {}
348impl ModalView for RenameBranchModal {}
349impl Focusable for RenameBranchModal {
350 fn focus_handle(&self, cx: &App) -> FocusHandle {
351 self.editor.focus_handle(cx)
352 }
353}
354
355impl Render for RenameBranchModal {
356 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
357 v_flex()
358 .key_context("RenameBranchModal")
359 .on_action(cx.listener(Self::cancel))
360 .on_action(cx.listener(Self::confirm))
361 .elevation_2(cx)
362 .w(rems(34.))
363 .child(
364 h_flex()
365 .px_3()
366 .pt_2()
367 .pb_1()
368 .w_full()
369 .gap_1p5()
370 .child(Icon::new(IconName::GitBranch).size(IconSize::XSmall))
371 .child(
372 Headline::new(format!("Rename Branch ({})", self.current_branch))
373 .size(HeadlineSize::XSmall),
374 ),
375 )
376 .child(div().px_3().pb_3().w_full().child(self.editor.clone()))
377 }
378}
379
380fn rename_current_branch(
381 workspace: &mut Workspace,
382 window: &mut Window,
383 cx: &mut Context<Workspace>,
384) {
385 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
386 return;
387 };
388 let current_branch: Option<String> = panel.update(cx, |panel, cx| {
389 let repo = panel.active_repository.as_ref()?;
390 let repo = repo.read(cx);
391 repo.branch.as_ref().map(|branch| branch.name().to_string())
392 });
393
394 let Some(current_branch_name) = current_branch else {
395 return;
396 };
397
398 let repo = panel.read(cx).active_repository.clone();
399 let Some(repo) = repo else {
400 return;
401 };
402
403 workspace.toggle_modal(window, cx, |window, cx| {
404 RenameBranchModal::new(current_branch_name, repo, window, cx)
405 });
406}
407
408struct RefPickerModal {
409 editor: Entity<Editor>,
410 repo: Entity<Repository>,
411 workspace: Entity<Workspace>,
412 commit_details: Option<CommitDetails>,
413 lookup_task: Option<Task<()>>,
414 _editor_subscription: Subscription,
415}
416
417impl RefPickerModal {
418 fn new(
419 repo: Entity<Repository>,
420 workspace: Entity<Workspace>,
421 window: &mut Window,
422 cx: &mut Context<Self>,
423 ) -> Self {
424 let editor = cx.new(|cx| {
425 let mut editor = Editor::single_line(window, cx);
426 editor.set_placeholder_text("Enter git ref...", window, cx);
427 editor
428 });
429
430 let _editor_subscription = cx.subscribe_in(
431 &editor,
432 window,
433 |this, _editor, event: &editor::EditorEvent, window, cx| {
434 if let editor::EditorEvent::BufferEdited = event {
435 this.lookup_commit_details(window, cx);
436 }
437 },
438 );
439
440 Self {
441 editor,
442 repo,
443 workspace,
444 commit_details: None,
445 lookup_task: None,
446 _editor_subscription,
447 }
448 }
449
450 fn lookup_commit_details(&mut self, window: &mut Window, cx: &mut Context<Self>) {
451 let git_ref = self.editor.read(cx).text(cx);
452 let git_ref = git_ref.trim().to_string();
453
454 if git_ref.is_empty() {
455 self.commit_details = None;
456 cx.notify();
457 return;
458 }
459
460 let repo = self.repo.clone();
461 self.lookup_task = Some(cx.spawn_in(window, async move |this, cx| {
462 cx.background_executor()
463 .timer(std::time::Duration::from_millis(300))
464 .await;
465
466 let show_result = repo
467 .update(cx, |repo, _| repo.show(git_ref.clone()))
468 .await
469 .ok();
470
471 if let Some(show_future) = show_result {
472 if let Ok(details) = show_future {
473 this.update(cx, |this, cx| {
474 this.commit_details = Some(details);
475 cx.notify();
476 })
477 .ok();
478 } else {
479 this.update(cx, |this, cx| {
480 this.commit_details = None;
481 cx.notify();
482 })
483 .ok();
484 }
485 }
486 }));
487 }
488
489 fn cancel(&mut self, _: &Cancel, _window: &mut Window, cx: &mut Context<Self>) {
490 cx.emit(DismissEvent);
491 }
492
493 fn confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
494 let git_ref = self.editor.read(cx).text(cx);
495 let git_ref = git_ref.trim();
496
497 if git_ref.is_empty() {
498 cx.emit(DismissEvent);
499 return;
500 }
501
502 let git_ref_string = git_ref.to_string();
503
504 let repo = self.repo.clone();
505 let workspace = self.workspace.clone();
506
507 window
508 .spawn(cx, async move |cx| -> anyhow::Result<()> {
509 let show_future = repo.update(cx, |repo, _| repo.show(git_ref_string.clone()));
510 let show_result = show_future.await;
511
512 match show_result {
513 Ok(Ok(details)) => {
514 workspace.update_in(cx, |workspace, window, cx| {
515 CommitView::open(
516 details.sha.to_string(),
517 repo.downgrade(),
518 workspace.weak_handle(),
519 None,
520 None,
521 window,
522 cx,
523 );
524 })?;
525 }
526 Ok(Err(_)) | Err(_) => {
527 workspace.update(cx, |workspace, cx| {
528 let error = anyhow::anyhow!("View commit failed");
529 Self::show_git_error_toast(&git_ref_string, error, workspace, cx);
530 });
531 }
532 }
533
534 Ok(())
535 })
536 .detach();
537 cx.emit(DismissEvent);
538 }
539
540 fn show_git_error_toast(
541 _git_ref: &str,
542 error: anyhow::Error,
543 workspace: &mut Workspace,
544 cx: &mut Context<Workspace>,
545 ) {
546 let toast = Toast::new(NotificationId::unique::<()>(), error.to_string());
547 workspace.show_toast(toast, cx);
548 }
549}
550
551impl EventEmitter<DismissEvent> for RefPickerModal {}
552impl ModalView for RefPickerModal {}
553impl Focusable for RefPickerModal {
554 fn focus_handle(&self, cx: &App) -> FocusHandle {
555 self.editor.focus_handle(cx)
556 }
557}
558
559impl Render for RefPickerModal {
560 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
561 let has_commit_details = self.commit_details.is_some();
562 let commit_preview = self.commit_details.as_ref().map(|details| {
563 let commit_time = OffsetDateTime::from_unix_timestamp(details.commit_timestamp)
564 .unwrap_or_else(|_| OffsetDateTime::now_utc());
565 let local_offset =
566 time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC);
567 let formatted_time = time_format::format_localized_timestamp(
568 commit_time,
569 OffsetDateTime::now_utc(),
570 local_offset,
571 time_format::TimestampFormat::Relative,
572 );
573
574 let subject = details.message.lines().next().unwrap_or("").to_string();
575 let author_and_subject = format!("{} • {}", details.author_name, subject);
576
577 h_flex()
578 .w_full()
579 .gap_6()
580 .justify_between()
581 .overflow_x_hidden()
582 .child(
583 div().max_w_96().child(
584 Label::new(author_and_subject)
585 .size(LabelSize::Small)
586 .truncate()
587 .color(Color::Muted),
588 ),
589 )
590 .child(
591 Label::new(formatted_time)
592 .size(LabelSize::Small)
593 .color(Color::Muted),
594 )
595 });
596
597 v_flex()
598 .key_context("RefPickerModal")
599 .on_action(cx.listener(Self::cancel))
600 .on_action(cx.listener(Self::confirm))
601 .elevation_2(cx)
602 .w(rems(34.))
603 .child(
604 h_flex()
605 .px_3()
606 .pt_2()
607 .pb_1()
608 .w_full()
609 .gap_1p5()
610 .child(Icon::new(IconName::Hash).size(IconSize::XSmall))
611 .child(Headline::new("View Commit").size(HeadlineSize::XSmall)),
612 )
613 .child(div().px_3().w_full().child(self.editor.clone()))
614 .when_some(commit_preview, |el, preview| {
615 el.child(div().px_3().pb_3().w_full().child(preview))
616 })
617 .when(!has_commit_details, |el| el.child(div().pb_3()))
618 }
619}
620
621fn show_ref_picker(
622 workspace: &mut Workspace,
623 _: &git::ViewCommit,
624 window: &mut Window,
625 cx: &mut Context<Workspace>,
626) {
627 let Some(repo) = workspace.project().read(cx).active_repository(cx) else {
628 return;
629 };
630
631 let workspace_entity = cx.entity();
632 workspace.toggle_modal(window, cx, |window, cx| {
633 RefPickerModal::new(repo, workspace_entity, window, cx)
634 });
635}
636
637fn render_remote_button(
638 id: impl Into<SharedString>,
639 branch: &Branch,
640 keybinding_target: Option<FocusHandle>,
641 show_fetch_button: bool,
642) -> Option<impl IntoElement> {
643 let id = id.into();
644 let upstream = branch.upstream.as_ref();
645 match upstream {
646 Some(Upstream {
647 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
648 ..
649 }) => match (*ahead, *behind) {
650 (0, 0) if show_fetch_button => {
651 Some(remote_button::render_fetch_button(keybinding_target, id))
652 }
653 (0, 0) => None,
654 (ahead, 0) => Some(remote_button::render_push_button(
655 keybinding_target,
656 id,
657 ahead,
658 )),
659 (ahead, behind) => Some(remote_button::render_pull_button(
660 keybinding_target,
661 id,
662 ahead,
663 behind,
664 )),
665 },
666 Some(Upstream {
667 tracking: UpstreamTracking::Gone,
668 ..
669 }) => Some(remote_button::render_republish_button(
670 keybinding_target,
671 id,
672 )),
673 None => Some(remote_button::render_publish_button(keybinding_target, id)),
674 }
675}
676
677mod remote_button {
678 use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
679 use ui::{
680 App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
681 IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
682 PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
683 };
684
685 pub fn render_fetch_button(
686 keybinding_target: Option<FocusHandle>,
687 id: SharedString,
688 ) -> SplitButton {
689 split_button(
690 id,
691 "Fetch",
692 0,
693 0,
694 Some(IconName::ArrowCircle),
695 keybinding_target.clone(),
696 move |_, window, cx| {
697 window.dispatch_action(Box::new(git::Fetch), cx);
698 },
699 move |_window, cx| {
700 git_action_tooltip(
701 "Fetch updates from remote",
702 &git::Fetch,
703 "git fetch",
704 keybinding_target.clone(),
705 cx,
706 )
707 },
708 )
709 }
710
711 pub fn render_push_button(
712 keybinding_target: Option<FocusHandle>,
713 id: SharedString,
714 ahead: u32,
715 ) -> SplitButton {
716 split_button(
717 id,
718 "Push",
719 ahead as usize,
720 0,
721 None,
722 keybinding_target.clone(),
723 move |_, window, cx| {
724 window.dispatch_action(Box::new(git::Push), cx);
725 },
726 move |_window, cx| {
727 git_action_tooltip(
728 "Push committed changes to remote",
729 &git::Push,
730 "git push",
731 keybinding_target.clone(),
732 cx,
733 )
734 },
735 )
736 }
737
738 pub fn render_pull_button(
739 keybinding_target: Option<FocusHandle>,
740 id: SharedString,
741 ahead: u32,
742 behind: u32,
743 ) -> SplitButton {
744 split_button(
745 id,
746 "Pull",
747 ahead as usize,
748 behind as usize,
749 None,
750 keybinding_target.clone(),
751 move |_, window, cx| {
752 window.dispatch_action(Box::new(git::Pull), cx);
753 },
754 move |_window, cx| {
755 git_action_tooltip(
756 "Pull",
757 &git::Pull,
758 "git pull",
759 keybinding_target.clone(),
760 cx,
761 )
762 },
763 )
764 }
765
766 pub fn render_publish_button(
767 keybinding_target: Option<FocusHandle>,
768 id: SharedString,
769 ) -> SplitButton {
770 split_button(
771 id,
772 "Publish",
773 0,
774 0,
775 Some(IconName::ExpandUp),
776 keybinding_target.clone(),
777 move |_, window, cx| {
778 window.dispatch_action(Box::new(git::Push), cx);
779 },
780 move |_window, cx| {
781 git_action_tooltip(
782 "Publish branch to remote",
783 &git::Push,
784 "git push --set-upstream",
785 keybinding_target.clone(),
786 cx,
787 )
788 },
789 )
790 }
791
792 pub fn render_republish_button(
793 keybinding_target: Option<FocusHandle>,
794 id: SharedString,
795 ) -> SplitButton {
796 split_button(
797 id,
798 "Republish",
799 0,
800 0,
801 Some(IconName::ExpandUp),
802 keybinding_target.clone(),
803 move |_, window, cx| {
804 window.dispatch_action(Box::new(git::Push), cx);
805 },
806 move |_window, cx| {
807 git_action_tooltip(
808 "Re-publish branch to remote",
809 &git::Push,
810 "git push --set-upstream",
811 keybinding_target.clone(),
812 cx,
813 )
814 },
815 )
816 }
817
818 fn git_action_tooltip(
819 label: impl Into<SharedString>,
820 action: &dyn Action,
821 command: impl Into<SharedString>,
822 focus_handle: Option<FocusHandle>,
823 cx: &mut App,
824 ) -> AnyView {
825 let label = label.into();
826 let command = command.into();
827
828 if let Some(handle) = focus_handle {
829 Tooltip::with_meta_in(label, Some(action), command, &handle, cx)
830 } else {
831 Tooltip::with_meta(label, Some(action), command, cx)
832 }
833 }
834
835 fn render_git_action_menu(
836 id: impl Into<ElementId>,
837 keybinding_target: Option<FocusHandle>,
838 ) -> impl IntoElement {
839 PopoverMenu::new(id.into())
840 .trigger(
841 ui::ButtonLike::new_rounded_right("split-button-right")
842 .layer(ui::ElevationIndex::ModalSurface)
843 .size(ui::ButtonSize::None)
844 .child(
845 div()
846 .px_1()
847 .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
848 ),
849 )
850 .menu(move |window, cx| {
851 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
852 context_menu
853 .when_some(keybinding_target.clone(), |el, keybinding_target| {
854 el.context(keybinding_target)
855 })
856 .action("Fetch", git::Fetch.boxed_clone())
857 .action("Fetch From", git::FetchFrom.boxed_clone())
858 .action("Pull", git::Pull.boxed_clone())
859 .action("Pull (Rebase)", git::PullRebase.boxed_clone())
860 .separator()
861 .action("Push", git::Push.boxed_clone())
862 .action("Push To", git::PushTo.boxed_clone())
863 .action("Force Push", git::ForcePush.boxed_clone())
864 }))
865 })
866 .anchor(Corner::TopRight)
867 }
868
869 #[allow(clippy::too_many_arguments)]
870 fn split_button(
871 id: SharedString,
872 left_label: impl Into<SharedString>,
873 ahead_count: usize,
874 behind_count: usize,
875 left_icon: Option<IconName>,
876 keybinding_target: Option<FocusHandle>,
877 left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
878 tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
879 ) -> SplitButton {
880 fn count(count: usize) -> impl IntoElement {
881 h_flex()
882 .ml_neg_px()
883 .h(rems(0.875))
884 .items_center()
885 .overflow_hidden()
886 .px_0p5()
887 .child(
888 Label::new(count.to_string())
889 .size(LabelSize::XSmall)
890 .line_height_style(LineHeightStyle::UiLabel),
891 )
892 }
893
894 let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
895
896 let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
897 format!("split-button-left-{}", id).into(),
898 ))
899 .layer(ui::ElevationIndex::ModalSurface)
900 .size(ui::ButtonSize::Compact)
901 .when(should_render_counts, |this| {
902 this.child(
903 h_flex()
904 .ml_neg_0p5()
905 .when(behind_count > 0, |this| {
906 this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
907 .child(count(behind_count))
908 })
909 .when(ahead_count > 0, |this| {
910 this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
911 .child(count(ahead_count))
912 }),
913 )
914 })
915 .when_some(left_icon, |this, left_icon| {
916 this.child(
917 h_flex()
918 .ml_neg_0p5()
919 .child(Icon::new(left_icon).size(IconSize::XSmall)),
920 )
921 })
922 .child(
923 div()
924 .child(Label::new(left_label).size(LabelSize::Small))
925 .mr_0p5(),
926 )
927 .on_click(left_on_click)
928 .tooltip(tooltip);
929
930 let right = render_git_action_menu(
931 ElementId::Name(format!("split-button-right-{}", id).into()),
932 keybinding_target,
933 )
934 .into_any_element();
935
936 SplitButton::new(left, right)
937 }
938}
939
940/// A visual representation of a file's Git status.
941#[derive(IntoElement, RegisterComponent)]
942pub struct GitStatusIcon {
943 status: FileStatus,
944}
945
946impl GitStatusIcon {
947 pub fn new(status: FileStatus) -> Self {
948 Self { status }
949 }
950}
951
952impl RenderOnce for GitStatusIcon {
953 fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
954 let status = self.status;
955
956 let (icon_name, color) = if status.is_conflicted() {
957 (
958 IconName::Warning,
959 cx.theme().colors().version_control_conflict,
960 )
961 } else if status.is_deleted() {
962 (
963 IconName::SquareMinus,
964 cx.theme().colors().version_control_deleted,
965 )
966 } else if status.is_modified() {
967 (
968 IconName::SquareDot,
969 cx.theme().colors().version_control_modified,
970 )
971 } else {
972 (
973 IconName::SquarePlus,
974 cx.theme().colors().version_control_added,
975 )
976 };
977
978 Icon::new(icon_name).color(Color::Custom(color))
979 }
980}
981
982// View this component preview using `workspace: open component-preview`
983impl Component for GitStatusIcon {
984 fn scope() -> ComponentScope {
985 ComponentScope::VersionControl
986 }
987
988 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
989 fn tracked_file_status(code: StatusCode) -> FileStatus {
990 FileStatus::Tracked(git::status::TrackedStatus {
991 index_status: code,
992 worktree_status: code,
993 })
994 }
995
996 let modified = tracked_file_status(StatusCode::Modified);
997 let added = tracked_file_status(StatusCode::Added);
998 let deleted = tracked_file_status(StatusCode::Deleted);
999 let conflict = UnmergedStatus {
1000 first_head: UnmergedStatusCode::Updated,
1001 second_head: UnmergedStatusCode::Updated,
1002 }
1003 .into();
1004
1005 Some(
1006 v_flex()
1007 .gap_6()
1008 .children(vec![example_group(vec![
1009 single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
1010 single_example("Added", GitStatusIcon::new(added).into_any_element()),
1011 single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
1012 single_example(
1013 "Conflicted",
1014 GitStatusIcon::new(conflict).into_any_element(),
1015 ),
1016 ])])
1017 .into_any_element(),
1018 )
1019 }
1020}
1021
1022struct GitCloneModal {
1023 panel: Entity<GitPanel>,
1024 repo_input: Entity<Editor>,
1025 focus_handle: FocusHandle,
1026}
1027
1028impl GitCloneModal {
1029 pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
1030 let repo_input = cx.new(|cx| {
1031 let mut editor = Editor::single_line(window, cx);
1032 editor.set_placeholder_text("Enter repository URL…", window, cx);
1033 editor
1034 });
1035 let focus_handle = repo_input.focus_handle(cx);
1036
1037 window.focus(&focus_handle, cx);
1038
1039 Self {
1040 panel,
1041 repo_input,
1042 focus_handle,
1043 }
1044 }
1045}
1046
1047impl Focusable for GitCloneModal {
1048 fn focus_handle(&self, _: &App) -> FocusHandle {
1049 self.focus_handle.clone()
1050 }
1051}
1052
1053impl Render for GitCloneModal {
1054 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1055 div()
1056 .elevation_3(cx)
1057 .w(rems(34.))
1058 .flex_1()
1059 .overflow_hidden()
1060 .child(
1061 div()
1062 .w_full()
1063 .p_2()
1064 .border_b_1()
1065 .border_color(cx.theme().colors().border_variant)
1066 .child(self.repo_input.clone()),
1067 )
1068 .child(
1069 h_flex()
1070 .w_full()
1071 .p_2()
1072 .gap_0p5()
1073 .rounded_b_sm()
1074 .bg(cx.theme().colors().editor_background)
1075 .child(
1076 Label::new("Clone a repository from GitHub or other sources.")
1077 .color(Color::Muted)
1078 .size(LabelSize::Small),
1079 )
1080 .child(
1081 Button::new("learn-more", "Learn More")
1082 .label_size(LabelSize::Small)
1083 .end_icon(Icon::new(IconName::ArrowUpRight).size(IconSize::XSmall))
1084 .on_click(|_, _, cx| {
1085 cx.open_url("https://github.com/git-guides/git-clone");
1086 }),
1087 ),
1088 )
1089 .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
1090 cx.emit(DismissEvent);
1091 }))
1092 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
1093 let repo = this.repo_input.read(cx).text(cx);
1094 this.panel.update(cx, |panel, cx| {
1095 panel.git_clone(repo, window, cx);
1096 });
1097 cx.emit(DismissEvent);
1098 }))
1099 }
1100}
1101
1102impl EventEmitter<DismissEvent> for GitCloneModal {}
1103
1104impl ModalView for GitCloneModal {}
1105
1106#[cfg(test)]
1107mod view_commit_tests {
1108 use super::*;
1109 use gpui::{TestAppContext, VisualTestContext, WindowHandle};
1110 use language::language_settings::AllLanguageSettings;
1111 use project::project_settings::ProjectSettings;
1112 use project::{FakeFs, Project, WorktreeSettings};
1113 use serde_json::json;
1114 use settings::{Settings as _, SettingsStore};
1115 use std::path::Path;
1116 use std::sync::Arc;
1117 use theme::LoadThemes;
1118 use util::path;
1119 use workspace::WorkspaceSettings;
1120
1121 fn init_test(cx: &mut TestAppContext) {
1122 zlog::init_test();
1123 cx.update(|cx| {
1124 let settings_store = SettingsStore::test(cx);
1125 cx.set_global(settings_store);
1126 theme_settings::init(LoadThemes::JustBase, cx);
1127 AllLanguageSettings::register(cx);
1128 editor::init(cx);
1129 ProjectSettings::register(cx);
1130 WorktreeSettings::register(cx);
1131 WorkspaceSettings::register(cx);
1132 });
1133 }
1134
1135 async fn setup_git_repo(cx: &mut TestAppContext) -> Arc<FakeFs> {
1136 let fs = FakeFs::new(cx.background_executor.clone());
1137 fs.insert_tree(
1138 "/root",
1139 json!({
1140 "project": {
1141 ".git": {},
1142 "src": {
1143 "main.rs": "fn main() {}"
1144 }
1145 }
1146 }),
1147 )
1148 .await;
1149 fs
1150 }
1151
1152 async fn create_test_workspace(
1153 fs: Arc<FakeFs>,
1154 cx: &mut TestAppContext,
1155 ) -> (Entity<Project>, WindowHandle<Workspace>) {
1156 let project = Project::test(fs.clone(), [Path::new(path!("/root/project"))], cx).await;
1157 let workspace =
1158 cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1159 cx.read(|cx| {
1160 project
1161 .read(cx)
1162 .worktrees(cx)
1163 .next()
1164 .unwrap()
1165 .read(cx)
1166 .as_local()
1167 .unwrap()
1168 .scan_complete()
1169 })
1170 .await;
1171 (project, workspace)
1172 }
1173
1174 #[gpui::test]
1175 async fn test_show_ref_picker_with_repository(cx: &mut TestAppContext) {
1176 init_test(cx);
1177 let fs = setup_git_repo(cx).await;
1178
1179 fs.set_status_for_repo(
1180 Path::new("/root/project/.git"),
1181 &[("src/main.rs", git::status::StatusCode::Modified.worktree())],
1182 );
1183
1184 let (_project, workspace) = create_test_workspace(fs, cx).await;
1185 let cx = &mut VisualTestContext::from_window(*workspace, cx);
1186
1187 let initial_modal_state = workspace
1188 .read_with(cx, |workspace, cx| {
1189 workspace.active_modal::<RefPickerModal>(cx).is_some()
1190 })
1191 .unwrap_or(false);
1192
1193 let _ = workspace.update(cx, |workspace, window, cx| {
1194 show_ref_picker(workspace, &git::ViewCommit, window, cx);
1195 });
1196
1197 let final_modal_state = workspace
1198 .read_with(cx, |workspace, cx| {
1199 workspace.active_modal::<RefPickerModal>(cx).is_some()
1200 })
1201 .unwrap_or(false);
1202
1203 assert!(!initial_modal_state);
1204 assert!(final_modal_state);
1205 }
1206}