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