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