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