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