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