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