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 });
152
153 let dock = workspace.dock_at_position(git_panel.position(window, cx));
154 let is_open = dock.read(cx).is_open();
155 let active_index = dock.read(cx).active_panel_index();
156 let dock = dock.downgrade();
157 let restore_dock_position = RestoreDock {
158 dock,
159 is_open,
160 active_index,
161 };
162
163 workspace.open_panel::<GitPanel>(window, cx);
164 workspace.toggle_modal(window, cx, move |window, cx| {
165 CommitModal::new(git_panel, restore_dock_position, window, cx)
166 })
167 }
168
169 fn new(
170 git_panel: Entity<GitPanel>,
171 restore_dock: RestoreDock,
172 window: &mut Window,
173 cx: &mut Context<Self>,
174 ) -> Self {
175 let panel = git_panel.read(cx);
176 let suggested_commit_message = panel.suggest_commit_message(cx);
177
178 let commit_editor = git_panel.update(cx, |git_panel, cx| {
179 git_panel.set_modal_open(true, cx);
180 let buffer = git_panel.commit_message_buffer(cx).clone();
181 let panel_editor = git_panel.commit_editor.clone();
182 let project = git_panel.project.clone();
183
184 cx.new(|cx| {
185 let mut editor =
186 commit_message_editor(buffer, None, project.clone(), false, window, cx);
187 editor.sync_selections(panel_editor, cx).detach();
188
189 editor
190 })
191 });
192
193 let commit_message = commit_editor.read(cx).text(cx);
194
195 if let Some(suggested_commit_message) = suggested_commit_message {
196 if commit_message.is_empty() {
197 commit_editor.update(cx, |editor, cx| {
198 editor.set_placeholder_text(suggested_commit_message, cx);
199 });
200 }
201 }
202
203 let focus_handle = commit_editor.focus_handle(cx);
204
205 cx.on_focus_out(&focus_handle, window, |this, _, window, cx| {
206 if !this.branch_list_handle.is_focused(window, cx)
207 && !this.commit_menu_handle.is_focused(window, cx)
208 {
209 cx.emit(DismissEvent);
210 }
211 })
212 .detach();
213
214 let properties = ModalContainerProperties::new(window, 50);
215
216 Self {
217 git_panel,
218 commit_editor,
219 restore_dock,
220 properties,
221 branch_list_handle: PopoverMenuHandle::default(),
222 commit_menu_handle: PopoverMenuHandle::default(),
223 }
224 }
225
226 fn commit_editor_element(&self, window: &mut Window, cx: &mut Context<Self>) -> EditorElement {
227 let editor_style = panel_editor_style(true, window, cx);
228 EditorElement::new(&self.commit_editor, editor_style)
229 }
230
231 pub fn render_commit_editor(
232 &self,
233 window: &mut Window,
234 cx: &mut Context<Self>,
235 ) -> impl IntoElement {
236 let properties = self.properties;
237 let padding_t = 3.0;
238 let padding_b = 6.0;
239 // magic number for editor not to overflow the container??
240 let extra_space_hack = 1.5 * window.line_height();
241
242 v_flex()
243 .h(px(properties.editor_height + padding_b + padding_t) + extra_space_hack)
244 .w_full()
245 .flex_none()
246 .rounded(properties.editor_border_radius())
247 .overflow_hidden()
248 .px_1p5()
249 .pt(px(padding_t))
250 .pb(px(padding_b))
251 .child(
252 div()
253 .h(px(properties.editor_height))
254 .w_full()
255 .child(self.commit_editor_element(window, cx)),
256 )
257 }
258
259 fn render_git_commit_menu(
260 &self,
261 id: impl Into<ElementId>,
262 keybinding_target: Option<FocusHandle>,
263 ) -> impl IntoElement {
264 PopoverMenu::new(id.into())
265 .trigger(
266 ui::ButtonLike::new_rounded_right("commit-split-button-right")
267 .layer(ui::ElevationIndex::ModalSurface)
268 .size(ui::ButtonSize::None)
269 .child(
270 div()
271 .px_1()
272 .child(Icon::new(IconName::ChevronDownSmall).size(IconSize::XSmall)),
273 ),
274 )
275 .menu(move |window, cx| {
276 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
277 context_menu
278 .when_some(keybinding_target.clone(), |el, keybinding_target| {
279 el.context(keybinding_target.clone())
280 })
281 .action("Amend", Amend.boxed_clone())
282 }))
283 })
284 .with_handle(self.commit_menu_handle.clone())
285 .anchor(Corner::TopRight)
286 }
287
288 pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
289 let (
290 can_commit,
291 tooltip,
292 commit_label,
293 co_authors,
294 generate_commit_message,
295 active_repo,
296 is_amend_pending,
297 has_previous_commit,
298 ) = self.git_panel.update(cx, |git_panel, cx| {
299 let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
300 let title = git_panel.commit_button_title();
301 let co_authors = git_panel.render_co_authors(cx);
302 let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
303 let active_repo = git_panel.active_repository.clone();
304 let is_amend_pending = git_panel.amend_pending();
305 let has_previous_commit = active_repo
306 .as_ref()
307 .and_then(|repo| repo.read(cx).head_commit.as_ref())
308 .is_some();
309 (
310 can_commit,
311 tooltip,
312 title,
313 co_authors,
314 generate_commit_message,
315 active_repo,
316 is_amend_pending,
317 has_previous_commit,
318 )
319 });
320
321 let branch = active_repo
322 .as_ref()
323 .and_then(|repo| repo.read(cx).branch.as_ref())
324 .map(|b| b.name().to_owned())
325 .unwrap_or_else(|| "<no branch>".to_owned());
326
327 let branch_picker_button = panel_button(branch)
328 .icon(IconName::GitBranch)
329 .icon_size(IconSize::Small)
330 .icon_color(Color::Placeholder)
331 .color(Color::Muted)
332 .icon_position(IconPosition::Start)
333 .tooltip(Tooltip::for_action_title(
334 "Switch Branch",
335 &zed_actions::git::Branch,
336 ))
337 .on_click(cx.listener(|_, _, window, cx| {
338 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
339 }))
340 .style(ButtonStyle::Transparent);
341
342 let branch_picker = PopoverMenu::new("popover-button")
343 .menu(move |window, cx| Some(branch_picker::popover(active_repo.clone(), window, cx)))
344 .with_handle(self.branch_list_handle.clone())
345 .trigger_with_tooltip(
346 branch_picker_button,
347 Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
348 )
349 .anchor(Corner::BottomLeft)
350 .offset(gpui::Point {
351 x: px(0.0),
352 y: px(-2.0),
353 });
354 let focus_handle = self.focus_handle(cx);
355
356 let close_kb_hint =
357 if let Some(close_kb) = ui::KeyBinding::for_action(&menu::Cancel, window, cx) {
358 Some(
359 KeybindingHint::new(close_kb, cx.theme().colors().editor_background)
360 .suffix("Cancel"),
361 )
362 } else {
363 None
364 };
365
366 h_flex()
367 .group("commit_editor_footer")
368 .flex_none()
369 .w_full()
370 .items_center()
371 .justify_between()
372 .w_full()
373 .h(px(self.properties.footer_height))
374 .gap_1()
375 .child(
376 h_flex()
377 .gap_1()
378 .flex_shrink()
379 .overflow_x_hidden()
380 .child(
381 h_flex()
382 .flex_shrink()
383 .overflow_x_hidden()
384 .child(branch_picker),
385 )
386 .children(generate_commit_message)
387 .children(co_authors),
388 )
389 .child(div().flex_1())
390 .child(
391 h_flex()
392 .items_center()
393 .justify_end()
394 .flex_none()
395 .px_1()
396 .gap_4()
397 .children(close_kb_hint)
398 .when(is_amend_pending, |this| {
399 let focus_handle = focus_handle.clone();
400 this.child(
401 panel_filled_button(commit_label)
402 .tooltip(move |window, cx| {
403 if can_commit {
404 Tooltip::for_action_in(
405 tooltip,
406 &Amend,
407 &focus_handle,
408 window,
409 cx,
410 )
411 } else {
412 Tooltip::simple(tooltip, cx)
413 }
414 })
415 .disabled(!can_commit)
416 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
417 telemetry::event!("Git Amended", source = "Git Modal");
418 this.git_panel.update(cx, |git_panel, cx| {
419 git_panel.set_amend_pending(false, cx);
420 git_panel.commit_changes(
421 CommitOptions { amend: true },
422 window,
423 cx,
424 );
425 });
426 cx.emit(DismissEvent);
427 })),
428 )
429 })
430 .when(!is_amend_pending, |this| {
431 this.when(has_previous_commit, |this| {
432 this.child(SplitButton::new(
433 ui::ButtonLike::new_rounded_left(ElementId::Name(
434 format!("split-button-left-{}", commit_label).into(),
435 ))
436 .layer(ui::ElevationIndex::ModalSurface)
437 .size(ui::ButtonSize::Compact)
438 .child(
439 div()
440 .child(Label::new(commit_label).size(LabelSize::Small))
441 .mr_0p5(),
442 )
443 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
444 telemetry::event!("Git Committed", source = "Git Modal");
445 this.git_panel.update(cx, |git_panel, cx| {
446 git_panel.commit_changes(
447 CommitOptions { amend: false },
448 window,
449 cx,
450 )
451 });
452 cx.emit(DismissEvent);
453 }))
454 .disabled(!can_commit)
455 .tooltip({
456 let focus_handle = focus_handle.clone();
457 move |window, cx| {
458 if can_commit {
459 Tooltip::with_meta_in(
460 tooltip,
461 Some(&git::Commit),
462 "git commit",
463 &focus_handle.clone(),
464 window,
465 cx,
466 )
467 } else {
468 Tooltip::simple(tooltip, cx)
469 }
470 }
471 }),
472 self.render_git_commit_menu(
473 ElementId::Name(
474 format!("split-button-right-{}", commit_label).into(),
475 ),
476 Some(focus_handle.clone()),
477 )
478 .into_any_element(),
479 ))
480 })
481 .when(!has_previous_commit, |this| {
482 this.child(
483 panel_filled_button(commit_label)
484 .tooltip(move |window, cx| {
485 if can_commit {
486 Tooltip::with_meta_in(
487 tooltip,
488 Some(&git::Commit),
489 "git commit",
490 &focus_handle,
491 window,
492 cx,
493 )
494 } else {
495 Tooltip::simple(tooltip, cx)
496 }
497 })
498 .disabled(!can_commit)
499 .on_click(cx.listener(
500 move |this, _: &ClickEvent, window, cx| {
501 telemetry::event!(
502 "Git Committed",
503 source = "Git Modal"
504 );
505 this.git_panel.update(cx, |git_panel, cx| {
506 git_panel.commit_changes(
507 CommitOptions { amend: false },
508 window,
509 cx,
510 )
511 });
512 cx.emit(DismissEvent);
513 },
514 )),
515 )
516 })
517 }),
518 )
519 }
520
521 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
522 if self.git_panel.read(cx).amend_pending() {
523 self.git_panel
524 .update(cx, |git_panel, cx| git_panel.set_amend_pending(false, cx));
525 } else {
526 cx.emit(DismissEvent);
527 }
528 }
529
530 fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
531 if self.git_panel.read(cx).amend_pending() {
532 return;
533 }
534 telemetry::event!("Git Committed", source = "Git Modal");
535 self.git_panel.update(cx, |git_panel, cx| {
536 git_panel.commit_changes(CommitOptions { amend: false }, window, cx)
537 });
538 cx.emit(DismissEvent);
539 }
540
541 fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
542 if self
543 .git_panel
544 .read(cx)
545 .active_repository
546 .as_ref()
547 .and_then(|repo| repo.read(cx).head_commit.as_ref())
548 .is_none()
549 {
550 return;
551 }
552 if !self.git_panel.read(cx).amend_pending() {
553 self.git_panel.update(cx, |git_panel, cx| {
554 git_panel.set_amend_pending(true, cx);
555 git_panel.load_last_commit_message_if_empty(cx);
556 });
557 } else {
558 telemetry::event!("Git Amended", source = "Git Modal");
559 self.git_panel.update(cx, |git_panel, cx| {
560 git_panel.set_amend_pending(false, cx);
561 git_panel.commit_changes(CommitOptions { amend: true }, window, cx);
562 });
563 cx.emit(DismissEvent);
564 }
565 }
566
567 fn toggle_branch_selector(&mut self, window: &mut Window, cx: &mut Context<Self>) {
568 if self.branch_list_handle.is_focused(window, cx) {
569 self.focus_handle(cx).focus(window)
570 } else {
571 self.branch_list_handle.toggle(window, cx);
572 }
573 }
574}
575
576impl Render for CommitModal {
577 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
578 let properties = self.properties;
579 let width = px(properties.modal_width);
580 let container_padding = px(properties.container_padding);
581 let border_radius = properties.modal_border_radius;
582 let editor_focus_handle = self.commit_editor.focus_handle(cx);
583
584 v_flex()
585 .id("commit-modal")
586 .key_context("GitCommit")
587 .on_action(cx.listener(Self::dismiss))
588 .on_action(cx.listener(Self::commit))
589 .on_action(cx.listener(Self::amend))
590 .on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| {
591 this.git_panel.update(cx, |panel, cx| {
592 panel.generate_commit_message(cx);
593 })
594 }))
595 .on_action(
596 cx.listener(|this, _: &zed_actions::git::Branch, window, cx| {
597 this.toggle_branch_selector(window, cx);
598 }),
599 )
600 .on_action(
601 cx.listener(|this, _: &zed_actions::git::CheckoutBranch, window, cx| {
602 this.toggle_branch_selector(window, cx);
603 }),
604 )
605 .on_action(
606 cx.listener(|this, _: &zed_actions::git::Switch, window, cx| {
607 this.toggle_branch_selector(window, cx);
608 }),
609 )
610 .elevation_3(cx)
611 .overflow_hidden()
612 .flex_none()
613 .relative()
614 .bg(cx.theme().colors().elevated_surface_background)
615 .rounded(px(border_radius))
616 .border_1()
617 .border_color(cx.theme().colors().border)
618 .w(width)
619 .p(container_padding)
620 .child(
621 v_flex()
622 .id("editor-container")
623 .justify_between()
624 .p_2()
625 .size_full()
626 .gap_2()
627 .rounded(properties.editor_border_radius())
628 .overflow_hidden()
629 .cursor_text()
630 .bg(cx.theme().colors().editor_background)
631 .border_1()
632 .border_color(cx.theme().colors().border_variant)
633 .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
634 window.focus(&editor_focus_handle);
635 }))
636 .child(
637 div()
638 .flex_1()
639 .size_full()
640 .child(self.render_commit_editor(window, cx)),
641 )
642 .child(self.render_footer(window, cx)),
643 )
644 }
645}