commit_modal.rs

  1use crate::branch_picker::{self, BranchList};
  2use crate::git_panel::{GitPanel, commit_message_editor};
  3use git::repository::CommitOptions;
  4use git::{Amend, Commit, GenerateCommitMessage};
  5use language::Buffer;
  6use panel::{panel_button, panel_editor_style, panel_filled_button};
  7use ui::{
  8    ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*,
  9};
 10
 11use editor::{Editor, EditorElement};
 12use gpui::*;
 13use util::ResultExt;
 14use workspace::{
 15    ModalView, Workspace,
 16    dock::{Dock, PanelHandle},
 17};
 18
 19// nate: It is a pain to get editors to size correctly and not overflow.
 20//
 21// this can get replaced with a simple flex layout with more time/a more thoughtful approach.
 22#[derive(Debug, Clone, Copy)]
 23pub struct ModalContainerProperties {
 24    pub modal_width: f32,
 25    pub editor_height: f32,
 26    pub footer_height: f32,
 27    pub container_padding: f32,
 28    pub modal_border_radius: f32,
 29}
 30
 31impl ModalContainerProperties {
 32    pub fn new(window: &Window, preferred_char_width: usize) -> Self {
 33        let container_padding = 5.0;
 34
 35        // Calculate width based on character width
 36        let mut modal_width = 460.0;
 37        let style = window.text_style().clone();
 38        let font_id = window.text_system().resolve_font(&style.font());
 39        let font_size = style.font_size.to_pixels(window.rem_size());
 40
 41        if let Ok(em_width) = window.text_system().em_width(font_id, font_size) {
 42            modal_width = preferred_char_width as f32 * em_width.0 + (container_padding * 2.0);
 43        }
 44
 45        Self {
 46            modal_width,
 47            editor_height: 300.0,
 48            footer_height: 24.0,
 49            container_padding,
 50            modal_border_radius: 12.0,
 51        }
 52    }
 53
 54    pub fn editor_border_radius(&self) -> Pixels {
 55        px(self.modal_border_radius - self.container_padding / 2.0)
 56    }
 57}
 58
 59pub struct CommitModal {
 60    git_panel: Entity<GitPanel>,
 61    commit_editor: Entity<Editor>,
 62    restore_dock: RestoreDock,
 63    properties: ModalContainerProperties,
 64    branch_list_handle: PopoverMenuHandle<BranchList>,
 65    commit_menu_handle: PopoverMenuHandle<ContextMenu>,
 66}
 67
 68impl Focusable for CommitModal {
 69    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
 70        self.commit_editor.focus_handle(cx)
 71    }
 72}
 73
 74impl EventEmitter<DismissEvent> for CommitModal {}
 75impl ModalView for CommitModal {
 76    fn on_before_dismiss(
 77        &mut self,
 78        window: &mut Window,
 79        cx: &mut Context<Self>,
 80    ) -> workspace::DismissDecision {
 81        self.git_panel.update(cx, |git_panel, cx| {
 82            git_panel.set_modal_open(false, cx);
 83        });
 84        self.restore_dock
 85            .dock
 86            .update(cx, |dock, cx| {
 87                if let Some(active_index) = self.restore_dock.active_index {
 88                    dock.activate_panel(active_index, window, cx)
 89                }
 90                dock.set_open(self.restore_dock.is_open, window, cx)
 91            })
 92            .log_err();
 93        workspace::DismissDecision::Dismiss(true)
 94    }
 95}
 96
 97struct RestoreDock {
 98    dock: WeakEntity<Dock>,
 99    is_open: bool,
100    active_index: Option<usize>,
101}
102
103impl CommitModal {
104    pub fn register(workspace: &mut Workspace) {
105        workspace.register_action(|workspace, _: &Commit, window, cx| {
106            CommitModal::toggle(workspace, window, cx);
107        });
108        workspace.register_action(|workspace, _: &Amend, window, cx| {
109            CommitModal::toggle(workspace, window, cx);
110        });
111    }
112
113    pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>) {
114        let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
115            return;
116        };
117
118        git_panel.update(cx, |git_panel, cx| {
119            git_panel.set_modal_open(true, cx);
120        });
121
122        let dock = workspace.dock_at_position(git_panel.position(window, cx));
123        let is_open = dock.read(cx).is_open();
124        let active_index = dock.read(cx).active_panel_index();
125        let dock = dock.downgrade();
126        let restore_dock_position = RestoreDock {
127            dock,
128            is_open,
129            active_index,
130        };
131
132        workspace.open_panel::<GitPanel>(window, cx);
133        workspace.toggle_modal(window, cx, move |window, cx| {
134            CommitModal::new(git_panel, restore_dock_position, window, cx)
135        })
136    }
137
138    fn new(
139        git_panel: Entity<GitPanel>,
140        restore_dock: RestoreDock,
141        window: &mut Window,
142        cx: &mut Context<Self>,
143    ) -> Self {
144        let panel = git_panel.read(cx);
145        let suggested_commit_message = panel.suggest_commit_message(cx);
146
147        let commit_editor = git_panel.update(cx, |git_panel, cx| {
148            git_panel.set_modal_open(true, cx);
149            let buffer = git_panel.commit_message_buffer(cx).clone();
150            let panel_editor = git_panel.commit_editor.clone();
151            let project = git_panel.project.clone();
152
153            cx.new(|cx| {
154                let mut editor =
155                    commit_message_editor(buffer, None, project.clone(), false, window, cx);
156                editor.sync_selections(panel_editor, cx).detach();
157
158                editor
159            })
160        });
161
162        let commit_message = commit_editor.read(cx).text(cx);
163
164        if let Some(suggested_commit_message) = suggested_commit_message {
165            if commit_message.is_empty() {
166                commit_editor.update(cx, |editor, cx| {
167                    editor.set_placeholder_text(suggested_commit_message, cx);
168                });
169            }
170        }
171
172        let focus_handle = commit_editor.focus_handle(cx);
173
174        cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
175            if !this.branch_list_handle.is_focused(window, cx)
176                && !this.commit_menu_handle.is_focused(window, cx)
177            {
178                cx.emit(DismissEvent);
179            }
180        })
181        .detach();
182
183        let properties = ModalContainerProperties::new(window, 50);
184
185        Self {
186            git_panel,
187            commit_editor,
188            restore_dock,
189            properties,
190            branch_list_handle: PopoverMenuHandle::default(),
191            commit_menu_handle: PopoverMenuHandle::default(),
192        }
193    }
194
195    fn commit_editor_element(&self, window: &mut Window, cx: &mut Context<Self>) -> EditorElement {
196        let editor_style = panel_editor_style(true, window, cx);
197        EditorElement::new(&self.commit_editor, editor_style)
198    }
199
200    pub fn render_commit_editor(
201        &self,
202        window: &mut Window,
203        cx: &mut Context<Self>,
204    ) -> impl IntoElement {
205        let properties = self.properties;
206        let padding_t = 3.0;
207        let padding_b = 6.0;
208        // magic number for editor not to overflow the container??
209        let extra_space_hack = 1.5 * window.line_height();
210
211        v_flex()
212            .h(px(properties.editor_height + padding_b + padding_t) + extra_space_hack)
213            .w_full()
214            .flex_none()
215            .rounded(properties.editor_border_radius())
216            .overflow_hidden()
217            .px_1p5()
218            .pt(px(padding_t))
219            .pb(px(padding_b))
220            .child(
221                div()
222                    .h(px(properties.editor_height))
223                    .w_full()
224                    .child(self.commit_editor_element(window, cx)),
225            )
226    }
227
228    fn render_git_commit_menu(
229        &self,
230        id: impl Into<ElementId>,
231        keybinding_target: Option<FocusHandle>,
232    ) -> impl IntoElement {
233        PopoverMenu::new(id.into())
234            .trigger(
235                ui::ButtonLike::new_rounded_right("commit-split-button-right")
236                    .layer(ui::ElevationIndex::ModalSurface)
237                    .size(ui::ButtonSize::None)
238                    .child(
239                        div()
240                            .px_1()
241                            .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
242                    ),
243            )
244            .menu(move |window, cx| {
245                Some(ContextMenu::build(window, cx, |context_menu, _, _| {
246                    context_menu
247                        .when_some(keybinding_target.clone(), |el, keybinding_target| {
248                            el.context(keybinding_target.clone())
249                        })
250                        .action("Amend...", Amend.boxed_clone())
251                }))
252            })
253            .with_handle(self.commit_menu_handle.clone())
254            .anchor(Corner::TopRight)
255    }
256
257    pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
258        let (
259            can_commit,
260            tooltip,
261            commit_label,
262            co_authors,
263            generate_commit_message,
264            active_repo,
265            is_amend_pending,
266            has_previous_commit,
267        ) = self.git_panel.update(cx, |git_panel, cx| {
268            let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
269            let title = git_panel.commit_button_title();
270            let co_authors = git_panel.render_co_authors(cx);
271            let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
272            let active_repo = git_panel.active_repository.clone();
273            let is_amend_pending = git_panel.amend_pending();
274            let has_previous_commit = active_repo
275                .as_ref()
276                .and_then(|repo| repo.read(cx).branch.as_ref())
277                .and_then(|branch| branch.most_recent_commit.as_ref())
278                .is_some();
279            (
280                can_commit,
281                tooltip,
282                title,
283                co_authors,
284                generate_commit_message,
285                active_repo,
286                is_amend_pending,
287                has_previous_commit,
288            )
289        });
290
291        let branch = active_repo
292            .as_ref()
293            .and_then(|repo| repo.read(cx).branch.as_ref())
294            .map(|b| b.name.clone())
295            .unwrap_or_else(|| "<no branch>".into());
296
297        let branch_picker_button = panel_button(branch)
298            .icon(IconName::GitBranch)
299            .icon_size(IconSize::Small)
300            .icon_color(Color::Placeholder)
301            .color(Color::Muted)
302            .icon_position(IconPosition::Start)
303            .tooltip(Tooltip::for_action_title(
304                "Switch Branch",
305                &zed_actions::git::Branch,
306            ))
307            .on_click(cx.listener(|_, _, window, cx| {
308                window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
309            }))
310            .style(ButtonStyle::Transparent);
311
312        let branch_picker = PopoverMenu::new("popover-button")
313            .menu(move |window, cx| Some(branch_picker::popover(active_repo.clone(), window, cx)))
314            .with_handle(self.branch_list_handle.clone())
315            .trigger_with_tooltip(
316                branch_picker_button,
317                Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
318            )
319            .anchor(Corner::BottomLeft)
320            .offset(gpui::Point {
321                x: px(0.0),
322                y: px(-2.0),
323            });
324        let focus_handle = self.focus_handle(cx);
325
326        let close_kb_hint =
327            if let Some(close_kb) = ui::KeyBinding::for_action(&menu::Cancel, window, cx) {
328                Some(
329                    KeybindingHint::new(close_kb, cx.theme().colors().editor_background)
330                        .suffix("Cancel"),
331                )
332            } else {
333                None
334            };
335
336        h_flex()
337            .group("commit_editor_footer")
338            .flex_none()
339            .w_full()
340            .items_center()
341            .justify_between()
342            .w_full()
343            .h(px(self.properties.footer_height))
344            .gap_1()
345            .child(
346                h_flex()
347                    .gap_1()
348                    .flex_shrink()
349                    .overflow_x_hidden()
350                    .child(
351                        h_flex()
352                            .flex_shrink()
353                            .overflow_x_hidden()
354                            .child(branch_picker),
355                    )
356                    .children(generate_commit_message)
357                    .children(co_authors),
358            )
359            .child(div().flex_1())
360            .child(
361                h_flex()
362                    .items_center()
363                    .justify_end()
364                    .flex_none()
365                    .px_1()
366                    .gap_4()
367                    .children(close_kb_hint)
368                    .when(is_amend_pending, |this| {
369                        let focus_handle = focus_handle.clone();
370                        this.child(
371                            panel_filled_button(commit_label)
372                                .tooltip(move |window, cx| {
373                                    if can_commit {
374                                        Tooltip::for_action_in(
375                                            tooltip,
376                                            &Amend,
377                                            &focus_handle,
378                                            window,
379                                            cx,
380                                        )
381                                    } else {
382                                        Tooltip::simple(tooltip, cx)
383                                    }
384                                })
385                                .disabled(!can_commit)
386                                .on_click(move |_, window, cx| {
387                                    window.dispatch_action(Box::new(git::Commit), cx);
388                                }),
389                        )
390                    })
391                    .when(!is_amend_pending, |this| {
392                        this.when(has_previous_commit, |this| {
393                            this.child(SplitButton::new(
394                                ui::ButtonLike::new_rounded_left(ElementId::Name(
395                                    format!("split-button-left-{}", commit_label).into(),
396                                ))
397                                .layer(ui::ElevationIndex::ModalSurface)
398                                .size(ui::ButtonSize::Compact)
399                                .child(
400                                    div()
401                                        .child(Label::new(commit_label).size(LabelSize::Small))
402                                        .mr_0p5(),
403                                )
404                                .on_click(move |_, window, cx| {
405                                    window.dispatch_action(Box::new(git::Commit), cx);
406                                })
407                                .disabled(!can_commit)
408                                .tooltip({
409                                    let focus_handle = focus_handle.clone();
410                                    move |window, cx| {
411                                        if can_commit {
412                                            Tooltip::with_meta_in(
413                                                tooltip,
414                                                Some(&git::Commit),
415                                                "git commit",
416                                                &focus_handle.clone(),
417                                                window,
418                                                cx,
419                                            )
420                                        } else {
421                                            Tooltip::simple(tooltip, cx)
422                                        }
423                                    }
424                                }),
425                                self.render_git_commit_menu(
426                                    ElementId::Name(
427                                        format!("split-button-right-{}", commit_label).into(),
428                                    ),
429                                    Some(focus_handle.clone()),
430                                )
431                                .into_any_element(),
432                            ))
433                        })
434                        .when(!has_previous_commit, |this| {
435                            this.child(
436                                panel_filled_button(commit_label)
437                                    .tooltip(move |window, cx| {
438                                        if can_commit {
439                                            Tooltip::with_meta_in(
440                                                tooltip,
441                                                Some(&git::Commit),
442                                                "git commit",
443                                                &focus_handle,
444                                                window,
445                                                cx,
446                                            )
447                                        } else {
448                                            Tooltip::simple(tooltip, cx)
449                                        }
450                                    })
451                                    .disabled(!can_commit)
452                                    .on_click(move |_, window, cx| {
453                                        window.dispatch_action(Box::new(git::Commit), cx);
454                                    }),
455                            )
456                        })
457                    }),
458            )
459    }
460
461    fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
462        if self.git_panel.read(cx).amend_pending() {
463            self.git_panel
464                .update(cx, |git_panel, _| git_panel.set_amend_pending(false));
465            cx.notify();
466        } else {
467            cx.emit(DismissEvent);
468        }
469    }
470
471    pub fn commit_message_buffer(&self, cx: &App) -> Entity<Buffer> {
472        self.commit_editor
473            .read(cx)
474            .buffer()
475            .read(cx)
476            .as_singleton()
477            .unwrap()
478            .clone()
479    }
480
481    fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
482        if self.git_panel.read(cx).amend_pending() {
483            return;
484        }
485        telemetry::event!("Git Committed", source = "Git Modal");
486        self.git_panel.update(cx, |git_panel, cx| {
487            git_panel.commit_changes(CommitOptions { amend: false }, window, cx)
488        });
489        cx.emit(DismissEvent);
490    }
491
492    fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
493        let Some(active_repository) = self.git_panel.read(cx).active_repository.as_ref() else {
494            return;
495        };
496        let Some(branch) = active_repository.read(cx).branch.as_ref() else {
497            return;
498        };
499        let Some(recent_sha) = branch
500            .most_recent_commit
501            .as_ref()
502            .map(|commit| commit.sha.to_string())
503        else {
504            return;
505        };
506        if self
507            .commit_editor
508            .focus_handle(cx)
509            .contains_focused(window, cx)
510        {
511            if !self.git_panel.read(cx).amend_pending() {
512                self.git_panel.update(cx, |git_panel, _| {
513                    git_panel.set_amend_pending(true);
514                });
515                cx.notify();
516                if self.commit_editor.read(cx).is_empty(cx) {
517                    let detail_task = self.git_panel.update(cx, |git_panel, cx| {
518                        git_panel.load_commit_details(recent_sha, cx)
519                    });
520                    cx.spawn(async move |this, cx| {
521                        if let Ok(message) = detail_task.await.map(|detail| detail.message) {
522                            this.update(cx, |this, cx| {
523                                this.commit_message_buffer(cx).update(cx, |buffer, cx| {
524                                    let insert_position = buffer.anchor_before(buffer.len());
525                                    buffer.edit(
526                                        [(insert_position..insert_position, message)],
527                                        None,
528                                        cx,
529                                    );
530                                });
531                            })
532                            .log_err();
533                        }
534                    })
535                    .detach();
536                }
537            } else {
538                telemetry::event!("Git Amended", source = "Git Panel");
539                self.git_panel.update(cx, |git_panel, cx| {
540                    git_panel.set_amend_pending(false);
541                    git_panel.commit_changes(CommitOptions { amend: true }, window, cx);
542                });
543                cx.emit(DismissEvent);
544            }
545        } else {
546            cx.propagate();
547        }
548    }
549
550    fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
551        if self.branch_list_handle.is_focused(window, cx) {
552            self.focus_handle(cx).focus(window)
553        } else {
554            self.branch_list_handle.toggle(window, cx);
555        }
556    }
557}
558
559impl Render for CommitModal {
560    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
561        let properties = self.properties;
562        let width = px(properties.modal_width);
563        let container_padding = px(properties.container_padding);
564        let border_radius = properties.modal_border_radius;
565        let editor_focus_handle = self.commit_editor.focus_handle(cx);
566
567        v_flex()
568            .id("commit-modal")
569            .key_context("GitCommit")
570            .on_action(cx.listener(Self::dismiss))
571            .on_action(cx.listener(Self::commit))
572            .on_action(cx.listener(Self::amend))
573            .on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| {
574                this.git_panel.update(cx, |panel, cx| {
575                    panel.generate_commit_message(cx);
576                })
577            }))
578            .on_action(
579                cx.listener(|this, _: &zed_actions::git::Branch, window, cx| {
580                    this.toggle_branch_selector(window, cx);
581                }),
582            )
583            .on_action(
584                cx.listener(|this, _: &zed_actions::git::CheckoutBranch, window, cx| {
585                    this.toggle_branch_selector(window, cx);
586                }),
587            )
588            .on_action(
589                cx.listener(|this, _: &zed_actions::git::Switch, window, cx| {
590                    this.toggle_branch_selector(window, cx);
591                }),
592            )
593            .elevation_3(cx)
594            .overflow_hidden()
595            .flex_none()
596            .relative()
597            .bg(cx.theme().colors().elevated_surface_background)
598            .rounded(px(border_radius))
599            .border_1()
600            .border_color(cx.theme().colors().border)
601            .w(width)
602            .p(container_padding)
603            .child(
604                v_flex()
605                    .id("editor-container")
606                    .justify_between()
607                    .p_2()
608                    .size_full()
609                    .gap_2()
610                    .rounded(properties.editor_border_radius())
611                    .overflow_hidden()
612                    .cursor_text()
613                    .bg(cx.theme().colors().editor_background)
614                    .border_1()
615                    .border_color(cx.theme().colors().border_variant)
616                    .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
617                        window.focus(&editor_focus_handle);
618                    }))
619                    .child(
620                        div()
621                            .flex_1()
622                            .size_full()
623                            .child(self.render_commit_editor(window, cx)),
624                    )
625                    .child(self.render_footer(window, cx)),
626            )
627    }
628}