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