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