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