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 // panel.update(cx, |panel, cx| {
186 // panel.git_clone(window, cx);
187 // });
188 });
189 workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
190 open_modified_files(workspace, window, cx);
191 });
192 workspace.register_action(
193 |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
194 if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
195 task.detach();
196 };
197 },
198 );
199 })
200 .detach();
201}
202
203fn open_modified_files(
204 workspace: &mut Workspace,
205 window: &mut Window,
206 cx: &mut Context<Workspace>,
207) {
208 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
209 return;
210 };
211 let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
212 let Some(repo) = panel.active_repository.as_ref() else {
213 return Vec::new();
214 };
215 let repo = repo.read(cx);
216 repo.cached_status()
217 .filter_map(|entry| {
218 if entry.status.is_modified() {
219 repo.repo_path_to_project_path(&entry.repo_path, cx)
220 } else {
221 None
222 }
223 })
224 .collect()
225 });
226 for path in modified_paths {
227 workspace.open_path(path, None, true, window, cx).detach();
228 }
229}
230
231pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
232 GitStatusIcon::new(status)
233}
234
235fn render_remote_button(
236 id: impl Into<SharedString>,
237 branch: &Branch,
238 keybinding_target: Option<FocusHandle>,
239 show_fetch_button: bool,
240) -> Option<impl IntoElement> {
241 let id = id.into();
242 let upstream = branch.upstream.as_ref();
243 match upstream {
244 Some(Upstream {
245 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
246 ..
247 }) => match (*ahead, *behind) {
248 (0, 0) if show_fetch_button => {
249 Some(remote_button::render_fetch_button(keybinding_target, id))
250 }
251 (0, 0) => None,
252 (ahead, 0) => Some(remote_button::render_push_button(
253 keybinding_target.clone(),
254 id,
255 ahead,
256 )),
257 (ahead, behind) => Some(remote_button::render_pull_button(
258 keybinding_target.clone(),
259 id,
260 ahead,
261 behind,
262 )),
263 },
264 Some(Upstream {
265 tracking: UpstreamTracking::Gone,
266 ..
267 }) => Some(remote_button::render_republish_button(
268 keybinding_target,
269 id,
270 )),
271 None => Some(remote_button::render_publish_button(keybinding_target, id)),
272 }
273}
274
275mod remote_button {
276 use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
277 use ui::{
278 App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
279 IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
280 PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
281 };
282
283 pub fn render_fetch_button(
284 keybinding_target: Option<FocusHandle>,
285 id: SharedString,
286 ) -> SplitButton {
287 split_button(
288 id,
289 "Fetch",
290 0,
291 0,
292 Some(IconName::ArrowCircle),
293 keybinding_target.clone(),
294 move |_, window, cx| {
295 window.dispatch_action(Box::new(git::Fetch), cx);
296 },
297 move |window, cx| {
298 git_action_tooltip(
299 "Fetch updates from remote",
300 &git::Fetch,
301 "git fetch",
302 keybinding_target.clone(),
303 window,
304 cx,
305 )
306 },
307 )
308 }
309
310 pub fn render_push_button(
311 keybinding_target: Option<FocusHandle>,
312 id: SharedString,
313 ahead: u32,
314 ) -> SplitButton {
315 split_button(
316 id,
317 "Push",
318 ahead as usize,
319 0,
320 None,
321 keybinding_target.clone(),
322 move |_, window, cx| {
323 window.dispatch_action(Box::new(git::Push), cx);
324 },
325 move |window, cx| {
326 git_action_tooltip(
327 "Push committed changes to remote",
328 &git::Push,
329 "git push",
330 keybinding_target.clone(),
331 window,
332 cx,
333 )
334 },
335 )
336 }
337
338 pub fn render_pull_button(
339 keybinding_target: Option<FocusHandle>,
340 id: SharedString,
341 ahead: u32,
342 behind: u32,
343 ) -> SplitButton {
344 split_button(
345 id,
346 "Pull",
347 ahead as usize,
348 behind as usize,
349 None,
350 keybinding_target.clone(),
351 move |_, window, cx| {
352 window.dispatch_action(Box::new(git::Pull), cx);
353 },
354 move |window, cx| {
355 git_action_tooltip(
356 "Pull",
357 &git::Pull,
358 "git pull",
359 keybinding_target.clone(),
360 window,
361 cx,
362 )
363 },
364 )
365 }
366
367 pub fn render_publish_button(
368 keybinding_target: Option<FocusHandle>,
369 id: SharedString,
370 ) -> SplitButton {
371 split_button(
372 id,
373 "Publish",
374 0,
375 0,
376 Some(IconName::ExpandUp),
377 keybinding_target.clone(),
378 move |_, window, cx| {
379 window.dispatch_action(Box::new(git::Push), cx);
380 },
381 move |window, cx| {
382 git_action_tooltip(
383 "Publish branch to remote",
384 &git::Push,
385 "git push --set-upstream",
386 keybinding_target.clone(),
387 window,
388 cx,
389 )
390 },
391 )
392 }
393
394 pub fn render_republish_button(
395 keybinding_target: Option<FocusHandle>,
396 id: SharedString,
397 ) -> SplitButton {
398 split_button(
399 id,
400 "Republish",
401 0,
402 0,
403 Some(IconName::ExpandUp),
404 keybinding_target.clone(),
405 move |_, window, cx| {
406 window.dispatch_action(Box::new(git::Push), cx);
407 },
408 move |window, cx| {
409 git_action_tooltip(
410 "Re-publish branch to remote",
411 &git::Push,
412 "git push --set-upstream",
413 keybinding_target.clone(),
414 window,
415 cx,
416 )
417 },
418 )
419 }
420
421 fn git_action_tooltip(
422 label: impl Into<SharedString>,
423 action: &dyn Action,
424 command: impl Into<SharedString>,
425 focus_handle: Option<FocusHandle>,
426 window: &mut Window,
427 cx: &mut App,
428 ) -> AnyView {
429 let label = label.into();
430 let command = command.into();
431
432 if let Some(handle) = focus_handle {
433 Tooltip::with_meta_in(
434 label.clone(),
435 Some(action),
436 command.clone(),
437 &handle,
438 window,
439 cx,
440 )
441 } else {
442 Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
443 }
444 }
445
446 fn render_git_action_menu(
447 id: impl Into<ElementId>,
448 keybinding_target: Option<FocusHandle>,
449 ) -> impl IntoElement {
450 PopoverMenu::new(id.into())
451 .trigger(
452 ui::ButtonLike::new_rounded_right("split-button-right")
453 .layer(ui::ElevationIndex::ModalSurface)
454 .size(ui::ButtonSize::None)
455 .child(
456 div()
457 .px_1()
458 .child(Icon::new(IconName::ChevronDown).size(IconSize::XSmall)),
459 ),
460 )
461 .menu(move |window, cx| {
462 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
463 context_menu
464 .when_some(keybinding_target.clone(), |el, keybinding_target| {
465 el.context(keybinding_target.clone())
466 })
467 .action("Fetch", git::Fetch.boxed_clone())
468 .action("Fetch From", git::FetchFrom.boxed_clone())
469 .action("Pull", git::Pull.boxed_clone())
470 .separator()
471 .action("Push", git::Push.boxed_clone())
472 .action("Push To", git::PushTo.boxed_clone())
473 .action("Force Push", git::ForcePush.boxed_clone())
474 }))
475 })
476 .anchor(Corner::TopRight)
477 }
478
479 #[allow(clippy::too_many_arguments)]
480 fn split_button(
481 id: SharedString,
482 left_label: impl Into<SharedString>,
483 ahead_count: usize,
484 behind_count: usize,
485 left_icon: Option<IconName>,
486 keybinding_target: Option<FocusHandle>,
487 left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
488 tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
489 ) -> SplitButton {
490 fn count(count: usize) -> impl IntoElement {
491 h_flex()
492 .ml_neg_px()
493 .h(rems(0.875))
494 .items_center()
495 .overflow_hidden()
496 .px_0p5()
497 .child(
498 Label::new(count.to_string())
499 .size(LabelSize::XSmall)
500 .line_height_style(LineHeightStyle::UiLabel),
501 )
502 }
503
504 let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
505
506 let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
507 format!("split-button-left-{}", id).into(),
508 ))
509 .layer(ui::ElevationIndex::ModalSurface)
510 .size(ui::ButtonSize::Compact)
511 .when(should_render_counts, |this| {
512 this.child(
513 h_flex()
514 .ml_neg_0p5()
515 .mr_1()
516 .when(behind_count > 0, |this| {
517 this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
518 .child(count(behind_count))
519 })
520 .when(ahead_count > 0, |this| {
521 this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
522 .child(count(ahead_count))
523 }),
524 )
525 })
526 .when_some(left_icon, |this, left_icon| {
527 this.child(
528 h_flex()
529 .ml_neg_0p5()
530 .mr_1()
531 .child(Icon::new(left_icon).size(IconSize::XSmall)),
532 )
533 })
534 .child(
535 div()
536 .child(Label::new(left_label).size(LabelSize::Small))
537 .mr_0p5(),
538 )
539 .on_click(left_on_click)
540 .tooltip(tooltip);
541
542 let right = render_git_action_menu(
543 ElementId::Name(format!("split-button-right-{}", id).into()),
544 keybinding_target,
545 )
546 .into_any_element();
547
548 SplitButton::new(left, right)
549 }
550}
551
552/// A visual representation of a file's Git status.
553#[derive(IntoElement, RegisterComponent)]
554pub struct GitStatusIcon {
555 status: FileStatus,
556}
557
558impl GitStatusIcon {
559 pub fn new(status: FileStatus) -> Self {
560 Self { status }
561 }
562}
563
564impl RenderOnce for GitStatusIcon {
565 fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
566 let status = self.status;
567
568 let (icon_name, color) = if status.is_conflicted() {
569 (
570 IconName::Warning,
571 cx.theme().colors().version_control_conflict,
572 )
573 } else if status.is_deleted() {
574 (
575 IconName::SquareMinus,
576 cx.theme().colors().version_control_deleted,
577 )
578 } else if status.is_modified() {
579 (
580 IconName::SquareDot,
581 cx.theme().colors().version_control_modified,
582 )
583 } else {
584 (
585 IconName::SquarePlus,
586 cx.theme().colors().version_control_added,
587 )
588 };
589
590 Icon::new(icon_name).color(Color::Custom(color))
591 }
592}
593
594// View this component preview using `workspace: open component-preview`
595impl Component for GitStatusIcon {
596 fn scope() -> ComponentScope {
597 ComponentScope::VersionControl
598 }
599
600 fn preview(_window: &mut Window, _cx: &mut App) -> Option<AnyElement> {
601 fn tracked_file_status(code: StatusCode) -> FileStatus {
602 FileStatus::Tracked(git::status::TrackedStatus {
603 index_status: code,
604 worktree_status: code,
605 })
606 }
607
608 let modified = tracked_file_status(StatusCode::Modified);
609 let added = tracked_file_status(StatusCode::Added);
610 let deleted = tracked_file_status(StatusCode::Deleted);
611 let conflict = UnmergedStatus {
612 first_head: UnmergedStatusCode::Updated,
613 second_head: UnmergedStatusCode::Updated,
614 }
615 .into();
616
617 Some(
618 v_flex()
619 .gap_6()
620 .children(vec![example_group(vec![
621 single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
622 single_example("Added", GitStatusIcon::new(added).into_any_element()),
623 single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
624 single_example(
625 "Conflicted",
626 GitStatusIcon::new(conflict).into_any_element(),
627 ),
628 ])])
629 .into_any_element(),
630 )
631 }
632}
633
634struct GitCloneModal {
635 panel: Entity<GitPanel>,
636 repo_input: Entity<Editor>,
637 focus_handle: FocusHandle,
638}
639
640impl GitCloneModal {
641 pub fn show(panel: Entity<GitPanel>, window: &mut Window, cx: &mut Context<Self>) -> Self {
642 let repo_input = cx.new(|cx| {
643 let mut editor = Editor::single_line(window, cx);
644 editor.set_placeholder_text("Enter repository", cx);
645 editor
646 });
647 let focus_handle = repo_input.focus_handle(cx);
648
649 window.focus(&focus_handle);
650
651 Self {
652 panel,
653 repo_input,
654 focus_handle,
655 }
656 }
657
658 fn render_editor(&self, window: &Window, cx: &App) -> impl IntoElement {
659 let settings = ThemeSettings::get_global(cx);
660 let theme = cx.theme();
661
662 let text_style = TextStyle {
663 color: cx.theme().colors().text,
664 font_family: settings.buffer_font.family.clone(),
665 font_features: settings.buffer_font.features.clone(),
666 font_size: settings.buffer_font_size(cx).into(),
667 font_weight: settings.buffer_font.weight,
668 line_height: relative(settings.buffer_line_height.value()),
669 background_color: Some(theme.colors().editor_background),
670 ..Default::default()
671 };
672
673 let element = EditorElement::new(
674 &self.repo_input,
675 EditorStyle {
676 background: theme.colors().editor_background,
677 local_player: theme.players().local(),
678 text: text_style,
679 ..Default::default()
680 },
681 );
682
683 div()
684 .rounded_md()
685 .p_1()
686 .border_1()
687 .border_color(theme.colors().border_variant)
688 .when(
689 self.repo_input
690 .focus_handle(cx)
691 .contains_focused(window, cx),
692 |this| this.border_color(theme.colors().border_focused),
693 )
694 .child(element)
695 .bg(theme.colors().editor_background)
696 }
697}
698
699impl Focusable for GitCloneModal {
700 fn focus_handle(&self, _: &App) -> FocusHandle {
701 self.focus_handle.clone()
702 }
703}
704
705impl Render for GitCloneModal {
706 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
707 div()
708 .size_full()
709 .w(rems(34.))
710 .elevation_3(cx)
711 .child(self.render_editor(window, cx))
712 .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
713 cx.emit(DismissEvent);
714 }))
715 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
716 let repo = this.repo_input.read(cx).text(cx);
717 this.panel.update(cx, |panel, cx| {
718 panel.git_clone(repo, window, cx);
719 });
720 cx.emit(DismissEvent);
721 }))
722 }
723}
724
725impl EventEmitter<DismissEvent> for GitCloneModal {}
726
727impl ModalView for GitCloneModal {}