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 workspace.register_action(|workspace, _: &git::Uncommit, window, cx| {
153 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
154 return;
155 };
156 panel.update(cx, |panel, cx| {
157 panel.uncommit(window, cx);
158 })
159 });
160 CommandPaletteFilter::update_global(cx, |filter, _cx| {
161 filter.hide_action_types(&[
162 zed_actions::OpenGitIntegrationOnboarding.type_id(),
163 // ResetOnboarding.type_id(),
164 ]);
165 });
166 workspace.register_action(
167 move |workspace, _: &zed_actions::OpenGitIntegrationOnboarding, window, cx| {
168 GitOnboardingModal::toggle(workspace, window, cx)
169 },
170 );
171 workspace.register_action(move |_, _: &ResetOnboarding, window, cx| {
172 window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx);
173 window.refresh();
174 });
175 workspace.register_action(|workspace, _action: &git::Init, window, cx| {
176 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
177 return;
178 };
179 panel.update(cx, |panel, cx| {
180 panel.git_init(window, cx);
181 });
182 });
183 workspace.register_action(|workspace, _action: &git::Clone, window, cx| {
184 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
185 return;
186 };
187
188 workspace.toggle_modal(window, cx, |window, cx| {
189 GitCloneModal::show(panel, window, cx)
190 });
191 });
192 workspace.register_action(|workspace, _: &git::OpenModifiedFiles, window, cx| {
193 open_modified_files(workspace, window, cx);
194 });
195 workspace.register_action(
196 |workspace, action: &DiffClipboardWithSelectionData, window, cx| {
197 if let Some(task) = TextDiffView::open(action, workspace, window, cx) {
198 task.detach();
199 };
200 },
201 );
202 })
203 .detach();
204}
205
206fn open_modified_files(
207 workspace: &mut Workspace,
208 window: &mut Window,
209 cx: &mut Context<Workspace>,
210) {
211 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
212 return;
213 };
214 let modified_paths: Vec<_> = panel.update(cx, |panel, cx| {
215 let Some(repo) = panel.active_repository.as_ref() else {
216 return Vec::new();
217 };
218 let repo = repo.read(cx);
219 repo.cached_status()
220 .filter_map(|entry| {
221 if entry.status.is_modified() {
222 repo.repo_path_to_project_path(&entry.repo_path, cx)
223 } else {
224 None
225 }
226 })
227 .collect()
228 });
229 for path in modified_paths {
230 workspace.open_path(path, None, true, window, cx).detach();
231 }
232}
233
234pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
235 GitStatusIcon::new(status)
236}
237
238fn render_remote_button(
239 id: impl Into<SharedString>,
240 branch: &Branch,
241 keybinding_target: Option<FocusHandle>,
242 show_fetch_button: bool,
243) -> Option<impl IntoElement> {
244 let id = id.into();
245 let upstream = branch.upstream.as_ref();
246 match upstream {
247 Some(Upstream {
248 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
249 ..
250 }) => match (*ahead, *behind) {
251 (0, 0) if show_fetch_button => {
252 Some(remote_button::render_fetch_button(keybinding_target, id))
253 }
254 (0, 0) => None,
255 (ahead, 0) => Some(remote_button::render_push_button(
256 keybinding_target,
257 id,
258 ahead,
259 )),
260 (ahead, behind) => Some(remote_button::render_pull_button(
261 keybinding_target,
262 id,
263 ahead,
264 behind,
265 )),
266 },
267 Some(Upstream {
268 tracking: UpstreamTracking::Gone,
269 ..
270 }) => Some(remote_button::render_republish_button(
271 keybinding_target,
272 id,
273 )),
274 None => Some(remote_button::render_publish_button(keybinding_target, id)),
275 }
276}
277
278mod remote_button {
279 use gpui::{Action, AnyView, ClickEvent, Corner, FocusHandle};
280 use ui::{
281 App, ButtonCommon, Clickable, ContextMenu, ElementId, FluentBuilder, Icon, IconName,
282 IconSize, IntoElement, Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement,
283 PopoverMenu, SharedString, SplitButton, Styled, Tooltip, Window, div, h_flex, rems,
284 };
285
286 pub fn render_fetch_button(
287 keybinding_target: Option<FocusHandle>,
288 id: SharedString,
289 ) -> SplitButton {
290 split_button(
291 id,
292 "Fetch",
293 0,
294 0,
295 Some(IconName::ArrowCircle),
296 keybinding_target.clone(),
297 move |_, window, cx| {
298 window.dispatch_action(Box::new(git::Fetch), cx);
299 },
300 move |window, cx| {
301 git_action_tooltip(
302 "Fetch updates from remote",
303 &git::Fetch,
304 "git fetch",
305 keybinding_target.clone(),
306 window,
307 cx,
308 )
309 },
310 )
311 }
312
313 pub fn render_push_button(
314 keybinding_target: Option<FocusHandle>,
315 id: SharedString,
316 ahead: u32,
317 ) -> SplitButton {
318 split_button(
319 id,
320 "Push",
321 ahead as usize,
322 0,
323 None,
324 keybinding_target.clone(),
325 move |_, window, cx| {
326 window.dispatch_action(Box::new(git::Push), cx);
327 },
328 move |window, cx| {
329 git_action_tooltip(
330 "Push committed changes to remote",
331 &git::Push,
332 "git push",
333 keybinding_target.clone(),
334 window,
335 cx,
336 )
337 },
338 )
339 }
340
341 pub fn render_pull_button(
342 keybinding_target: Option<FocusHandle>,
343 id: SharedString,
344 ahead: u32,
345 behind: u32,
346 ) -> SplitButton {
347 split_button(
348 id,
349 "Pull",
350 ahead as usize,
351 behind as usize,
352 None,
353 keybinding_target.clone(),
354 move |_, window, cx| {
355 window.dispatch_action(Box::new(git::Pull), cx);
356 },
357 move |window, cx| {
358 git_action_tooltip(
359 "Pull",
360 &git::Pull,
361 "git pull",
362 keybinding_target.clone(),
363 window,
364 cx,
365 )
366 },
367 )
368 }
369
370 pub fn render_publish_button(
371 keybinding_target: Option<FocusHandle>,
372 id: SharedString,
373 ) -> SplitButton {
374 split_button(
375 id,
376 "Publish",
377 0,
378 0,
379 Some(IconName::ExpandUp),
380 keybinding_target.clone(),
381 move |_, window, cx| {
382 window.dispatch_action(Box::new(git::Push), cx);
383 },
384 move |window, cx| {
385 git_action_tooltip(
386 "Publish branch to remote",
387 &git::Push,
388 "git push --set-upstream",
389 keybinding_target.clone(),
390 window,
391 cx,
392 )
393 },
394 )
395 }
396
397 pub fn render_republish_button(
398 keybinding_target: Option<FocusHandle>,
399 id: SharedString,
400 ) -> SplitButton {
401 split_button(
402 id,
403 "Republish",
404 0,
405 0,
406 Some(IconName::ExpandUp),
407 keybinding_target.clone(),
408 move |_, window, cx| {
409 window.dispatch_action(Box::new(git::Push), cx);
410 },
411 move |window, cx| {
412 git_action_tooltip(
413 "Re-publish branch to remote",
414 &git::Push,
415 "git push --set-upstream",
416 keybinding_target.clone(),
417 window,
418 cx,
419 )
420 },
421 )
422 }
423
424 fn git_action_tooltip(
425 label: impl Into<SharedString>,
426 action: &dyn Action,
427 command: impl Into<SharedString>,
428 focus_handle: Option<FocusHandle>,
429 window: &mut Window,
430 cx: &mut App,
431 ) -> AnyView {
432 let label = label.into();
433 let command = command.into();
434
435 if let Some(handle) = focus_handle {
436 Tooltip::with_meta_in(label, Some(action), command, &handle, window, cx)
437 } else {
438 Tooltip::with_meta(label, Some(action), command, 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)
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 URL…", window, 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
655impl Focusable for GitCloneModal {
656 fn focus_handle(&self, _: &App) -> FocusHandle {
657 self.focus_handle.clone()
658 }
659}
660
661impl Render for GitCloneModal {
662 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
663 div()
664 .elevation_3(cx)
665 .w(rems(34.))
666 .flex_1()
667 .overflow_hidden()
668 .child(
669 div()
670 .w_full()
671 .p_2()
672 .border_b_1()
673 .border_color(cx.theme().colors().border_variant)
674 .child(self.repo_input.clone()),
675 )
676 .child(
677 h_flex()
678 .w_full()
679 .p_2()
680 .gap_0p5()
681 .rounded_b_sm()
682 .bg(cx.theme().colors().editor_background)
683 .child(
684 Label::new("Clone a repository from GitHub or other sources.")
685 .color(Color::Muted)
686 .size(LabelSize::Small),
687 )
688 .child(
689 Button::new("learn-more", "Learn More")
690 .label_size(LabelSize::Small)
691 .icon(IconName::ArrowUpRight)
692 .icon_size(IconSize::XSmall)
693 .on_click(|_, _, cx| {
694 cx.open_url("https://github.com/git-guides/git-clone");
695 }),
696 ),
697 )
698 .on_action(cx.listener(|_, _: &menu::Cancel, _, cx| {
699 cx.emit(DismissEvent);
700 }))
701 .on_action(cx.listener(|this, _: &menu::Confirm, window, cx| {
702 let repo = this.repo_input.read(cx).text(cx);
703 this.panel.update(cx, |panel, cx| {
704 panel.git_clone(repo, window, cx);
705 });
706 cx.emit(DismissEvent);
707 }))
708 }
709}
710
711impl EventEmitter<DismissEvent> for GitCloneModal {}
712
713impl ModalView for GitCloneModal {}