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