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