1use std::any::Any;
2
3use ::settings::Settings;
4use command_palette_hooks::CommandPaletteFilter;
5use git::{
6 repository::{Branch, Upstream, UpstreamTracking, UpstreamTrackingStatus},
7 status::{FileStatus, StatusCode, UnmergedStatus, UnmergedStatusCode},
8};
9use git_panel_settings::GitPanelSettings;
10use gpui::{actions, App, Entity, FocusHandle};
11use onboarding::{clear_dismissed, GitOnboardingModal};
12use project::Project;
13use project_diff::ProjectDiff;
14use ui::prelude::*;
15use workspace::Workspace;
16
17mod askpass_modal;
18pub mod branch_picker;
19mod commit_modal;
20pub mod git_panel;
21mod git_panel_settings;
22pub mod onboarding;
23pub mod picker_prompt;
24pub mod project_diff;
25pub(crate) mod remote_output;
26pub mod repository_selector;
27
28actions!(git, [ResetOnboarding]);
29
30pub fn init(cx: &mut App) {
31 GitPanelSettings::register(cx);
32 branch_picker::init(cx);
33 cx.observe_new(ProjectDiff::register).detach();
34 commit_modal::init(cx);
35 git_panel::init(cx);
36
37 cx.observe_new(|workspace: &mut Workspace, _, cx| {
38 let project = workspace.project().read(cx);
39 if project.is_read_only(cx) {
40 return;
41 }
42 if !project.is_via_collab() {
43 workspace.register_action(|workspace, _: &git::Fetch, window, cx| {
44 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
45 return;
46 };
47 panel.update(cx, |panel, cx| {
48 panel.fetch(window, cx);
49 });
50 });
51 workspace.register_action(|workspace, _: &git::Push, window, cx| {
52 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
53 return;
54 };
55 panel.update(cx, |panel, cx| {
56 panel.push(false, window, cx);
57 });
58 });
59 workspace.register_action(|workspace, _: &git::ForcePush, window, cx| {
60 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
61 return;
62 };
63 panel.update(cx, |panel, cx| {
64 panel.push(true, window, cx);
65 });
66 });
67 workspace.register_action(|workspace, _: &git::Pull, window, cx| {
68 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
69 return;
70 };
71 panel.update(cx, |panel, cx| {
72 panel.pull(window, cx);
73 });
74 });
75 }
76 workspace.register_action(|workspace, action: &git::StageAll, window, cx| {
77 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
78 return;
79 };
80 panel.update(cx, |panel, cx| {
81 panel.stage_all(action, window, cx);
82 });
83 });
84 workspace.register_action(|workspace, action: &git::UnstageAll, window, cx| {
85 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
86 return;
87 };
88 panel.update(cx, |panel, cx| {
89 panel.unstage_all(action, window, cx);
90 });
91 });
92 CommandPaletteFilter::update_global(cx, |filter, _cx| {
93 filter.hide_action_types(&[
94 zed_actions::OpenGitIntegrationOnboarding.type_id(),
95 // ResetOnboarding.type_id(),
96 ]);
97 });
98 workspace.register_action(
99 move |workspace, _: &zed_actions::OpenGitIntegrationOnboarding, window, cx| {
100 GitOnboardingModal::toggle(workspace, window, cx)
101 },
102 );
103 workspace.register_action(move |_, _: &ResetOnboarding, window, cx| {
104 clear_dismissed(cx);
105 window.refresh();
106 });
107 workspace.register_action(|workspace, _action: &git::Init, window, cx| {
108 let Some(panel) = workspace.panel::<git_panel::GitPanel>(cx) else {
109 return;
110 };
111 panel.update(cx, |panel, cx| {
112 panel.git_init(window, cx);
113 });
114 });
115 })
116 .detach();
117}
118
119pub fn git_status_icon(status: FileStatus) -> impl IntoElement {
120 GitStatusIcon::new(status)
121}
122
123fn can_push_and_pull(project: &Entity<Project>, cx: &App) -> bool {
124 !project.read(cx).is_via_collab()
125}
126
127fn render_remote_button(
128 id: impl Into<SharedString>,
129 branch: &Branch,
130 keybinding_target: Option<FocusHandle>,
131 show_fetch_button: bool,
132) -> Option<impl IntoElement> {
133 let id = id.into();
134 let upstream = branch.upstream.as_ref();
135 match upstream {
136 Some(Upstream {
137 tracking: UpstreamTracking::Tracked(UpstreamTrackingStatus { ahead, behind }),
138 ..
139 }) => match (*ahead, *behind) {
140 (0, 0) if show_fetch_button => {
141 Some(remote_button::render_fetch_button(keybinding_target, id))
142 }
143 (0, 0) => None,
144 (ahead, 0) => Some(remote_button::render_push_button(
145 keybinding_target.clone(),
146 id,
147 ahead,
148 )),
149 (ahead, behind) => Some(remote_button::render_pull_button(
150 keybinding_target.clone(),
151 id,
152 ahead,
153 behind,
154 )),
155 },
156 Some(Upstream {
157 tracking: UpstreamTracking::Gone,
158 ..
159 }) => Some(remote_button::render_republish_button(
160 keybinding_target,
161 id,
162 )),
163 None => Some(remote_button::render_publish_button(keybinding_target, id)),
164 }
165}
166
167mod remote_button {
168 use gpui::{hsla, point, Action, AnyView, BoxShadow, ClickEvent, Corner, FocusHandle};
169 use ui::{
170 div, h_flex, px, rems, ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, Clickable,
171 ContextMenu, ElementId, Elevation, FluentBuilder, Icon, IconName, IconSize, IntoElement,
172 Label, LabelCommon, LabelSize, LineHeightStyle, ParentElement, PopoverMenu, RenderOnce,
173 SharedString, Styled, Tooltip, Window,
174 };
175
176 pub fn render_fetch_button(
177 keybinding_target: Option<FocusHandle>,
178 id: SharedString,
179 ) -> SplitButton {
180 SplitButton::new(
181 id,
182 "Fetch",
183 0,
184 0,
185 Some(IconName::ArrowCircle),
186 keybinding_target.clone(),
187 move |_, window, cx| {
188 window.dispatch_action(Box::new(git::Fetch), cx);
189 },
190 move |window, cx| {
191 git_action_tooltip(
192 "Fetch updates from remote",
193 &git::Fetch,
194 "git fetch",
195 keybinding_target.clone(),
196 window,
197 cx,
198 )
199 },
200 )
201 }
202
203 pub fn render_push_button(
204 keybinding_target: Option<FocusHandle>,
205 id: SharedString,
206 ahead: u32,
207 ) -> SplitButton {
208 SplitButton::new(
209 id,
210 "Push",
211 ahead as usize,
212 0,
213 None,
214 keybinding_target.clone(),
215 move |_, window, cx| {
216 window.dispatch_action(Box::new(git::Push), cx);
217 },
218 move |window, cx| {
219 git_action_tooltip(
220 "Push committed changes to remote",
221 &git::Push,
222 "git push",
223 keybinding_target.clone(),
224 window,
225 cx,
226 )
227 },
228 )
229 }
230
231 pub fn render_pull_button(
232 keybinding_target: Option<FocusHandle>,
233 id: SharedString,
234 ahead: u32,
235 behind: u32,
236 ) -> SplitButton {
237 SplitButton::new(
238 id,
239 "Pull",
240 ahead as usize,
241 behind as usize,
242 None,
243 keybinding_target.clone(),
244 move |_, window, cx| {
245 window.dispatch_action(Box::new(git::Pull), cx);
246 },
247 move |window, cx| {
248 git_action_tooltip(
249 "Pull",
250 &git::Pull,
251 "git pull",
252 keybinding_target.clone(),
253 window,
254 cx,
255 )
256 },
257 )
258 }
259
260 pub fn render_publish_button(
261 keybinding_target: Option<FocusHandle>,
262 id: SharedString,
263 ) -> SplitButton {
264 SplitButton::new(
265 id,
266 "Publish",
267 0,
268 0,
269 Some(IconName::ArrowUpFromLine),
270 keybinding_target.clone(),
271 move |_, window, cx| {
272 window.dispatch_action(Box::new(git::Push), cx);
273 },
274 move |window, cx| {
275 git_action_tooltip(
276 "Publish branch to remote",
277 &git::Push,
278 "git push --set-upstream",
279 keybinding_target.clone(),
280 window,
281 cx,
282 )
283 },
284 )
285 }
286
287 pub fn render_republish_button(
288 keybinding_target: Option<FocusHandle>,
289 id: SharedString,
290 ) -> SplitButton {
291 SplitButton::new(
292 id,
293 "Republish",
294 0,
295 0,
296 Some(IconName::ArrowUpFromLine),
297 keybinding_target.clone(),
298 move |_, window, cx| {
299 window.dispatch_action(Box::new(git::Push), cx);
300 },
301 move |window, cx| {
302 git_action_tooltip(
303 "Re-publish branch to remote",
304 &git::Push,
305 "git push --set-upstream",
306 keybinding_target.clone(),
307 window,
308 cx,
309 )
310 },
311 )
312 }
313
314 fn git_action_tooltip(
315 label: impl Into<SharedString>,
316 action: &dyn Action,
317 command: impl Into<SharedString>,
318 focus_handle: Option<FocusHandle>,
319 window: &mut Window,
320 cx: &mut App,
321 ) -> AnyView {
322 let label = label.into();
323 let command = command.into();
324
325 if let Some(handle) = focus_handle {
326 Tooltip::with_meta_in(
327 label.clone(),
328 Some(action),
329 command.clone(),
330 &handle,
331 window,
332 cx,
333 )
334 } else {
335 Tooltip::with_meta(label.clone(), Some(action), command.clone(), window, cx)
336 }
337 }
338
339 fn render_git_action_menu(
340 id: impl Into<ElementId>,
341 keybinding_target: Option<FocusHandle>,
342 ) -> impl IntoElement {
343 PopoverMenu::new(id.into())
344 .trigger(
345 ui::ButtonLike::new_rounded_right("split-button-right")
346 .elevation(ui::Elevation::ModalSurface)
347 .size(ui::ButtonSize::None)
348 .child(
349 div()
350 .px_1()
351 .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
352 ),
353 )
354 .menu(move |window, cx| {
355 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
356 context_menu
357 .when_some(keybinding_target.clone(), |el, keybinding_target| {
358 el.context(keybinding_target.clone())
359 })
360 .action("Fetch", git::Fetch.boxed_clone())
361 .action("Pull", git::Pull.boxed_clone())
362 .separator()
363 .action("Push", git::Push.boxed_clone())
364 .action("Force Push", git::ForcePush.boxed_clone())
365 }))
366 })
367 .anchor(Corner::TopRight)
368 }
369
370 #[derive(IntoElement)]
371 pub struct SplitButton {
372 pub left: ButtonLike,
373 pub right: AnyElement,
374 }
375
376 impl SplitButton {
377 #[allow(clippy::too_many_arguments)]
378 fn new(
379 id: impl Into<SharedString>,
380 left_label: impl Into<SharedString>,
381 ahead_count: usize,
382 behind_count: usize,
383 left_icon: Option<IconName>,
384 keybinding_target: Option<FocusHandle>,
385 left_on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
386 tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static,
387 ) -> Self {
388 let id = id.into();
389
390 fn count(count: usize) -> impl IntoElement {
391 h_flex()
392 .ml_neg_px()
393 .h(rems(0.875))
394 .items_center()
395 .overflow_hidden()
396 .px_0p5()
397 .child(
398 Label::new(count.to_string())
399 .size(LabelSize::XSmall)
400 .line_height_style(LineHeightStyle::UiLabel),
401 )
402 }
403
404 let should_render_counts = left_icon.is_none() && (ahead_count > 0 || behind_count > 0);
405
406 let left = ui::ButtonLike::new_rounded_left(ElementId::Name(
407 format!("split-button-left-{}", id).into(),
408 ))
409 .elevation(ui::Elevation::ModalSurface)
410 .size(ui::ButtonSize::Compact)
411 .when(should_render_counts, |this| {
412 this.child(
413 h_flex()
414 .ml_neg_0p5()
415 .mr_1()
416 .when(behind_count > 0, |this| {
417 this.child(Icon::new(IconName::ArrowDown).size(IconSize::XSmall))
418 .child(count(behind_count))
419 })
420 .when(ahead_count > 0, |this| {
421 this.child(Icon::new(IconName::ArrowUp).size(IconSize::XSmall))
422 .child(count(ahead_count))
423 }),
424 )
425 })
426 .when_some(left_icon, |this, left_icon| {
427 this.child(
428 h_flex()
429 .ml_neg_0p5()
430 .mr_1()
431 .child(Icon::new(left_icon).size(IconSize::XSmall)),
432 )
433 })
434 .child(
435 div()
436 .child(Label::new(left_label).size(LabelSize::Small))
437 .mr_0p5(),
438 )
439 .on_click(left_on_click)
440 .tooltip(tooltip);
441
442 let right = render_git_action_menu(
443 ElementId::Name(format!("split-button-right-{}", id).into()),
444 keybinding_target,
445 )
446 .into_any_element();
447
448 Self { left, right }
449 }
450 }
451
452 impl RenderOnce for SplitButton {
453 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
454 h_flex()
455 .rounded_sm()
456 .border_1()
457 .border_color(cx.theme().colors().text_muted.alpha(0.12))
458 .child(div().flex_grow().child(self.left))
459 .child(
460 div()
461 .h_full()
462 .w_px()
463 .bg(cx.theme().colors().text_muted.alpha(0.16)),
464 )
465 .child(self.right)
466 .bg(Elevation::Surface.on_elevation_bg(cx))
467 .shadow(smallvec::smallvec![BoxShadow {
468 color: hsla(0.0, 0.0, 0.0, 0.16),
469 offset: point(px(0.), px(1.)),
470 blur_radius: px(0.),
471 spread_radius: px(0.),
472 }])
473 }
474 }
475}
476
477#[derive(IntoElement, IntoComponent)]
478#[component(scope = "Version Control")]
479pub struct GitStatusIcon {
480 status: FileStatus,
481}
482
483impl GitStatusIcon {
484 pub fn new(status: FileStatus) -> Self {
485 Self { status }
486 }
487}
488
489impl RenderOnce for GitStatusIcon {
490 fn render(self, _window: &mut ui::Window, cx: &mut App) -> impl IntoElement {
491 let status = self.status;
492
493 let (icon_name, color) = if status.is_conflicted() {
494 (
495 IconName::Warning,
496 cx.theme().colors().version_control_conflict,
497 )
498 } else if status.is_deleted() {
499 (
500 IconName::SquareMinus,
501 cx.theme().colors().version_control_deleted,
502 )
503 } else if status.is_modified() {
504 (
505 IconName::SquareDot,
506 cx.theme().colors().version_control_modified,
507 )
508 } else {
509 (
510 IconName::SquarePlus,
511 cx.theme().colors().version_control_added,
512 )
513 };
514
515 Icon::new(icon_name).color(Color::Custom(color))
516 }
517}
518
519// View this component preview using `workspace: open component-preview`
520impl ComponentPreview for GitStatusIcon {
521 fn preview(_window: &mut Window, _cx: &mut App) -> AnyElement {
522 fn tracked_file_status(code: StatusCode) -> FileStatus {
523 FileStatus::Tracked(git::status::TrackedStatus {
524 index_status: code,
525 worktree_status: code,
526 })
527 }
528
529 let modified = tracked_file_status(StatusCode::Modified);
530 let added = tracked_file_status(StatusCode::Added);
531 let deleted = tracked_file_status(StatusCode::Deleted);
532 let conflict = UnmergedStatus {
533 first_head: UnmergedStatusCode::Updated,
534 second_head: UnmergedStatusCode::Updated,
535 }
536 .into();
537
538 v_flex()
539 .gap_6()
540 .children(vec![example_group(vec![
541 single_example("Modified", GitStatusIcon::new(modified).into_any_element()),
542 single_example("Added", GitStatusIcon::new(added).into_any_element()),
543 single_example("Deleted", GitStatusIcon::new(deleted).into_any_element()),
544 single_example(
545 "Conflicted",
546 GitStatusIcon::new(conflict).into_any_element(),
547 ),
548 ])])
549 .into_any_element()
550 }
551}