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