1use crate::branch_picker::{self, BranchList};
2use crate::git_panel::{GitPanel, commit_message_editor};
3use git::repository::CommitOptions;
4use git::{Amend, Commit, GenerateCommitMessage, Signoff};
5use panel::{panel_button, panel_editor_style};
6use project::DisableAiSettings;
7use settings::Settings;
8use ui::{
9 ContextMenu, KeybindingHint, PopoverMenu, PopoverMenuHandle, SplitButton, Tooltip, prelude::*,
10};
11
12use editor::{Editor, EditorElement};
13use gpui::*;
14use util::ResultExt;
15use workspace::{
16 ModalView, Workspace,
17 dock::{Dock, PanelHandle},
18};
19
20// nate: It is a pain to get editors to size correctly and not overflow.
21//
22// this can get replaced with a simple flex layout with more time/a more thoughtful approach.
23#[derive(Debug, Clone, Copy)]
24pub struct ModalContainerProperties {
25 pub modal_width: f32,
26 pub editor_height: f32,
27 pub footer_height: f32,
28 pub container_padding: f32,
29 pub modal_border_radius: f32,
30}
31
32impl ModalContainerProperties {
33 pub fn new(window: &Window, preferred_char_width: usize) -> Self {
34 let container_padding = 5.0;
35
36 // Calculate width based on character width
37 let mut modal_width = 460.0;
38 let style = window.text_style().clone();
39 let font_id = window.text_system().resolve_font(&style.font());
40 let font_size = style.font_size.to_pixels(window.rem_size());
41
42 if let Ok(em_width) = window.text_system().em_width(font_id, font_size) {
43 modal_width = preferred_char_width as f32 * em_width.0 + (container_padding * 2.0);
44 }
45
46 Self {
47 modal_width,
48 editor_height: 300.0,
49 footer_height: 24.0,
50 container_padding,
51 modal_border_radius: 12.0,
52 }
53 }
54
55 pub fn editor_border_radius(&self) -> Pixels {
56 px(self.modal_border_radius - self.container_padding / 2.0)
57 }
58}
59
60pub struct CommitModal {
61 git_panel: Entity<GitPanel>,
62 commit_editor: Entity<Editor>,
63 restore_dock: RestoreDock,
64 properties: ModalContainerProperties,
65 branch_list_handle: PopoverMenuHandle<BranchList>,
66 commit_menu_handle: PopoverMenuHandle<ContextMenu>,
67}
68
69impl Focusable for CommitModal {
70 fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
71 self.commit_editor.focus_handle(cx)
72 }
73}
74
75impl EventEmitter<DismissEvent> for CommitModal {}
76impl ModalView for CommitModal {
77 fn on_before_dismiss(
78 &mut self,
79 window: &mut Window,
80 cx: &mut Context<Self>,
81 ) -> workspace::DismissDecision {
82 self.git_panel.update(cx, |git_panel, cx| {
83 git_panel.set_modal_open(false, cx);
84 });
85 self.restore_dock
86 .dock
87 .update(cx, |dock, cx| {
88 if let Some(active_index) = self.restore_dock.active_index {
89 dock.activate_panel(active_index, window, cx)
90 }
91 dock.set_open(self.restore_dock.is_open, window, cx)
92 })
93 .log_err();
94 workspace::DismissDecision::Dismiss(true)
95 }
96}
97
98struct RestoreDock {
99 dock: WeakEntity<Dock>,
100 is_open: bool,
101 active_index: Option<usize>,
102}
103
104pub enum ForceMode {
105 Amend,
106 Commit,
107}
108
109impl CommitModal {
110 pub fn register(workspace: &mut Workspace) {
111 workspace.register_action(|workspace, _: &Commit, window, cx| {
112 CommitModal::toggle(workspace, Some(ForceMode::Commit), window, cx);
113 });
114 workspace.register_action(|workspace, _: &Amend, window, cx| {
115 CommitModal::toggle(workspace, Some(ForceMode::Amend), window, cx);
116 });
117 }
118
119 pub fn toggle(
120 workspace: &mut Workspace,
121 force_mode: Option<ForceMode>,
122 window: &mut Window,
123 cx: &mut Context<Workspace>,
124 ) {
125 let Some(git_panel) = workspace.panel::<GitPanel>(cx) else {
126 return;
127 };
128
129 git_panel.update(cx, |git_panel, cx| {
130 if let Some(force_mode) = force_mode {
131 match force_mode {
132 ForceMode::Amend => {
133 if git_panel
134 .active_repository
135 .as_ref()
136 .and_then(|repo| repo.read(cx).head_commit.as_ref())
137 .is_some()
138 && !git_panel.amend_pending() {
139 git_panel.set_amend_pending(true, cx);
140 git_panel.load_last_commit_message_if_empty(cx);
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 && commit_message.is_empty() {
198 commit_editor.update(cx, |editor, cx| {
199 editor.set_placeholder_text(suggested_commit_message, cx);
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::ChevronDown).size(IconSize::XSmall)),
273 ),
274 )
275 .menu({
276 let git_panel_entity = self.git_panel.clone();
277 move |window, cx| {
278 let git_panel = git_panel_entity.read(cx);
279 let amend_enabled = git_panel.amend_pending();
280 let signoff_enabled = git_panel.signoff_enabled();
281 let has_previous_commit = git_panel.head_commit(cx).is_some();
282
283 Some(ContextMenu::build(window, cx, |context_menu, _, _| {
284 context_menu
285 .when_some(keybinding_target.clone(), |el, keybinding_target| {
286 el.context(keybinding_target.clone())
287 })
288 .when(has_previous_commit, |this| {
289 this.toggleable_entry(
290 "Amend",
291 amend_enabled,
292 IconPosition::Start,
293 Some(Box::new(Amend)),
294 {
295 let git_panel = git_panel_entity.downgrade();
296 move |_, cx| {
297 git_panel
298 .update(cx, |git_panel, cx| {
299 git_panel.toggle_amend_pending(cx);
300 })
301 .ok();
302 }
303 },
304 )
305 })
306 .toggleable_entry(
307 "Signoff",
308 signoff_enabled,
309 IconPosition::Start,
310 Some(Box::new(Signoff)),
311 {
312 let git_panel = git_panel_entity.clone();
313 move |window, cx| {
314 git_panel.update(cx, |git_panel, cx| {
315 git_panel.toggle_signoff_enabled(&Signoff, window, cx);
316 })
317 }
318 },
319 )
320 }))
321 }
322 })
323 .with_handle(self.commit_menu_handle.clone())
324 .anchor(Corner::TopRight)
325 }
326
327 pub fn render_footer(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
328 let (
329 can_commit,
330 tooltip,
331 commit_label,
332 co_authors,
333 generate_commit_message,
334 active_repo,
335 is_amend_pending,
336 is_signoff_enabled,
337 ) = self.git_panel.update(cx, |git_panel, cx| {
338 let (can_commit, tooltip) = git_panel.configure_commit_button(cx);
339 let title = git_panel.commit_button_title();
340 let co_authors = git_panel.render_co_authors(cx);
341 let generate_commit_message = git_panel.render_generate_commit_message_button(cx);
342 let active_repo = git_panel.active_repository.clone();
343 let is_amend_pending = git_panel.amend_pending();
344 let is_signoff_enabled = git_panel.signoff_enabled();
345 (
346 can_commit,
347 tooltip,
348 title,
349 co_authors,
350 generate_commit_message,
351 active_repo,
352 is_amend_pending,
353 is_signoff_enabled,
354 )
355 });
356
357 let branch = active_repo
358 .as_ref()
359 .and_then(|repo| repo.read(cx).branch.as_ref())
360 .map(|b| b.name().to_owned())
361 .unwrap_or_else(|| "<no branch>".to_owned());
362
363 let branch_picker_button = panel_button(branch)
364 .icon(IconName::GitBranch)
365 .icon_size(IconSize::Small)
366 .icon_color(Color::Placeholder)
367 .color(Color::Muted)
368 .icon_position(IconPosition::Start)
369 .tooltip(Tooltip::for_action_title(
370 "Switch Branch",
371 &zed_actions::git::Branch,
372 ))
373 .on_click(cx.listener(|_, _, window, cx| {
374 window.dispatch_action(zed_actions::git::Branch.boxed_clone(), cx);
375 }))
376 .style(ButtonStyle::Transparent);
377
378 let branch_picker = PopoverMenu::new("popover-button")
379 .menu(move |window, cx| Some(branch_picker::popover(active_repo.clone(), window, cx)))
380 .with_handle(self.branch_list_handle.clone())
381 .trigger_with_tooltip(
382 branch_picker_button,
383 Tooltip::for_action_title("Switch Branch", &zed_actions::git::Branch),
384 )
385 .anchor(Corner::BottomLeft)
386 .offset(gpui::Point {
387 x: px(0.0),
388 y: px(-2.0),
389 });
390 let focus_handle = self.focus_handle(cx);
391
392 let close_kb_hint =
393 if let Some(close_kb) = ui::KeyBinding::for_action(&menu::Cancel, window, cx) {
394 Some(
395 KeybindingHint::new(close_kb, cx.theme().colors().editor_background)
396 .suffix("Cancel"),
397 )
398 } else {
399 None
400 };
401
402 h_flex()
403 .group("commit_editor_footer")
404 .flex_none()
405 .w_full()
406 .items_center()
407 .justify_between()
408 .w_full()
409 .h(px(self.properties.footer_height))
410 .gap_1()
411 .child(
412 h_flex()
413 .gap_1()
414 .flex_shrink()
415 .overflow_x_hidden()
416 .child(
417 h_flex()
418 .flex_shrink()
419 .overflow_x_hidden()
420 .child(branch_picker),
421 )
422 .children(generate_commit_message)
423 .children(co_authors),
424 )
425 .child(div().flex_1())
426 .child(
427 h_flex()
428 .items_center()
429 .justify_end()
430 .flex_none()
431 .px_1()
432 .gap_4()
433 .children(close_kb_hint)
434 .child(SplitButton::new(
435 ui::ButtonLike::new_rounded_left(ElementId::Name(
436 format!("split-button-left-{}", commit_label).into(),
437 ))
438 .layer(ui::ElevationIndex::ModalSurface)
439 .size(ui::ButtonSize::Compact)
440 .child(
441 div()
442 .child(Label::new(commit_label).size(LabelSize::Small))
443 .mr_0p5(),
444 )
445 .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| {
446 telemetry::event!("Git Committed", source = "Git Modal");
447 this.git_panel.update(cx, |git_panel, cx| {
448 git_panel.commit_changes(
449 CommitOptions {
450 amend: is_amend_pending,
451 signoff: is_signoff_enabled,
452 },
453 window,
454 cx,
455 )
456 });
457 cx.emit(DismissEvent);
458 }))
459 .disabled(!can_commit)
460 .tooltip({
461 let focus_handle = focus_handle.clone();
462 move |window, cx| {
463 if can_commit {
464 Tooltip::with_meta_in(
465 tooltip,
466 Some(&git::Commit),
467 format!(
468 "git commit{}{}",
469 if is_amend_pending { " --amend" } else { "" },
470 if is_signoff_enabled { " --signoff" } else { "" }
471 ),
472 &focus_handle.clone(),
473 window,
474 cx,
475 )
476 } else {
477 Tooltip::simple(tooltip, cx)
478 }
479 }
480 }),
481 self.render_git_commit_menu(
482 ElementId::Name(format!("split-button-right-{}", commit_label).into()),
483 Some(focus_handle.clone()),
484 )
485 .into_any_element(),
486 )),
487 )
488 }
489
490 fn dismiss(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
491 if self.git_panel.read(cx).amend_pending() {
492 self.git_panel
493 .update(cx, |git_panel, cx| git_panel.set_amend_pending(false, cx));
494 } else {
495 cx.emit(DismissEvent);
496 }
497 }
498
499 fn commit(&mut self, _: &git::Commit, window: &mut Window, cx: &mut Context<Self>) {
500 if self.git_panel.read(cx).amend_pending() {
501 return;
502 }
503 telemetry::event!("Git Committed", source = "Git Modal");
504 self.git_panel.update(cx, |git_panel, cx| {
505 git_panel.commit_changes(
506 CommitOptions {
507 amend: false,
508 signoff: git_panel.signoff_enabled(),
509 },
510 window,
511 cx,
512 )
513 });
514 cx.emit(DismissEvent);
515 }
516
517 fn amend(&mut self, _: &git::Amend, window: &mut Window, cx: &mut Context<Self>) {
518 if self
519 .git_panel
520 .read(cx)
521 .active_repository
522 .as_ref()
523 .and_then(|repo| repo.read(cx).head_commit.as_ref())
524 .is_none()
525 {
526 return;
527 }
528 if !self.git_panel.read(cx).amend_pending() {
529 self.git_panel.update(cx, |git_panel, cx| {
530 git_panel.set_amend_pending(true, cx);
531 git_panel.load_last_commit_message_if_empty(cx);
532 });
533 } else {
534 telemetry::event!("Git Amended", source = "Git Modal");
535 self.git_panel.update(cx, |git_panel, cx| {
536 git_panel.set_amend_pending(false, cx);
537 git_panel.commit_changes(
538 CommitOptions {
539 amend: true,
540 signoff: git_panel.signoff_enabled(),
541 },
542 window,
543 cx,
544 );
545 });
546 cx.emit(DismissEvent);
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 .when(!DisableAiSettings::get_global(cx).disable_ai, |this| {
574 this.on_action(cx.listener(|this, _: &GenerateCommitMessage, _, cx| {
575 this.git_panel.update(cx, |panel, cx| {
576 panel.generate_commit_message(cx);
577 })
578 }))
579 })
580 .on_action(
581 cx.listener(|this, _: &zed_actions::git::Branch, window, cx| {
582 this.toggle_branch_selector(window, cx);
583 }),
584 )
585 .on_action(
586 cx.listener(|this, _: &zed_actions::git::CheckoutBranch, window, cx| {
587 this.toggle_branch_selector(window, cx);
588 }),
589 )
590 .on_action(
591 cx.listener(|this, _: &zed_actions::git::Switch, window, cx| {
592 this.toggle_branch_selector(window, cx);
593 }),
594 )
595 .elevation_3(cx)
596 .overflow_hidden()
597 .flex_none()
598 .relative()
599 .bg(cx.theme().colors().elevated_surface_background)
600 .rounded(px(border_radius))
601 .border_1()
602 .border_color(cx.theme().colors().border)
603 .w(width)
604 .p(container_padding)
605 .child(
606 v_flex()
607 .id("editor-container")
608 .justify_between()
609 .p_2()
610 .size_full()
611 .gap_2()
612 .rounded(properties.editor_border_radius())
613 .overflow_hidden()
614 .cursor_text()
615 .bg(cx.theme().colors().editor_background)
616 .border_1()
617 .border_color(cx.theme().colors().border_variant)
618 .on_click(cx.listener(move |_, _: &ClickEvent, window, _cx| {
619 window.focus(&editor_focus_handle);
620 }))
621 .child(
622 div()
623 .flex_1()
624 .size_full()
625 .child(self.render_commit_editor(window, cx)),
626 )
627 .child(self.render_footer(window, cx)),
628 )
629 }
630}