diff --git a/assets/icons/ai_edit.svg b/assets/icons/ai_edit.svg new file mode 100644 index 0000000000..2f93ab9fd9 --- /dev/null +++ b/assets/icons/ai_edit.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/crates/git_ui/src/commit_message_prompt.txt b/crates/git_ui/src/commit_message_prompt.txt index 78b31e38dd..e2dca2fc08 100644 --- a/crates/git_ui/src/commit_message_prompt.txt +++ b/crates/git_ui/src/commit_message_prompt.txt @@ -15,5 +15,3 @@ Follow good Git style: - Use the imperative mood in the subject line - Wrap the body at 72 characters - Keep the body short and concise (omit it entirely if not useful) - -Here are the changes in this commit: diff --git a/crates/git_ui/src/commit_modal.rs b/crates/git_ui/src/commit_modal.rs index 137ca8dcec..ff7b2a295d 100644 --- a/crates/git_ui/src/commit_modal.rs +++ b/crates/git_ui/src/commit_modal.rs @@ -304,8 +304,11 @@ impl CommitModal { git_panel.update(cx, |git_panel, cx| git_panel.editor_focus_handle(cx)); let commit_button = panel_filled_button(commit_label) - .tooltip(move |window, cx| { - Tooltip::for_action_in(tooltip, &Commit, &panel_editor_focus_handle, window, cx) + .tooltip({ + let panel_editor_focus_handle = panel_editor_focus_handle.clone(); + move |window, cx| { + Tooltip::for_action_in(tooltip, &Commit, &panel_editor_focus_handle, window, cx) + } }) .disabled(!can_commit) .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { @@ -328,8 +331,8 @@ impl CommitModal { h_flex() .gap_1() .child(branch_picker) - .children(co_authors) - .child(generate_commit_message), + .children(generate_commit_message) + .children(co_authors), ) .child(div().flex_1()) .child( diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e4c9d80ab6..241f0505c1 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -23,7 +23,7 @@ use git::repository::{ ResetMode, Upstream, UpstreamTracking, UpstreamTrackingStatus, }; use git::{repository::RepoPath, status::FileStatus, Commit, ToggleStaged}; -use git::{RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll}; +use git::{ExpandCommitEditor, RestoreTrackedFiles, StageAll, TrashUntrackedFiles, UnstageAll}; use gpui::{ actions, anchored, deferred, hsla, percentage, point, uniform_list, Action, Animation, AnimationExt as _, AnyView, BoxShadow, ClickEvent, Corner, DismissEvent, Entity, EventEmitter, @@ -35,7 +35,7 @@ use gpui::{ use itertools::Itertools; use language::{Buffer, File}; use language_model::{ - LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role, + LanguageModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role, }; use menu::{Confirm, SecondaryConfirm, SelectFirst, SelectLast, SelectNext, SelectPrevious}; use multi_buffer::ExcerptInfo; @@ -78,6 +78,7 @@ actions!( FocusEditor, FocusChanges, ToggleFillCoAuthors, + GenerateCommitMessage ] ); @@ -124,6 +125,9 @@ pub fn init(cx: &mut App) { workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { workspace.toggle_panel_focus::(window, cx); }); + workspace.register_action(|workspace, _: &ExpandCommitEditor, window, cx| { + CommitModal::toggle(workspace, window, cx) + }); }, ) .detach(); @@ -1438,17 +1442,11 @@ impl GitPanel { } /// Generates a commit message using an LLM. - fn generate_commit_message(&mut self, cx: &mut Context) { - let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else { - return; + pub fn generate_commit_message(&mut self, cx: &mut Context) { + let model = match current_language_model(cx) { + Some(value) => value, + None => return, }; - let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else { - return; - }; - - if !provider.is_authenticated(cx) { - return; - } let Some(repo) = self.active_repository.as_ref() else { return; @@ -1478,17 +1476,30 @@ impl GitPanel { }); let mut diff_text = diff.await??; + const ONE_MB: usize = 1_000_000; if diff_text.len() > ONE_MB { diff_text = diff_text.chars().take(ONE_MB).collect() } + let subject = this.update(&mut cx, |this, cx| { + this.commit_editor.read(cx).text(cx).lines().next().map(ToOwned::to_owned).unwrap_or_default() + })?; + + let text_empty = subject.trim().is_empty(); + + let content = if text_empty { + format!("{PROMPT}\nHere are the changes in this commit:\n{diff_text}") + } else { + format!("{PROMPT}\nHere is the user's subject line:\n{subject}\nHere are the changes in this commit:\n{diff_text}\n") + }; + const PROMPT: &str = include_str!("commit_message_prompt.txt"); let request = LanguageModelRequest { messages: vec![LanguageModelRequestMessage { role: Role::User, - content: vec![format!("{PROMPT}\n{diff_text}").into()], + content: vec![content.into()], cache: false, }], tools: Vec::new(), @@ -1499,6 +1510,15 @@ impl GitPanel { let stream = model.stream_completion_text(request, &cx); let mut messages = stream.await?; + if !text_empty { + this.update(&mut cx, |this, cx| { + this.commit_message_buffer(cx).update(cx, |buffer, cx| { + let insert_position = buffer.anchor_before(buffer.len()); + buffer.edit([(insert_position..insert_position, "\n")], None, cx) + }); + })?; + } + while let Some(message) = messages.stream.next().await { let text = message?; @@ -2178,64 +2198,82 @@ impl GitPanel { self.has_staged_changes() } - pub(crate) fn render_generate_commit_message_button(&self, cx: &Context) -> AnyElement { - if self.generate_commit_message_task.is_some() { - return Icon::new(IconName::ArrowCircle) - .size(IconSize::XSmall) - .color(Color::Info) - .with_animation( - "arrow-circle", - Animation::new(Duration::from_secs(2)).repeat(), - |icon, delta| icon.transform(Transformation::rotate(percentage(delta))), - ) - .into_any_element(); - } + pub(crate) fn render_generate_commit_message_button( + &self, + cx: &Context, + ) -> Option { + current_language_model(cx).is_some().then(|| { + if self.generate_commit_message_task.is_some() { + return h_flex() + .gap_1() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::XSmall) + .color(Color::Info) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage(delta))) + }, + ), + ) + .child( + Label::new("Generating Commit...") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any_element(); + } - IconButton::new("generate-commit-message", IconName::ZedAssistant) - .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::for_action_title_in( - "Generate commit message", - &git::GenerateCommitMessage, - &self.commit_editor.focus_handle(cx), - )) - .on_click(cx.listener(move |this, _event, _window, cx| { - this.generate_commit_message(cx); - })) - .into_any_element() + IconButton::new("generate-commit-message", IconName::AiEdit) + .shape(ui::IconButtonShape::Square) + .icon_color(Color::Muted) + .tooltip(Tooltip::for_action_title_in( + "Generate Commit Message", + &git::GenerateCommitMessage, + &self.commit_editor.focus_handle(cx), + )) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.generate_commit_message(cx); + })) + .into_any_element() + }) } pub(crate) fn render_co_authors(&self, cx: &Context) -> Option { let potential_co_authors = self.potential_co_authors(cx); - if potential_co_authors.is_empty() { - None - } else { - Some( - IconButton::new("co-authors", IconName::Person) - .icon_color(Color::Disabled) - .selected_icon_color(Color::Selected) - .toggle_state(self.add_coauthors) - .tooltip(move |_, cx| { - let title = format!( - "Add co-authored-by:{}{}", - if potential_co_authors.len() == 1 { - "" - } else { - "\n" - }, - potential_co_authors - .iter() - .map(|(name, email)| format!(" {} <{}>", name, email)) - .join("\n") - ); - Tooltip::simple(title, cx) - }) - .on_click(cx.listener(|this, _, _, cx| { - this.add_coauthors = !this.add_coauthors; - cx.notify(); - })) - .into_any_element(), - ) - } + // if potential_co_authors.is_empty() { + // None + // } else { + Some( + IconButton::new("co-authors", IconName::Person) + .shape(ui::IconButtonShape::Square) + .icon_color(Color::Disabled) + .selected_icon_color(Color::Selected) + .toggle_state(self.add_coauthors) + .tooltip(move |_, cx| { + let title = format!( + "Add co-authored-by:{}{}", + if potential_co_authors.len() == 1 { + "" + } else { + "\n" + }, + potential_co_authors + .iter() + .map(|(name, email)| format!(" {} <{}>", name, email)) + .join("\n") + ); + Tooltip::simple(title, cx) + }) + .on_click(cx.listener(|this, _, _, cx| { + this.add_coauthors = !this.add_coauthors; + cx.notify(); + })) + .into_any_element(), + ) + // } } pub fn configure_commit_button(&self, cx: &mut Context) -> (bool, &'static str) { @@ -2292,19 +2330,18 @@ impl GitPanel { let panel_editor_style = panel_editor_style(true, window, cx); let enable_coauthors = self.render_co_authors(cx); - let title = self.commit_button_title(); + let editor_focus_handle = self.commit_editor.focus_handle(cx); + let commit_tooltip_focus_handle = editor_focus_handle.clone(); + let expand_tooltip_focus_handle = editor_focus_handle.clone(); let branch = active_repository.read(cx).current_branch().cloned(); let footer_size = px(32.); let gap = px(8.0); - let max_height = window.line_height() * 5. + gap + footer_size; - let expand_button_size = px(16.); - let git_panel = cx.entity().clone(); let display_name = SharedString::from(Arc::from( active_repository @@ -2325,9 +2362,9 @@ impl GitPanel { .id("commit-editor-container") .relative() .h(max_height) - // .w_full() - // .border_t_1() - // .border_color(cx.theme().colors().border) + .w_full() + .border_t_1() + .border_color(cx.theme().colors().border_variant) .bg(cx.theme().colors().editor_background) .cursor_text() .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { @@ -2338,54 +2375,66 @@ impl GitPanel { .id("commit-footer") .absolute() .bottom_0() - .right_2() - .gap_0p5() + .left_0() + .w_full() + .px_2() .h(footer_size) .flex_none() - .children(enable_coauthors) - .child(self.render_generate_commit_message_button(cx)) + .justify_between() .child( - panel_filled_button(title) - .tooltip(move |window, cx| { - if can_commit { - Tooltip::for_action_in( - tooltip, - &Commit, - &editor_focus_handle, - window, - cx, - ) - } else { - Tooltip::simple(tooltip, cx) - } - }) - .disabled(!can_commit || self.modal_open) - .on_click({ - cx.listener(move |this, _: &ClickEvent, window, cx| { - telemetry::event!( - "Git Committed", - source = "Git Panel" - ); - this.commit_changes(window, cx) + self.render_generate_commit_message_button(cx) + .unwrap_or_else(|| div().into_any_element()), + ) + .child( + h_flex().gap_0p5().children(enable_coauthors).child( + panel_filled_button(title) + .tooltip(move |window, cx| { + if can_commit { + Tooltip::for_action_in( + tooltip, + &Commit, + &commit_tooltip_focus_handle, + window, + cx, + ) + } else { + Tooltip::simple(tooltip, cx) + } }) - }), + .disabled(!can_commit || self.modal_open) + .on_click({ + cx.listener(move |this, _: &ClickEvent, window, cx| { + this.commit_changes(window, cx) + }) + }), + ), ), ) - // .when(!self.modal_open, |el| { - .child(EditorElement::new(&self.commit_editor, panel_editor_style)) .child( div() + .pr_2p5() + .child(EditorElement::new(&self.commit_editor, panel_editor_style)), + ) + .child( + h_flex() .absolute() - .top_1() + .top_2() .right_2() .opacity(0.5) .hover(|this| this.opacity(1.0)) - .w(expand_button_size) .child( panel_icon_button("expand-commit-editor", IconName::Maximize) .icon_size(IconSize::Small) - .style(ButtonStyle::Transparent) - .width(expand_button_size.into()) + .size(ui::ButtonSize::Default) + .tooltip(move |window, cx| { + Tooltip::for_action_in( + "Open Commit Modal", + &git::ExpandCommitEditor, + &expand_tooltip_focus_handle, + window, + cx, + ) + }) .on_click(cx.listener({ move |_, _, window, cx| { window.dispatch_action( @@ -2963,6 +3012,12 @@ impl GitPanel { } } +fn current_language_model(cx: &Context<'_, GitPanel>) -> Option> { + let provider = LanguageModelRegistry::read_global(cx).active_provider()?; + let model = LanguageModelRegistry::read_global(cx).active_model()?; + provider.is_authenticated(cx).then(|| model) +} + impl Render for GitPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let project = self.project.read(cx); diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 9f655a686a..fce2a8906a 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -128,6 +128,7 @@ pub enum IconName { AiBedrock, AiAnthropicHosted, AiDeepSeek, + AiEdit, AiGoogle, AiLmStudio, AiMistral, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 779c730d9b..6ac97d5045 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -35,7 +35,7 @@ use futures::{ Future, FutureExt, StreamExt, }; use gpui::{ - action_as, actions, canvas, impl_action_as, impl_actions, point, relative, size, + action_as, actions, canvas, deferred, impl_action_as, impl_actions, point, relative, size, transparent_black, Action, AnyView, AnyWeakView, App, AsyncApp, AsyncWindowContext, Bounds, Context, CursorStyle, Decorations, DragMoveEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, Global, Hsla, KeyContext, Keystroke, ManagedView, MouseButton, PathPromptOptions, @@ -5546,7 +5546,7 @@ impl Render for Workspace { .children(self.render_notifications(window, cx)), ) .child(self.status_bar.clone()) - .child(self.modal_layer.clone()) + .child(deferred(self.modal_layer.clone())) .child(self.toast_layer.clone()), ), window,