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