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