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