diff --git a/Cargo.lock b/Cargo.lock index ee1b95a986..339b741d84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,6 +373,7 @@ dependencies = [ "serde", "serde_json", "settings", + "similar", "smol", "strsim 0.11.1", "strum", diff --git a/Cargo.toml b/Cargo.toml index 4090567600..48b22cc18a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -340,6 +340,7 @@ serde_repr = "0.1" sha2 = "0.10" shellexpand = "2.1.0" shlex = "1.3.0" +similar = "1.3" smallvec = { version = "1.6", features = ["union"] } smol = "1.2" strum = { version = "0.25.0", features = ["derive"] } diff --git a/assets/icons/context.svg b/assets/icons/context.svg new file mode 100644 index 0000000000..837b3aadd9 --- /dev/null +++ b/assets/icons/context.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/rotate_cw.svg b/assets/icons/rotate_cw.svg new file mode 100644 index 0000000000..019367745f --- /dev/null +++ b/assets/icons/rotate_cw.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/stop.svg b/assets/icons/stop.svg new file mode 100644 index 0000000000..3beabd5394 --- /dev/null +++ b/assets/icons/stop.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 06df24d69a..eaa6fc5b73 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -47,6 +47,7 @@ semantic_index.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true +similar.workspace = true smol.workspace = true strsim = "0.11" strum.workspace = true diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index e7fadd70a7..a6efad6049 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1222,6 +1222,10 @@ impl Context { } } + pub(crate) fn token_count(&self) -> Option { + self.token_count + } + pub(crate) fn count_remaining_tokens(&mut self, cx: &mut ModelContext) { let request = self.to_completion_request(cx); self.pending_token_count = cx.spawn(|this, mut cx| { diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs index a40c4d1338..c45de4a1fd 100644 --- a/crates/assistant/src/inline_assistant.rs +++ b/crates/assistant/src/inline_assistant.rs @@ -11,8 +11,8 @@ use editor::{ BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, RenderBlock, }, scroll::{Autoscroll, AutoscrollStrategy}, - Anchor, Editor, EditorElement, EditorEvent, EditorStyle, GutterDimensions, MultiBuffer, - MultiBufferSnapshot, ToOffset, ToPoint, + Anchor, AnchorRangeExt, Editor, EditorElement, EditorEvent, EditorStyle, ExcerptRange, + GutterDimensions, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint, }; use futures::{channel::mpsc, SinkExt, Stream, StreamExt}; use gpui::{ @@ -20,12 +20,18 @@ use gpui::{ Global, HighlightStyle, Model, ModelContext, Subscription, Task, TextStyle, UpdateGlobal, View, ViewContext, WeakView, WhiteSpace, WindowContext, }; -use language::{Point, TransactionId}; +use language::{Buffer, Point, TransactionId}; use multi_buffer::MultiBufferRow; use parking_lot::Mutex; use rope::Rope; use settings::Settings; -use std::{cmp, future, ops::Range, sync::Arc, time::Instant}; +use similar::TextDiff; +use std::{ + cmp, future, mem, + ops::{Range, RangeInclusive}, + sync::Arc, + time::Instant, +}; use theme::ThemeSettings; use ui::{prelude::*, Tooltip}; use workspace::{notifications::NotificationId, Toast, Workspace}; @@ -108,37 +114,53 @@ impl InlineAssistant { }); let gutter_dimensions = Arc::new(Mutex::new(GutterDimensions::default())); - let inline_assist_editor = cx.new_view(|cx| { + let prompt_editor = cx.new_view(|cx| { InlineAssistEditor::new( inline_assist_id, gutter_dimensions.clone(), self.prompt_history.clone(), codegen.clone(), + workspace.clone(), cx, ) }); - let block_id = editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |selections| { - selections.select_anchor_ranges([selection.head()..selection.head()]) + let (prompt_block_id, end_block_id) = editor.update(cx, |editor, cx| { + let start_anchor = snapshot.anchor_before(point_selection.start); + let end_anchor = snapshot.anchor_after(point_selection.end); + editor.change_selections(Some(Autoscroll::newest()), cx, |selections| { + selections.select_anchor_ranges([start_anchor..start_anchor]) }); - editor.insert_blocks( - [BlockProperties { - style: BlockStyle::Sticky, - position: snapshot.anchor_before(Point::new(point_selection.head().row, 0)), - height: inline_assist_editor.read(cx).height_in_lines, - render: build_inline_assist_editor_renderer( - &inline_assist_editor, - gutter_dimensions, - ), - disposition: if selection.reversed { - BlockDisposition::Above - } else { - BlockDisposition::Below + let block_ids = editor.insert_blocks( + [ + BlockProperties { + style: BlockStyle::Sticky, + position: start_anchor, + height: prompt_editor.read(cx).height_in_lines, + render: build_inline_assist_editor_renderer( + &prompt_editor, + gutter_dimensions, + ), + disposition: BlockDisposition::Above, }, - }], + BlockProperties { + style: BlockStyle::Sticky, + position: end_anchor, + height: 1, + render: Box::new(|cx| { + v_flex() + .h_full() + .w_full() + .border_t_1() + .border_color(cx.theme().status().info_border) + .into_any_element() + }), + disposition: BlockDisposition::Below, + }, + ], Some(Autoscroll::Strategy(AutoscrollStrategy::Newest)), cx, - )[0] + ); + (block_ids[0], block_ids[1]) }); self.pending_assists.insert( @@ -146,17 +168,22 @@ impl InlineAssistant { PendingInlineAssist { include_context, editor: editor.downgrade(), - inline_assist_editor: Some((block_id, inline_assist_editor.clone())), + editor_decorations: Some(PendingInlineAssistDecorations { + prompt_block_id, + prompt_editor: prompt_editor.clone(), + removed_line_block_ids: HashSet::default(), + end_block_id, + }), codegen: codegen.clone(), workspace, _subscriptions: vec![ - cx.subscribe(&inline_assist_editor, |inline_assist_editor, event, cx| { + cx.subscribe(&prompt_editor, |inline_assist_editor, event, cx| { InlineAssistant::update_global(cx, |this, cx| { this.handle_inline_assistant_event(inline_assist_editor, event, cx) }) }), cx.subscribe(editor, { - let inline_assist_editor = inline_assist_editor.downgrade(); + let inline_assist_editor = prompt_editor.downgrade(); move |editor, event, cx| { if let Some(inline_assist_editor) = inline_assist_editor.upgrade() { if let EditorEvent::SelectionsChanged { local } = event { @@ -176,7 +203,8 @@ impl InlineAssistant { move |_, cx| { if let Some(editor) = editor.upgrade() { InlineAssistant::update_global(cx, |this, cx| { - this.update_highlights_for_editor(&editor, cx); + this.update_editor_highlights(&editor, cx); + this.update_editor_blocks(&editor, inline_assist_id, cx); }) } } @@ -195,17 +223,15 @@ impl InlineAssistant { return; }; - let error = codegen - .read(cx) - .error() - .map(|error| format!("Inline assistant error: {}", error)); - if let Some(error) = error { - if pending_assist.inline_assist_editor.is_none() { + if let CodegenStatus::Error(error) = &codegen.read(cx).status { + if pending_assist.editor_decorations.is_none() { if let Some(workspace) = pending_assist .workspace .as_ref() .and_then(|workspace| workspace.upgrade()) { + let error = + format!("Inline assistant error: {}", error); workspace.update(cx, |workspace, cx| { struct InlineAssistantError; @@ -218,10 +244,10 @@ impl InlineAssistant { workspace.show_toast(Toast::new(id, error), cx); }) } - - this.finish_inline_assist(inline_assist_id, false, cx); } - } else { + } + + if pending_assist.editor_decorations.is_none() { this.finish_inline_assist(inline_assist_id, false, cx); } } @@ -239,7 +265,7 @@ impl InlineAssistant { }) .assist_ids .push(inline_assist_id); - self.update_highlights_for_editor(editor, cx); + self.update_editor_highlights(editor, cx); } fn handle_inline_assistant_event( @@ -250,14 +276,20 @@ impl InlineAssistant { ) { let assist_id = inline_assist_editor.read(cx).id; match event { - InlineAssistEditorEvent::Confirmed { prompt } => { - self.confirm_inline_assist(assist_id, prompt, cx); + InlineAssistEditorEvent::Started => { + self.start_inline_assist(assist_id, cx); + } + InlineAssistEditorEvent::Stopped => { + self.stop_inline_assist(assist_id, cx); + } + InlineAssistEditorEvent::Confirmed => { + self.finish_inline_assist(assist_id, false, cx); } InlineAssistEditorEvent::Canceled => { self.finish_inline_assist(assist_id, true, cx); } InlineAssistEditorEvent::Dismissed => { - self.hide_inline_assist(assist_id, cx); + self.hide_inline_assist_decorations(assist_id, cx); } InlineAssistEditorEvent::Resized { height_in_lines } => { self.resize_inline_assist(assist_id, *height_in_lines, cx); @@ -287,7 +319,7 @@ impl InlineAssistant { undo: bool, cx: &mut WindowContext, ) { - self.hide_inline_assist(assist_id, cx); + self.hide_inline_assist_decorations(assist_id, cx); if let Some(pending_assist) = self.pending_assists.remove(&assist_id) { if let hash_map::Entry::Occupied(mut entry) = self @@ -301,7 +333,7 @@ impl InlineAssistant { } if let Some(editor) = pending_assist.editor.upgrade() { - self.update_highlights_for_editor(&editor, cx); + self.update_editor_highlights(&editor, cx); if undo { pending_assist @@ -312,21 +344,37 @@ impl InlineAssistant { } } - fn hide_inline_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { - if let Some(pending_assist) = self.pending_assists.get_mut(&assist_id) { - if let Some(editor) = pending_assist.editor.upgrade() { - if let Some((block_id, inline_assist_editor)) = - pending_assist.inline_assist_editor.take() - { - editor.update(cx, |editor, cx| { - editor.remove_blocks(HashSet::from_iter([block_id]), None, cx); - if inline_assist_editor.focus_handle(cx).contains_focused(cx) { - editor.focus(cx); - } - }); - } + fn hide_inline_assist_decorations( + &mut self, + assist_id: InlineAssistId, + cx: &mut WindowContext, + ) -> bool { + let Some(pending_assist) = self.pending_assists.get_mut(&assist_id) else { + return false; + }; + let Some(editor) = pending_assist.editor.upgrade() else { + return false; + }; + let Some(decorations) = pending_assist.editor_decorations.take() else { + return false; + }; + + editor.update(cx, |editor, cx| { + let mut to_remove = decorations.removed_line_block_ids; + to_remove.insert(decorations.prompt_block_id); + to_remove.insert(decorations.end_block_id); + editor.remove_blocks(to_remove, None, cx); + if decorations + .prompt_editor + .focus_handle(cx) + .contains_focused(cx) + { + editor.focus(cx); } - } + }); + + self.update_editor_highlights(&editor, cx); + true } fn resize_inline_assist( @@ -337,17 +385,16 @@ impl InlineAssistant { ) { if let Some(pending_assist) = self.pending_assists.get_mut(&assist_id) { if let Some(editor) = pending_assist.editor.upgrade() { - if let Some((block_id, inline_assist_editor)) = - pending_assist.inline_assist_editor.as_ref() - { - let gutter_dimensions = inline_assist_editor.read(cx).gutter_dimensions.clone(); + if let Some(decorations) = pending_assist.editor_decorations.as_ref() { + let gutter_dimensions = + decorations.prompt_editor.read(cx).gutter_dimensions.clone(); let mut new_blocks = HashMap::default(); new_blocks.insert( - *block_id, + decorations.prompt_block_id, ( Some(height_in_lines), build_inline_assist_editor_renderer( - inline_assist_editor, + &decorations.prompt_editor, gutter_dimensions, ), ), @@ -362,12 +409,7 @@ impl InlineAssistant { } } - fn confirm_inline_assist( - &mut self, - assist_id: InlineAssistId, - user_prompt: &str, - cx: &mut WindowContext, - ) { + fn start_inline_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { let pending_assist = if let Some(pending_assist) = self.pending_assists.get_mut(&assist_id) { pending_assist @@ -375,6 +417,18 @@ impl InlineAssistant { return; }; + pending_assist + .codegen + .update(cx, |codegen, cx| codegen.undo(cx)); + + let Some(user_prompt) = pending_assist + .editor_decorations + .as_ref() + .map(|decorations| decorations.prompt_editor.read(cx).prompt(cx)) + else { + return; + }; + let context = if pending_assist.include_context { pending_assist.workspace.as_ref().and_then(|workspace| { let workspace = workspace.upgrade()?.read(cx); @@ -404,8 +458,8 @@ impl InlineAssistant { ) }); - self.prompt_history.retain(|prompt| prompt != user_prompt); - self.prompt_history.push_back(user_prompt.into()); + self.prompt_history.retain(|prompt| *prompt != user_prompt); + self.prompt_history.push_back(user_prompt.clone()); if self.prompt_history.len() > PROMPT_HISTORY_MAX_LEN { self.prompt_history.pop_front(); } @@ -453,8 +507,6 @@ impl InlineAssistant { 1.0 }; - let user_prompt = user_prompt.to_string(); - let prompt = cx.background_executor().spawn(async move { let language_name = language_name.as_deref(); generate_content_prompt(user_prompt, language_name, buffer, range, project_name) @@ -488,9 +540,24 @@ impl InlineAssistant { .detach_and_log_err(cx); } - fn update_highlights_for_editor(&self, editor: &View, cx: &mut WindowContext) { - let mut background_ranges = Vec::new(); + fn stop_inline_assist(&mut self, assist_id: InlineAssistId, cx: &mut WindowContext) { + let pending_assist = if let Some(pending_assist) = self.pending_assists.get_mut(&assist_id) + { + pending_assist + } else { + return; + }; + + pending_assist + .codegen + .update(cx, |codegen, cx| codegen.stop(cx)); + } + + fn update_editor_highlights(&self, editor: &View, cx: &mut WindowContext) { + let mut gutter_pending_ranges = Vec::new(); + let mut gutter_transformed_ranges = Vec::new(); let mut foreground_ranges = Vec::new(); + let mut inserted_row_ranges = Vec::new(); let empty_inline_assist_ids = Vec::new(); let inline_assist_ids = self .pending_assist_ids_by_editor @@ -502,23 +569,47 @@ impl InlineAssistant { for inline_assist_id in inline_assist_ids { if let Some(pending_assist) = self.pending_assists.get(inline_assist_id) { let codegen = pending_assist.codegen.read(cx); - background_ranges.push(codegen.range()); foreground_ranges.extend(codegen.last_equal_ranges().iter().cloned()); + + if codegen.edit_position != codegen.range().end { + gutter_pending_ranges.push(codegen.edit_position..codegen.range().end); + } + + if codegen.range().start != codegen.edit_position { + gutter_transformed_ranges.push(codegen.range().start..codegen.edit_position); + } + + if pending_assist.editor_decorations.is_some() { + inserted_row_ranges.extend(codegen.diff.inserted_row_ranges.iter().cloned()); + } } } let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); - merge_ranges(&mut background_ranges, &snapshot); merge_ranges(&mut foreground_ranges, &snapshot); + merge_ranges(&mut gutter_pending_ranges, &snapshot); + merge_ranges(&mut gutter_transformed_ranges, &snapshot); editor.update(cx, |editor, cx| { - if background_ranges.is_empty() { - editor.clear_background_highlights::(cx); + enum GutterPendingRange {} + if gutter_pending_ranges.is_empty() { + editor.clear_gutter_highlights::(cx); } else { - editor.highlight_background::( - &background_ranges, - |theme| theme.editor_active_line_background, // TODO use the appropriate color + editor.highlight_gutter::( + &gutter_pending_ranges, + |cx| cx.theme().status().info_background, cx, - ); + ) + } + + enum GutterTransformedRange {} + if gutter_transformed_ranges.is_empty() { + editor.clear_gutter_highlights::(cx); + } else { + editor.highlight_gutter::( + &gutter_transformed_ranges, + |cx| cx.theme().status().info, + cx, + ) } if foreground_ranges.is_empty() { @@ -533,8 +624,108 @@ impl InlineAssistant { cx, ); } + + editor.clear_row_highlights::(); + for row_range in inserted_row_ranges { + editor.highlight_rows::( + row_range, + Some(cx.theme().status().info_background), + false, + cx, + ); + } }); } + + fn update_editor_blocks( + &mut self, + editor: &View, + assist_id: InlineAssistId, + cx: &mut WindowContext, + ) { + let Some(pending_assist) = self.pending_assists.get_mut(&assist_id) else { + return; + }; + let Some(decorations) = pending_assist.editor_decorations.as_mut() else { + return; + }; + + let codegen = pending_assist.codegen.read(cx); + let old_snapshot = codegen.snapshot.clone(); + let old_buffer = codegen.old_buffer.clone(); + let deleted_row_ranges = codegen.diff.deleted_row_ranges.clone(); + + editor.update(cx, |editor, cx| { + let old_blocks = mem::take(&mut decorations.removed_line_block_ids); + editor.remove_blocks(old_blocks, None, cx); + + let mut new_blocks = Vec::new(); + for (new_row, old_row_range) in deleted_row_ranges { + let (_, buffer_start) = old_snapshot + .point_to_buffer_offset(Point::new(*old_row_range.start(), 0)) + .unwrap(); + let (_, buffer_end) = old_snapshot + .point_to_buffer_offset(Point::new( + *old_row_range.end(), + old_snapshot.line_len(MultiBufferRow(*old_row_range.end())), + )) + .unwrap(); + + let deleted_lines_editor = cx.new_view(|cx| { + let multi_buffer = cx.new_model(|_| { + MultiBuffer::without_headers(0, language::Capability::ReadOnly) + }); + multi_buffer.update(cx, |multi_buffer, cx| { + multi_buffer.push_excerpts( + old_buffer.clone(), + Some(ExcerptRange { + context: buffer_start..buffer_end, + primary: None, + }), + cx, + ); + }); + + enum DeletedLines {} + let mut editor = Editor::for_multibuffer(multi_buffer, None, true, cx); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::None, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_gutter(false, cx); + editor.scroll_manager.set_forbid_vertical_scroll(true); + editor.set_read_only(true); + editor.highlight_rows::( + Anchor::min()..=Anchor::max(), + Some(cx.theme().status().deleted_background), + false, + cx, + ); + editor + }); + + let height = deleted_lines_editor + .update(cx, |editor, cx| editor.max_point(cx).row().0 as u8 + 1); + new_blocks.push(BlockProperties { + position: new_row, + height, + style: BlockStyle::Flex, + render: Box::new(move |cx| { + div() + .bg(cx.theme().status().deleted_background) + .size_full() + .pl(cx.gutter_dimensions.full_width()) + .child(deleted_lines_editor.clone()) + .into_any_element() + }), + disposition: BlockDisposition::Above, + }); + } + + decorations.removed_line_block_ids = editor + .insert_blocks(new_blocks, None, cx) + .into_iter() + .collect(); + }) + } } fn build_inline_assist_editor_renderer( @@ -560,7 +751,9 @@ impl InlineAssistId { } enum InlineAssistEditorEvent { - Confirmed { prompt: String }, + Started, + Stopped, + Confirmed, Canceled, Dismissed, Resized { height_in_lines: u8 }, @@ -570,12 +763,13 @@ struct InlineAssistEditor { id: InlineAssistId, height_in_lines: u8, prompt_editor: View, - confirmed: bool, + edited_since_done: bool, gutter_dimensions: Arc>, prompt_history: VecDeque, prompt_history_ix: Option, pending_prompt: String, codegen: Model, + workspace: Option>, _subscriptions: Vec, } @@ -584,39 +778,170 @@ impl EventEmitter for InlineAssistEditor {} impl Render for InlineAssistEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let gutter_dimensions = *self.gutter_dimensions.lock(); - let icon_size = IconSize::default(); - h_flex() - .w_full() - .py_1p5() - .border_y_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .on_action(cx.listener(Self::confirm)) - .on_action(cx.listener(Self::cancel)) - .on_action(cx.listener(Self::move_up)) - .on_action(cx.listener(Self::move_down)) - .child( - h_flex() - .w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0)) - .pr(gutter_dimensions.fold_area_width()) - .justify_end() - .children(if let Some(error) = self.codegen.read(cx).error() { - let error_message = SharedString::from(error.to_string()); - Some( - div() - .id("error") - .tooltip(move |cx| Tooltip::text(error_message.clone(), cx)) - .child( - Icon::new(IconName::XCircle) - .size(icon_size) - .color(Color::Error), - ), - ) + + let buttons = match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + vec![ + IconButton::new("start", IconName::Sparkle) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .tooltip(|cx| Tooltip::for_action("Transform", &menu::Confirm, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(InlineAssistEditorEvent::Started)), + ), + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(InlineAssistEditorEvent::Canceled)), + ), + ] + } + CodegenStatus::Pending => { + vec![ + IconButton::new("stop", IconName::Stop) + .icon_color(Color::Error) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .tooltip(|cx| { + Tooltip::with_meta( + "Interrupt Transformation", + Some(&menu::Cancel), + "Changes won't be discarded", + cx, + ) + }) + .on_click( + cx.listener(|_, _, cx| cx.emit(InlineAssistEditorEvent::Stopped)), + ), + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::text("Cancel Assist", cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(InlineAssistEditorEvent::Canceled)), + ), + ] + } + CodegenStatus::Error(_) | CodegenStatus::Done => { + vec![ + if self.edited_since_done { + IconButton::new("restart", IconName::RotateCw) + .icon_color(Color::Info) + .icon_size(IconSize::XSmall) + .size(ButtonSize::None) + .tooltip(|cx| { + Tooltip::with_meta( + "Restart Transformation", + Some(&menu::Confirm), + "Changes will be discarded", + cx, + ) + }) + .on_click(cx.listener(|_, _, cx| { + cx.emit(InlineAssistEditorEvent::Started); + })) } else { - None - }), - ) - .child(div().flex_1().child(self.render_prompt_editor(cx))) + IconButton::new("confirm", IconName::Check) + .icon_color(Color::Info) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Confirm Assist", &menu::Confirm, cx)) + .on_click(cx.listener(|_, _, cx| { + cx.emit(InlineAssistEditorEvent::Confirmed); + })) + }, + IconButton::new("cancel", IconName::Close) + .icon_color(Color::Muted) + .size(ButtonSize::None) + .tooltip(|cx| Tooltip::for_action("Cancel Assist", &menu::Cancel, cx)) + .on_click( + cx.listener(|_, _, cx| cx.emit(InlineAssistEditorEvent::Canceled)), + ), + ] + } + }; + + v_flex().h_full().w_full().justify_end().child( + h_flex() + .bg(cx.theme().colors().editor_background) + .border_y_1() + .border_color(cx.theme().status().info_border) + .py_1p5() + .w_full() + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::move_up)) + .on_action(cx.listener(Self::move_down)) + .child( + h_flex() + .w(gutter_dimensions.full_width() + (gutter_dimensions.margin / 2.0)) + // .pr(gutter_dimensions.fold_area_width()) + .justify_center() + .gap_2() + .children(self.workspace.clone().map(|workspace| { + IconButton::new("context", IconName::Context) + .size(ButtonSize::None) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .on_click({ + let workspace = workspace.clone(); + cx.listener(move |_, _, cx| { + workspace + .update(cx, |workspace, cx| { + workspace.focus_panel::(cx); + }) + .ok(); + }) + }) + .tooltip(move |cx| { + let token_count = workspace.upgrade().and_then(|workspace| { + let panel = + workspace.read(cx).panel::(cx)?; + let context = panel.read(cx).active_context(cx)?; + context.read(cx).token_count() + }); + if let Some(token_count) = token_count { + Tooltip::with_meta( + format!( + "{} Additional Context Tokens from Assistant", + token_count + ), + Some(&crate::ToggleFocus), + "Click to open…", + cx, + ) + } else { + Tooltip::for_action( + "Toggle Assistant Panel", + &crate::ToggleFocus, + cx, + ) + } + }) + })) + .children( + if let CodegenStatus::Error(error) = &self.codegen.read(cx).status { + let error_message = SharedString::from(error.to_string()); + Some( + div() + .id("error") + .tooltip(move |cx| Tooltip::text(error_message.clone(), cx)) + .child( + Icon::new(IconName::XCircle) + .size(IconSize::Small) + .color(Color::Error), + ), + ) + } else { + None + }, + ), + ) + .child(div().flex_1().child(self.render_prompt_editor(cx))) + .child(h_flex().gap_2().pr_4().children(buttons)), + ) } } @@ -635,16 +960,13 @@ impl InlineAssistEditor { gutter_dimensions: Arc>, prompt_history: VecDeque, codegen: Model, + workspace: Option>, cx: &mut ViewContext, ) -> Self { let prompt_editor = cx.new_view(|cx| { let mut editor = Editor::auto_height(Self::MAX_LINES as usize, cx); editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); - let placeholder = match codegen.read(cx).kind() { - CodegenKind::Transform { .. } => "Enter transformation prompt…", - CodegenKind::Generate { .. } => "Enter generation prompt…", - }; - editor.set_placeholder_text(placeholder, cx); + editor.set_placeholder_text("Add a prompt…", cx); editor }); cx.focus_view(&prompt_editor); @@ -659,21 +981,26 @@ impl InlineAssistEditor { id, height_in_lines: 1, prompt_editor, - confirmed: false, + edited_since_done: false, gutter_dimensions, prompt_history, prompt_history_ix: None, pending_prompt: String::new(), codegen, + workspace, _subscriptions: subscriptions, }; this.count_lines(cx); this } + fn prompt(&self, cx: &AppContext) -> String { + self.prompt_editor.read(cx).text(cx) + } + fn count_lines(&mut self, cx: &mut ViewContext) { let height_in_lines = cmp::max( - 2, // Make the editor at least two lines tall, to account for padding. + 2, // Make the editor at least two lines tall, to account for padding and buttons. cmp::min( self.prompt_editor .update(cx, |editor, cx| editor.max_point(cx).row().0 + 1), @@ -699,12 +1026,25 @@ impl InlineAssistEditor { ) { match event { EditorEvent::Edited => { + self.edited_since_done = true; self.pending_prompt = self.prompt_editor.read(cx).text(cx); cx.notify(); } EditorEvent::Blurred => { - if !self.confirmed { - cx.emit(InlineAssistEditorEvent::Canceled); + if let CodegenStatus::Idle = &self.codegen.read(cx).status { + let assistant_panel_is_focused = self + .workspace + .as_ref() + .and_then(|workspace| { + let panel = + workspace.upgrade()?.read(cx).panel::(cx)?; + Some(panel.focus_handle(cx).contains_focused(cx)) + }) + .unwrap_or(false); + + if !assistant_panel_is_focused { + cx.emit(InlineAssistEditorEvent::Canceled); + } } } _ => {} @@ -712,35 +1052,49 @@ impl InlineAssistEditor { } fn handle_codegen_changed(&mut self, _: Model, cx: &mut ViewContext) { - let is_read_only = !self.codegen.read(cx).idle(); - self.prompt_editor.update(cx, |editor, cx| { - let was_read_only = editor.read_only(cx); - if was_read_only != is_read_only { - if is_read_only { - editor.set_read_only(true); - } else { - self.confirmed = false; - editor.set_read_only(false); - } + match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + self.prompt_editor + .update(cx, |editor, _| editor.set_read_only(false)); } - }); - cx.notify(); + CodegenStatus::Pending => { + self.prompt_editor + .update(cx, |editor, _| editor.set_read_only(true)); + } + CodegenStatus::Done | CodegenStatus::Error(_) => { + self.edited_since_done = false; + self.prompt_editor + .update(cx, |editor, _| editor.set_read_only(false)); + } + } } fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { - cx.emit(InlineAssistEditorEvent::Canceled); + match &self.codegen.read(cx).status { + CodegenStatus::Idle | CodegenStatus::Done | CodegenStatus::Error(_) => { + cx.emit(InlineAssistEditorEvent::Canceled); + } + CodegenStatus::Pending => { + cx.emit(InlineAssistEditorEvent::Stopped); + } + } } fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { - if self.confirmed { - cx.emit(InlineAssistEditorEvent::Dismissed); - } else { - let prompt = self.prompt_editor.read(cx).text(cx); - self.prompt_editor - .update(cx, |editor, _cx| editor.set_read_only(true)); - cx.emit(InlineAssistEditorEvent::Confirmed { prompt }); - self.confirmed = true; - cx.notify(); + match &self.codegen.read(cx).status { + CodegenStatus::Idle => { + cx.emit(InlineAssistEditorEvent::Started); + } + CodegenStatus::Pending => { + cx.emit(InlineAssistEditorEvent::Dismissed); + } + CodegenStatus::Done | CodegenStatus::Error(_) => { + if self.edited_since_done { + cx.emit(InlineAssistEditorEvent::Started); + } else { + cx.emit(InlineAssistEditorEvent::Confirmed); + } + } } } @@ -814,13 +1168,20 @@ impl InlineAssistEditor { struct PendingInlineAssist { editor: WeakView, - inline_assist_editor: Option<(BlockId, View)>, + editor_decorations: Option, codegen: Model, _subscriptions: Vec, workspace: Option>, include_context: bool, } +struct PendingInlineAssistDecorations { + prompt_block_id: BlockId, + prompt_editor: View, + removed_line_block_ids: HashSet, + end_block_id: BlockId, +} + #[derive(Debug)] pub enum CodegenEvent { Finished, @@ -833,19 +1194,45 @@ pub enum CodegenKind { Generate { position: Anchor }, } +impl CodegenKind { + fn range(&self, snapshot: &MultiBufferSnapshot) -> Range { + match self { + CodegenKind::Transform { range } => range.clone(), + CodegenKind::Generate { position } => position.bias_left(snapshot)..*position, + } + } +} + pub struct Codegen { buffer: Model, + old_buffer: Model, snapshot: MultiBufferSnapshot, kind: CodegenKind, + edit_position: Anchor, last_equal_ranges: Vec>, transaction_id: Option, - error: Option, + status: CodegenStatus, generation: Task<()>, - idle: bool, + diff: Diff, telemetry: Option>, _subscription: gpui::Subscription, } +enum CodegenStatus { + Idle, + Pending, + Done, + Error(anyhow::Error), +} + +#[derive(Default)] +struct Diff { + task: Option>, + should_update: bool, + deleted_row_ranges: Vec<(Anchor, RangeInclusive)>, + inserted_row_ranges: Vec>, +} + impl EventEmitter for Codegen {} impl Codegen { @@ -856,15 +1243,38 @@ impl Codegen { cx: &mut ModelContext, ) -> Self { let snapshot = buffer.read(cx).snapshot(cx); + + let (old_buffer, _, _) = buffer + .read(cx) + .range_to_buffer_ranges(kind.range(&snapshot), cx) + .pop() + .unwrap(); + let old_buffer = cx.new_model(|cx| { + let old_buffer = old_buffer.read(cx); + let text = old_buffer.as_rope().clone(); + let line_ending = old_buffer.line_ending(); + let language = old_buffer.language().cloned(); + let language_registry = old_buffer.language_registry(); + + let mut buffer = Buffer::local_normalized(text, line_ending, cx); + buffer.set_language(language, cx); + if let Some(language_registry) = language_registry { + buffer.set_language_registry(language_registry) + } + buffer + }); + Self { buffer: buffer.clone(), + old_buffer, + edit_position: kind.range(&snapshot).start, snapshot, kind, last_equal_ranges: Default::default(), transaction_id: Default::default(), - error: Default::default(), - idle: true, + status: CodegenStatus::Idle, generation: Task::ready(()), + diff: Diff::default(), telemetry, _subscription: cx.subscribe(&buffer, Self::handle_buffer_event), } @@ -886,28 +1296,13 @@ impl Codegen { } pub fn range(&self) -> Range { - match &self.kind { - CodegenKind::Transform { range } => range.clone(), - CodegenKind::Generate { position } => position.bias_left(&self.snapshot)..*position, - } - } - - pub fn kind(&self) -> &CodegenKind { - &self.kind + self.kind.range(&self.snapshot) } pub fn last_equal_ranges(&self) -> &[Range] { &self.last_equal_ranges } - pub fn idle(&self) -> bool { - self.idle - } - - pub fn error(&self) -> Option<&anyhow::Error> { - self.error.as_ref() - } - pub fn start(&mut self, prompt: LanguageModelRequest, cx: &mut ModelContext) { let range = self.range(); let snapshot = self.snapshot.clone(); @@ -925,6 +1320,9 @@ impl Codegen { let model_telemetry_id = prompt.model.telemetry_id(); let response = CompletionProvider::global(cx).complete(prompt); let telemetry = self.telemetry.clone(); + self.edit_position = range.start; + self.diff = Diff::default(); + self.status = CodegenStatus::Pending; self.generation = cx.spawn(|this, mut cx| { async move { let generate = async { @@ -1058,6 +1456,7 @@ impl Codegen { None, cx, ); + this.edit_position = snapshot.anchor_after(edit_start); buffer.end_transaction(cx) }); @@ -1080,6 +1479,7 @@ impl Codegen { } } + this.update_diff(cx); cx.notify(); })?; } @@ -1092,9 +1492,10 @@ impl Codegen { let result = generate.await; this.update(&mut cx, |this, cx| { this.last_equal_ranges.clear(); - this.idle = true; if let Err(error) = result { - this.error = Some(error); + this.status = CodegenStatus::Error(error); + } else { + this.status = CodegenStatus::Done; } cx.emit(CodegenEvent::Finished); cx.notify(); @@ -1102,17 +1503,122 @@ impl Codegen { .ok(); } }); - self.error.take(); - self.idle = false; + cx.notify(); + } + + pub fn stop(&mut self, cx: &mut ModelContext) { + self.last_equal_ranges.clear(); + self.status = CodegenStatus::Done; + self.generation = Task::ready(()); + cx.emit(CodegenEvent::Finished); cx.notify(); } pub fn undo(&mut self, cx: &mut ModelContext) { - if let Some(transaction_id) = self.transaction_id { + if let Some(transaction_id) = self.transaction_id.take() { self.buffer .update(cx, |buffer, cx| buffer.undo_transaction(transaction_id, cx)); } } + + fn update_diff(&mut self, cx: &mut ModelContext) { + if self.diff.task.is_some() { + self.diff.should_update = true; + } else { + self.diff.should_update = false; + + let old_snapshot = self.snapshot.clone(); + let old_range = self.range().to_point(&old_snapshot); + let new_snapshot = self.buffer.read(cx).snapshot(cx); + let new_range = self.range().to_point(&new_snapshot); + + self.diff.task = Some(cx.spawn(|this, mut cx| async move { + let (deleted_row_ranges, inserted_row_ranges) = cx + .background_executor() + .spawn(async move { + let old_text = old_snapshot + .text_for_range( + Point::new(old_range.start.row, 0) + ..Point::new( + old_range.end.row, + old_snapshot.line_len(MultiBufferRow(old_range.end.row)), + ), + ) + .collect::(); + let new_text = new_snapshot + .text_for_range( + Point::new(new_range.start.row, 0) + ..Point::new( + new_range.end.row, + new_snapshot.line_len(MultiBufferRow(new_range.end.row)), + ), + ) + .collect::(); + + let mut old_row = old_range.start.row; + let mut new_row = new_range.start.row; + let diff = TextDiff::from_lines(old_text.as_str(), new_text.as_str()); + + let mut deleted_row_ranges: Vec<(Anchor, RangeInclusive)> = Vec::new(); + let mut inserted_row_ranges = Vec::new(); + for change in diff.iter_all_changes() { + let line_count = change.value().lines().count() as u32; + match change.tag() { + similar::ChangeTag::Equal => { + old_row += line_count; + new_row += line_count; + } + similar::ChangeTag::Delete => { + let old_end_row = old_row + line_count - 1; + let new_row = + new_snapshot.anchor_before(Point::new(new_row, 0)); + + if let Some((_, last_deleted_row_range)) = + deleted_row_ranges.last_mut() + { + if *last_deleted_row_range.end() + 1 == old_row { + *last_deleted_row_range = + *last_deleted_row_range.start()..=old_end_row; + } else { + deleted_row_ranges + .push((new_row, old_row..=old_end_row)); + } + } else { + deleted_row_ranges.push((new_row, old_row..=old_end_row)); + } + + old_row += line_count; + } + similar::ChangeTag::Insert => { + let new_end_row = new_row + line_count - 1; + let start = new_snapshot.anchor_before(Point::new(new_row, 0)); + let end = new_snapshot.anchor_before(Point::new( + new_end_row, + new_snapshot.line_len(MultiBufferRow(new_end_row)), + )); + inserted_row_ranges.push(start..=end); + new_row += line_count; + } + } + } + + (deleted_row_ranges, inserted_row_ranges) + }) + .await; + + this.update(&mut cx, |this, cx| { + this.diff.deleted_row_ranges = deleted_row_ranges; + this.diff.inserted_row_ranges = inserted_row_ranges; + this.diff.task = None; + if this.diff.should_update { + this.update_diff(cx); + } + cx.notify(); + }) + .ok(); + })); + } + } } fn strip_invalid_spans_from_codeblock( diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 1694b83231..5bed94a7ec 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -52,8 +52,14 @@ use multi_buffer::{ ToOffset, ToPoint, }; use serde::Deserialize; -use std::ops::Add; -use std::{any::TypeId, borrow::Cow, fmt::Debug, num::NonZeroU32, ops::Range, sync::Arc}; +use std::{ + any::TypeId, + borrow::Cow, + fmt::Debug, + num::NonZeroU32, + ops::{Add, Range, Sub}, + sync::Arc, +}; use sum_tree::{Bias, TreeMap}; use tab_map::{TabMap, TabSnapshot}; use text::LineIndent; @@ -1027,6 +1033,14 @@ impl Add for DisplayRow { } } +impl Sub for DisplayRow { + type Output = Self; + + fn sub(self, other: Self) -> Self::Output { + DisplayRow(self.0 - other.0) + } +} + impl DisplayPoint { pub fn new(row: DisplayRow, column: u32) -> Self { Self(BlockPoint(Point::new(row.0, column))) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 16402795ee..1e02312ecb 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -376,6 +376,7 @@ type CompletionId = usize; // type OverrideTextStyle = dyn Fn(&EditorStyle) -> Option; type BackgroundHighlight = (fn(&ThemeColors) -> Hsla, Arc<[Range]>); +type GutterHighlight = (fn(&AppContext) -> Hsla, Arc<[Range]>); struct ScrollbarMarkerState { scrollbar_size: Size, @@ -464,6 +465,7 @@ pub struct Editor { highlight_order: usize, highlighted_rows: HashMap>, background_highlights: TreeMap, + gutter_highlights: TreeMap, scrollbar_marker_state: ScrollbarMarkerState, active_indent_guides_state: ActiveIndentGuidesState, nav_history: Option, @@ -1752,6 +1754,7 @@ impl Editor { highlight_order: 0, highlighted_rows: HashMap::default(), background_highlights: Default::default(), + gutter_highlights: TreeMap::default(), scrollbar_marker_state: ScrollbarMarkerState::default(), active_indent_guides_state: ActiveIndentGuidesState::default(), nav_history: None, @@ -10263,6 +10266,25 @@ impl Editor { Some(text_highlights) } + pub fn highlight_gutter( + &mut self, + ranges: &[Range], + color_fetcher: fn(&AppContext) -> Hsla, + cx: &mut ViewContext, + ) { + self.gutter_highlights + .insert(TypeId::of::(), (color_fetcher, Arc::from(ranges))); + cx.notify(); + } + + pub fn clear_gutter_highlights( + &mut self, + cx: &mut ViewContext, + ) -> Option { + cx.notify(); + self.gutter_highlights.remove(&TypeId::of::()) + } + #[cfg(feature = "test-support")] pub fn all_text_background_highlights( &mut self, @@ -10452,6 +10474,44 @@ impl Editor { results } + pub fn gutter_highlights_in_range( + &self, + search_range: Range, + display_snapshot: &DisplaySnapshot, + cx: &AppContext, + ) -> Vec<(Range, Hsla)> { + let mut results = Vec::new(); + for (color_fetcher, ranges) in self.gutter_highlights.values() { + let color = color_fetcher(cx); + let start_ix = match ranges.binary_search_by(|probe| { + let cmp = probe + .end + .cmp(&search_range.start, &display_snapshot.buffer_snapshot); + if cmp.is_gt() { + Ordering::Greater + } else { + Ordering::Less + } + }) { + Ok(i) | Err(i) => i, + }; + for range in &ranges[start_ix..] { + if range + .start + .cmp(&search_range.end, &display_snapshot.buffer_snapshot) + .is_ge() + { + break; + } + + let start = range.start.to_display_point(&display_snapshot); + let end = range.end.to_display_point(&display_snapshot); + results.push((start..end, color)) + } + } + results + } + /// Get the text ranges corresponding to the redaction query pub fn redacted_ranges( &self, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4df0022855..7eca7685af 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -2837,6 +2837,8 @@ impl EditorElement { Self::paint_diff_hunks(layout.gutter_hitbox.bounds, layout, cx) } + self.paint_gutter_highlights(layout, cx); + if layout.blamed_display_rows.is_some() { self.paint_blamed_display_rows(layout, cx); } @@ -3006,6 +3008,37 @@ impl EditorElement { } } + fn paint_gutter_highlights(&self, layout: &EditorLayout, cx: &mut WindowContext) { + let highlight_width = 0.275 * layout.position_map.line_height; + let highlight_corner_radii = Corners::all(0.05 * layout.position_map.line_height); + cx.paint_layer(layout.gutter_hitbox.bounds, |cx| { + for (range, color) in &layout.highlighted_gutter_ranges { + let start_row = if range.start.row() < layout.visible_display_row_range.start { + layout.visible_display_row_range.start - DisplayRow(1) + } else { + range.start.row() + }; + let end_row = if range.end.row() > layout.visible_display_row_range.end { + layout.visible_display_row_range.end + DisplayRow(1) + } else { + range.end.row() + }; + + let start_y = layout.gutter_hitbox.top() + + start_row.0 as f32 * layout.position_map.line_height + - layout.position_map.scroll_pixel_position.y; + let end_y = layout.gutter_hitbox.top() + + (end_row.0 + 1) as f32 * layout.position_map.line_height + - layout.position_map.scroll_pixel_position.y; + let bounds = Bounds::from_corners( + point(layout.gutter_hitbox.left(), start_y), + point(layout.gutter_hitbox.left() + highlight_width, end_y), + ); + cx.paint_quad(fill(bounds, *color).corner_radii(highlight_corner_radii)); + } + }); + } + fn paint_blamed_display_rows(&self, layout: &mut EditorLayout, cx: &mut WindowContext) { let Some(blamed_display_rows) = layout.blamed_display_rows.take() else { return; @@ -4631,6 +4664,12 @@ impl Element for EditorElement { &snapshot.display_snapshot, cx.theme().colors(), ); + let highlighted_gutter_ranges = + self.editor.read(cx).gutter_highlights_in_range( + start_anchor..end_anchor, + &snapshot.display_snapshot, + cx, + ); let redacted_ranges = self.editor.read(cx).redacted_ranges( start_anchor..end_anchor, @@ -4991,6 +5030,7 @@ impl Element for EditorElement { active_rows, highlighted_rows, highlighted_ranges, + highlighted_gutter_ranges, redacted_ranges, line_elements, line_numbers, @@ -5121,6 +5161,7 @@ pub struct EditorLayout { inline_blame: Option, blocks: Vec, highlighted_ranges: Vec<(Range, Hsla)>, + highlighted_gutter_ranges: Vec<(Range, Hsla)>, redacted_ranges: Vec>, cursors: Vec<(DisplayPoint, Hsla)>, visible_cursors: Vec, diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 2fcae1b602..af2d90421f 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -49,7 +49,7 @@ schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -similar = "1.3" +similar.workspace = true smallvec.workspace = true smol.workspace = true sum_tree.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 36f905c961..31b6cb573e 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -797,6 +797,10 @@ impl Buffer { .set_language_registry(language_registry); } + pub fn language_registry(&self) -> Option> { + self.syntax_map.lock().language_registry() + } + /// Assign the buffer a new [Capability]. pub fn set_capability(&mut self, capability: Capability, cx: &mut ModelContext) { self.capability = capability; diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index cbda8cb12f..8103390eac 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -106,6 +106,7 @@ pub enum IconName { Code, Collab, Command, + Context, Control, Copilot, CopilotDisabled, @@ -170,6 +171,7 @@ pub enum IconName { Rerun, Return, Reveal, + RotateCw, Save, Screen, SelectAll, @@ -186,6 +188,7 @@ pub enum IconName { Split, Star, StarFilled, + Stop, Strikethrough, Supermaven, SupermavenDisabled, @@ -233,6 +236,7 @@ impl IconName { IconName::Code => "icons/code.svg", IconName::Collab => "icons/user_group_16.svg", IconName::Command => "icons/command.svg", + IconName::Context => "icons/context.svg", IconName::Control => "icons/control.svg", IconName::Copilot => "icons/copilot.svg", IconName::CopilotDisabled => "icons/copilot_disabled.svg", @@ -297,6 +301,7 @@ impl IconName { IconName::ReplyArrowRight => "icons/reply_arrow_right.svg", IconName::Rerun => "icons/rerun.svg", IconName::Return => "icons/return.svg", + IconName::RotateCw => "icons/rotate_cw.svg", IconName::Save => "icons/save.svg", IconName::Screen => "icons/desktop.svg", IconName::SelectAll => "icons/select_all.svg", @@ -313,6 +318,7 @@ impl IconName { IconName::Split => "icons/split.svg", IconName::Star => "icons/star.svg", IconName::StarFilled => "icons/star_filled.svg", + IconName::Stop => "icons/stop.svg", IconName::Strikethrough => "icons/strikethrough.svg", IconName::Supermaven => "icons/supermaven.svg", IconName::SupermavenDisabled => "icons/supermaven_disabled.svg",