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