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