1use std::any::Any;
2
3use ::settings::Settings;
4use command_palette_hooks::CommandPaletteFilter;
5use commit_modal::CommitModal;
6use editor::{Editor, actions::DiffClipboardWithSelectionData};
7mod blame_ui;
8use git::{
9 repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
10 status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
11};
12use git_panel_settings::GitPanelSettings;
13use gpui::{
14 Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Window,
15 actions,
16};
17use onboarding::GitOnboardingModal;
18use project_diff::ProjectDiff;
19use ui::prelude::*;
20use workspace::{ModalView, Workspace};
21use zed_actions;
22
23use crate::{git_panel::GitPanel, text_diff_view::TextDiffView};
24
25mod askpass_modal;
26pub mod branch_picker;
27mod commit_modal;
28pub mod commit_tooltip;
29mod commit_view;
30mod conflict_view;
31pub mod file_diff_view;
32pub mod git_panel;
33mod git_panel_settings;
34pub mod onboarding;
35pub mod picker_prompt;
36pub mod project_diff;
37pub(crate) mod remote_output;
38pub mod repository_selector;
39pub mod text_diff_view;
40
41actions!(
42 git,
43 [
44 /// Resets the git onboarding state to show the tutorial again.
45 ResetOnboarding
46 ]
47);
48
49pub fn init(cx: &mut App) {
50 GitPanelSettings::register(cx);
51
52 editor::set_blame_renderer(blame_ui::GitBlameRenderer, cx);
53
54 cx.observe_new(|editor: &mut Editor, _, cx| {
55 conflict_view::register_editor(editor, editor.buffer().clone(), cx);
56 })
57 .detach();
58
59 cx.observe_new(|workspace: &mut Workspace, _, cx| {
60 ProjectDiff::register(workspace, cx);
61 CommitModal::register(workspace);
62 git_panel::register(workspace);
63 repository_selector::register(workspace);
64 branch_picker::register(workspace);
65
66 let project = workspace.project().read(cx);
67 if project.is_read_only(cx) {
68 return;
69 }
70 if !project.is_via_collab() {
71 workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
72 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
73 return;
74 };
75 panel.update(cx, |panel, cx| {
76 panel.fetch(true, window, cx);
77 });
78 });
79 workspace.register_action(|workspace, _: &git::FetchFrom, window, cx| {
80 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
81 return;
82 };
83 panel.update(cx, |panel, cx| {
84 panel.fetch(false, window, cx);
85 });
86 });
87 workspace.register_action(|workspace, _: &git::Push, window, cx| {
88 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
89 return;
90 };
91 panel.update(cx, |panel, cx| {
92 panel.push(false, false, window, cx);
93 });
94 });
95 workspace.register_action(|workspace, _: &git::PushTo, window, cx| {
96 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
97 return;
98 };
99 panel.update(cx, |panel, cx| {
100 panel.push(false, true, window, cx);
101 });
102 });
103 workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
104 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
105 return;
106 };
107 panel.update(cx, |panel, cx| {
108 panel.push(true, false, window, cx);
109 });
110 });
111 workspace.register_action(|workspace, _: &git::Pull, window, cx| {
112 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
113 return;
114 };
115 panel.update(cx, |panel, cx| {
116 panel.pull(window, cx);
117 });
118 });
119 }
120 workspace.register_action(|workspace, action: &git::StashAll, window, cx| {
121 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
122 return;
123 };
124 panel.update(cx, |panel, cx| {
125 panel.stash_all(action, window, cx);
126 });
127 });
128 workspace.register_action(|workspace, action: &git::StashPop, window, cx| {
129 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
130 return;
131 };
132 panel.update(cx, |panel, cx| {
133 panel.stash_pop(action, window, cx);
134 });
135 });
136 workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
137 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
138 return;
139 };
140 panel.update(cx, |panel, cx| {
141 panel.stage_all(action, window, cx);
142 });
143 });
144 workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
145 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
146 return;
147 };
148 panel.update(cx, |panel, cx| {
149 panel.unstage_all(action, window, cx);
150 });
151 });
152 CommandPaletteFilter::update_global(cx, |filter, _cx| {
153 filter.hide_action_types(&[
154 zed_actions::OpenGitIntegrationOnboarding.type_id(),
155 // ResetOnboarding.type_id(),
156 ]);
157 });
158 workspace.register_action(
159 move |workspace, _: &zed_actions::OpenGitIntegrationOnboarding, window, cx| {
160 GitOnboardingModal::toggle(workspace, window, cx)
161 },
162 );
163 workspace.register_action(move |_, _: &ResetOnboarding, window, cx| {
164 window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
165 window.refresh();
166 });
167 workspace.register_action(|workspace, _action: &git::Init, window, cx| {
168 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
169 return;
170 };
171 panel.update(cx, |panel, cx| {
172 panel.git_init(window, cx);
173 });
174 });
175 workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
176 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
177 return;
178 };
179
180 workspace.toggle_modal(window, cx, |window, cx| {
181 GitCloneModal::show(panel, window, cx)
182 });
183 });
184 workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
185 open_modified_files(workspace, window, cx);
186 });
187 workspace.register_action(
188 |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
189 if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
190 task.detach();
191 };
192 },
193 );
194 })
195 .detach();
196}
197
198fn open_modified_files(
199 workspace: &mut Workspace,
200 window: &mut Window,
201 cx: &mut Context<Workspace>,
202) {
203 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
204 return;
205 };
206 let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
207 let Some(repo) = panel.active_repository.as_ref() else {
208 return Vec::new();
209 };
210 let repo = repo.read(cx);
211 repo.cached_status()
212 .filter_map(|entry| {
213 if entry.status.is_modified() {
214 repo.repo_path_to_project_path(&entry.repo_path, cx)
215 } else {
216 None
217 }
218 })
219 .collect()
220 });
221 for path in modified_paths {
222 workspace.open_path(path, None, true, window, cx).detach();
223 }
224}
225
226pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
227 GitStatusIcon::new(status)
228}
229
230fn render_remote_button(
231 id: impl Into<SharedString>,
232 branch: &Branch,
233 keybinding_target: Option<FocusHandle>,
234 show_fetch_button: bool,
235) -> Option<impl IntoElement> {
236 let id = id.into();
237 let upstream = branch.upstream.as_ref();
238 match upstream {
239 Some(Upstream {
240 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
241 ..
242 }) => match (*ahead, *behind) {
243 (0, 0) if show_fetch_button => {
244 Some(remote_button::render_fetch_button(keybinding_target, id))
245 }
246 (0, 0) => None,
247 (ahead, 0) => Some(remote_button::render_push_button(
248 keybinding_target.clone(),
249 id,
250 ahead,
251 )),
252 (ahead, behind) => Some(remote_button::render_pull_button(
253 keybinding_target.clone(),
254 id,
255 ahead,
256 behind,
257 )),
258 },
259 Some(Upstream {
260 tracking: UpstreamTracking::Gone,
261 ..
262 }) => Some(remote_button::render_republish_button(
263 keybinding_target,
264 id,
265 )),
266 None => Some(remote_button::render_publish_button(keybinding_target, id)),
267 }
268}
269
270mod remote_button {
271 use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
272 use ui::{
273 App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
274 IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
275 PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
276 };
277
278 pub fn render_fetch_button(
279 keybinding_target: Option<FocusHandle>,
280 id: SharedString,
281 ) -> SplitButton {
282 split_button(
283 id,
284 "Fetch",
285 0,
286 0,
287 Some(IconName::ArrowCircle),
288 keybinding_target.clone(),
289 move |_, window, cx| {
290 window.dispatch_action(Box::new(git::Fetch), cx);
291 },
292 move |window, cx| {
293 git_action_tooltip(
294 "Fetch updates from remote",
295 &git::Fetch,
296 "git fetch",
297 keybinding_target.clone(),
298 window,
299 cx,
300 )
301 },
302 )
303 }
304
305 pub fn render_push_button(
306 keybinding_target: Option<FocusHandle>,
307 id: SharedString,
308 ahead: u32,
309 ) -> SplitButton {
310 split_button(
311 id,
312 "Push",
313 ahead as usize,
314 0,
315 None,
316 keybinding_target.clone(),
317 move |_, window, cx| {
318 window.dispatch_action(Box::new(git::Push), cx);
319 },
320 move |window, cx| {
321 git_action_tooltip(
322 "Push committed changes to remote",
323 &git::Push,
324 "git push",
325 keybinding_target.clone(),
326 window,
327 cx,
328 )
329 },
330 )
331 }
332
333 pub fn render_pull_button(
334 keybinding_target: Option<FocusHandle>,
335 id: SharedString,
336 ahead: u32,
337 behind: u32,
338 ) -> SplitButton {
339 split_button(
340 id,
341 "Pull",
342 ahead as usize,
343 behind as usize,
344 None,
345 keybinding_target.clone(),
346 move |_, window, cx| {
347 window.dispatch_action(Box::new(git::Pull), cx);
348 },
349 move |window, cx| {
350 git_action_tooltip(
351 "Pull",
352 &git::Pull,
353 "git pull",
354 keybinding_target.clone(),
355 window,
356 cx,
357 )
358 },
359 )
360 }
361
362 pub fn render_publish_button(
363 keybinding_target: Option<FocusHandle>,
364 id: SharedString,
365 ) -> SplitButton {
366 split_button(
367 id,
368 "Publish",
369 0,
370 0,
371 Some(IconName::ExpandUp),
372 keybinding_target.clone(),
373 move |_, window, cx| {
374 window.dispatch_action(Box::new(git::Push), cx);
375 },
376 move |window, cx| {
377 git_action_tooltip(
378 "Publish branch to remote",
379 &git::Push,
380 "git push --set-upstream",
381 keybinding_target.clone(),
382 window,
383 cx,
384 )
385 },
386 )
387 }
388
389 pub fn render_republish_button(
390 keybinding_target: Option<FocusHandle>,
391 id: SharedString,
392 ) -> SplitButton {
393 split_button(
394 id,
395 "Republish",
396 0,
397 0,
398 Some(IconName::ExpandUp),
399 keybinding_target.clone(),
400 move |_, window, cx| {
401 window.dispatch_action(Box::new(git::Push), cx);
402 },
403 move |window, cx| {
404 git_action_tooltip(
405 "Re-publish branch to remote",
406 &git::Push,
407 "git push --set-upstream",
408 keybinding_target.clone(),
409 window,
410 cx,
411 )
412 },
413 )
414 }
415
416 fn git_action_tooltip(
417 label: impl Into<SharedString>,
418 action: &dyn Action,
419 command: impl Into<SharedString>,
420 focus_handle: Option<FocusHandle>,
421 window: &mut Window,
422 cx: &mut App,
423 ) -> AnyView {
424 let label = label.into();
425 let command = command.into();
426
427 if let Some(handle) = focus_handle {
428 Tooltip::with_meta_in(
429 label.clone(),
430 Some(action),
431 command.clone(),
432 &handle,
433 window,
434 cx,
435 )
436 } else {
437 Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
438 }
439 }
440
441 fn render_git_action_menu(
442 id: impl Into<ElementId>,
443 keybinding_target: Option<FocusHandle>,
444 ) -> impl IntoElement {
445 PopoverMenu::new(id.into())
446 .trigger(
447 ui::ButtonLike::new_rounded_right("split-button-right")
448 .layer(ui::ElevationIndex::ModalSurface)
449 .size(ui::ButtonSize::None)
450 .child(
451 div()
452 .px_1()
453 .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
454 ),
455 )
456 .menu(move |window, cx| {
457 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
458 context_menu
459 .when_some(keybinding_target.clone(), |el, keybinding_target| {
460 el.context(keybinding_target.clone())
461 })
462 .action("Fetch", git::Fetch.boxed_clone())
463 .action("Fetch From", git::FetchFrom.boxed_clone())
464 .action("Pull", git::Pull.boxed_clone())
465 .separator()
466 .action("Push", git::Push.boxed_clone())
467 .action("Push To", git::PushTo.boxed_clone())
468 .action("Force Push", git::ForcePush.boxed_clone())
469 }))
470 })
471 .anchor(Corner::TopRight)
472 }
473
474 #[allow(clippy::too_many_arguments)]
475 fn split_button(
476 id: SharedString,
477 left_label: impl Into<SharedString>,
478 ahead_count: usize,
479 behind_count: usize,
480 left_icon: Option<IconName>,
481 keybinding_target: Option<FocusHandle>,
482 left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
483 tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
484 ) -> SplitButton {
485 fn count(count: usize) -> impl IntoElement {
486 h_flex()
487 .ml_neg_px()
488 .h(rems(0.875))
489 .items_center()
490 .overflow_hidden()
491 .px_0p5()
492 .child(
493 Label::new(count.to_string())
494 .size(LabelSize::XSmall)
495 .line_height_style(LineHeightStyle::UiLabel),
496 )
497 }
498
499 let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
500
501 let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
502 format!("split-button-left-{}", id).into(),
503 ))
504 .layer(ui::ElevationIndex::ModalSurface)
505 .size(ui::ButtonSize::Compact)
506 .when(should_render_counts, |this| {
507 this.child(
508 h_flex()
509 .ml_neg_0p5()
510 .mr_1()
511 .when(behind_count > 0, |this| {
512 this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
513 .child(count(behind_count))
514 })
515 .when(ahead_count > 0, |this| {
516 this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
517 .child(count(ahead_count))
518 }),
519 )
520 })
521 .when_some(left_icon, |this, left_icon| {
522 this.child(
523 h_flex()
524 .ml_neg_0p5()
525 .mr_1()
526 .child(Icon::new(left_icon).size(IconSize::XSmall)),
527 )
528 })
529 .child(
530 div()
531 .child(Label::new(left_label).size(LabelSize::Small))
532 .mr_0p5(),
533 )
534 .on_click(left_on_click)
535 .tooltip(tooltip);
536
537 let right = render_git_action_menu(
538 ElementId::Name(format!("split-button-right-{}", id).into()),
539 keybinding_target,
540 )
541 .into_any_element();
542
543 SplitButton::new(left, right)
544 }
545}
546
547/// A visual representation of a file's Git status.
548#[derive(IntoElement, RegisterComponent)]
549pub struct GitStatusIcon {
550 status: FileStatus,
551}
552
553impl GitStatusIcon {
554 pub fn new(status: FileStatus) -> Self {
555 Self { status }
556 }
557}
558
559impl RenderOnce for GitStatusIcon {
560 fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
561 let status = self.status;
562
563 let (icon_name, color) = if status.is_conflicted() {
564 (
565 IconName::Warning,
566 cx.theme().colors().version_control_conflict,
567 )
568 } else if status.is_deleted() {
569 (
570 IconName::SquareMinus,
571 cx.theme().colors().version_control_deleted,
572 )
573 } else if status.is_modified() {
574 (
575 IconName::SquareDot,
576 cx.theme().colors().version_control_modified,
577 )
578 } else {
579 (
580 IconName::SquarePlus,
581 cx.theme().colors().version_control_added,
582 )
583 };
584
585 Icon::new(icon_name).color(Color::Custom(color))
586 }
587}
588
589// View this component preview using `workspace: open component-preview`
590impl Component for GitStatusIcon {
591 fn scope() -> ComponentScope {
592 ComponentScope::VersionControl
593 }
594
595 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
596 fn tracked_file_status(code: StatusCode) -> FileStatus {
597 FileStatus::Tracked(git::status::TrackedStatus {
598 index_status: code,
599 worktree_status: code,
600 })
601 }
602
603 let modified = tracked_file_status(StatusCode::Modified);
604 let added = tracked_file_status(StatusCode::Added);
605 let deleted = tracked_file_status(StatusCode::Deleted);
606 let conflict = UnmergedStatus {
607 first_head: UnmergedStatusCode::Updated,
608 second_head: UnmergedStatusCode::Updated,
609 }
610 .into();
611
612 Some(
613 v_flex()
614 .gap_6()
615 .children(vec![example_group(vec![
616 single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
617 single_example("Added", GitStatusIcon::new(added).into_any_element()),
618 single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
619 single_example(
620 "Conflicted",
621 GitStatusIcon::new(conflict).into_any_element(),
622 ),
623 ])])
624 .into_any_element(),
625 )
626 }
627}
628
629struct GitCloneModal {
630 panel: Entity<GitPanel>,
631 repo_input: Entity<Editor>,
632 focus_handle: FocusHandle,
633}
634
635impl GitCloneModal {
636 pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
637 let repo_input = cx.new(|cx| {
638 let mut editor = Editor::single_line(window, cx);
639 editor.set_placeholder_text("Enter repository URL…", cx);
640 editor
641 });
642 let focus_handle = repo_input.focus_handle(cx);
643
644 window.focus(&focus_handle);
645
646 Self {
647 panel,
648 repo_input,
649 focus_handle,
650 }
651 }
652}
653
654impl Focusable for GitCloneModal {
655 fn focus_handle(&self, _: &App) -> FocusHandle {
656 self.focus_handle.clone()
657 }
658}
659
660impl Render for GitCloneModal {
661 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
662 div()
663 .elevation_3(cx)
664 .w(rems(34.))
665 .flex_1()
666 .overflow_hidden()
667 .child(
668 div()
669 .w_full()
670 .p_2()
671 .border_b_1()
672 .border_color(cx.theme().colors().border_variant)
673 .child(self.repo_input.clone()),
674 )
675 .child(
676 h_flex()
677 .w_full()
678 .p_2()
679 .gap_0p5()
680 .rounded_b_sm()
681 .bg(cx.theme().colors().editor_background)
682 .child(
683 Label::new("Clone a repository from GitHub or other sources.")
684 .color(Color::Muted)
685 .size(LabelSize::Small),
686 )
687 .child(
688 Button::new("learn-more", "Learn More")
689 .label_size(LabelSize::Small)
690 .icon(IconName::ArrowUpRight)
691 .icon_size(IconSize::XSmall)
692 .on_click(|_, _, cx| {
693 cx.open_url("https://github.com/git-guides/git-clone");
694 }),
695 ),
696 )
697 .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
698 cx.emit(DismissEvent);
699 }))
700 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
701 let repo = this.repo_input.read(cx).text(cx);
702 this.panel.update(cx, |panel, cx| {
703 panel.git_clone(repo, window, cx);
704 });
705 cx.emit(DismissEvent);
706 }))
707 }
708}
709
710impl EventEmitter<DismissEvent> for GitCloneModal {}
711
712impl ModalView for GitCloneModal {}