diff --git a/assets/icons/reveal.svg b/assets/icons/reveal.svg new file mode 100644 index 0000000000000000000000000000000000000000..ff5444d8f84c311ac79c2f289b9f35a8c897fb0f --- /dev/null +++ b/assets/icons/reveal.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/save.svg b/assets/icons/save.svg new file mode 100644 index 0000000000000000000000000000000000000000..f83d035331c2badfd2e1411d06df97a0e37bd4e3 --- /dev/null +++ b/assets/icons/save.svg @@ -0,0 +1 @@ + diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index e58731a00b8696b8dde0a45085991c33fa086417..d125b0fc0feaa4b380584c4b5b6ed9832ab30bc3 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,12 +1,10 @@ use crate::ambient_context::{AmbientContext, ContextUpdated, RecentBuffer}; -use crate::prompts::prompt_library::PromptLibrary; -use crate::prompts::prompt_manager::PromptManager; +use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager}; use crate::{ ambient_context::*, assistant_settings::{AssistantDockPosition, AssistantSettings, ZedDotDevModel}, codegen::{self, Codegen, CodegenKind}, omit_ranges::text_in_range_omitting_ranges, - prompts::prompt::generate_content_prompt, search::*, slash_command::{ current_file_command, file_command, prompt_command, SlashCommandCleanup, @@ -148,7 +146,7 @@ impl AssistantPanel { .unwrap_or_default(); let prompt_library = Arc::new( - PromptLibrary::load(fs.clone()) + PromptLibrary::load_index(fs.clone()) .await .log_err() .unwrap_or_default(), diff --git a/crates/assistant/src/prompts.rs b/crates/assistant/src/prompts.rs index 9d0e2be23e590d93c63a0fbc46590a2b894eb7e6..a124b99a1ef2ae787b9766ec2222362c223d65e4 100644 --- a/crates/assistant/src/prompts.rs +++ b/crates/assistant/src/prompts.rs @@ -1,3 +1,7 @@ -pub mod prompt; -pub mod prompt_library; -pub mod prompt_manager; +mod prompt; +mod prompt_library; +mod prompt_manager; + +pub use prompt::*; +pub use prompt_library::*; +pub use prompt_manager::*; diff --git a/crates/assistant/src/prompts/prompt.rs b/crates/assistant/src/prompts/prompt.rs index f24bb7c965b5e02f6d78f20bd9a4b212c51dd62b..d93e8140a3053bb7d4690554b4bbe7d728d23528 100644 --- a/crates/assistant/src/prompts/prompt.rs +++ b/crates/assistant/src/prompts/prompt.rs @@ -1,10 +1,34 @@ +use fs::Fs; use language::BufferSnapshot; -use std::{fmt::Write, ops::Range}; +use std::{fmt::Write, ops::Range, path::PathBuf, sync::Arc}; use ui::SharedString; +use util::paths::PROMPTS_DIR; use gray_matter::{engine::YAML, Matter}; use serde::{Deserialize, Serialize}; +use super::prompt_library::PromptId; + +pub const PROMPT_DEFAULT_TITLE: &str = "Untitled Prompt"; + +fn standardize_value(value: String) -> String { + value.replace(['\n', '\r', '"', '\''], "") +} + +fn slugify(input: String) -> String { + let mut slug = String::new(); + for c in input.chars() { + if c.is_alphanumeric() { + slug.push(c.to_ascii_lowercase()); + } else if c.is_whitespace() { + slug.push('-'); + } else { + slug.push('_'); + } + } + slug +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct StaticPromptFrontmatter { title: String, @@ -19,15 +43,51 @@ pub struct StaticPromptFrontmatter { impl Default for StaticPromptFrontmatter { fn default() -> Self { Self { - title: "Untitled Prompt".to_string(), + title: PROMPT_DEFAULT_TITLE.to_string(), version: "1.0".to_string(), - author: "No Author".to_string(), - languages: vec!["*".to_string()], + author: "You ".to_string(), + languages: vec![], dependencies: vec![], } } } +impl StaticPromptFrontmatter { + /// Returns the frontmatter as a markdown frontmatter string + pub fn frontmatter_string(&self) -> String { + let mut frontmatter = format!( + "---\ntitle: \"{}\"\nversion: \"{}\"\nauthor: \"{}\"\n", + standardize_value(self.title.clone()), + standardize_value(self.version.clone()), + standardize_value(self.author.clone()), + ); + + if !self.languages.is_empty() { + let languages = self + .languages + .iter() + .map(|l| standardize_value(l.clone())) + .collect::>() + .join(", "); + writeln!(frontmatter, "languages: [{}]", languages).unwrap(); + } + + if !self.dependencies.is_empty() { + let dependencies = self + .dependencies + .iter() + .map(|d| standardize_value(d.clone())) + .collect::>() + .join(", "); + writeln!(frontmatter, "dependencies: [{}]", dependencies).unwrap(); + } + + frontmatter.push_str("---\n"); + + frontmatter + } +} + /// A static prompt that can be loaded into the prompt library /// from Markdown with a frontmatter header /// @@ -62,16 +122,39 @@ impl Default for StaticPromptFrontmatter { /// ``` #[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct StaticPrompt { + #[serde(skip_deserializing)] + id: PromptId, #[serde(skip)] metadata: StaticPromptFrontmatter, content: String, - file_name: Option, + file_name: Option, +} + +impl Default for StaticPrompt { + fn default() -> Self { + let metadata = StaticPromptFrontmatter::default(); + + let content = metadata.clone().frontmatter_string(); + + Self { + id: PromptId::new(), + metadata, + content, + file_name: None, + } + } } impl StaticPrompt { pub fn new(content: String, file_name: Option) -> Self { let matter = Matter::::new(); let result = matter.parse(&content); + let file_name = if let Some(file_name) = file_name { + let shared_filename: SharedString = file_name.into(); + Some(shared_filename) + } else { + None + }; let metadata = result .data @@ -91,19 +174,48 @@ impl StaticPrompt { StaticPromptFrontmatter::default() }); + let id = if let Some(file_name) = &file_name { + PromptId::from_str(file_name).unwrap_or_default() + } else { + PromptId::new() + }; + StaticPrompt { + id, content, file_name, metadata, } } + + pub fn update(&mut self, id: PromptId, content: String) { + let mut updated_prompt = + StaticPrompt::new(content, self.file_name.clone().map(|s| s.to_string())); + updated_prompt.id = id; + *self = updated_prompt; + } } impl StaticPrompt { + /// Returns the prompt's id + pub fn id(&self) -> &PromptId { + &self.id + } + + pub fn file_name(&self) -> Option<&SharedString> { + self.file_name.as_ref() + } + /// Sets the file name of the prompt - pub fn _file_name(&mut self, file_name: String) -> &mut Self { - self.file_name = Some(file_name); - self + pub fn new_file_name(&self) -> String { + let in_name = format!( + "{}_{}_{}", + standardize_value(self.metadata.title.clone()), + standardize_value(self.metadata.version.clone()), + standardize_value(self.id.0.to_string()) + ); + let out_name = slugify(in_name); + out_name } /// Returns the prompt's content @@ -126,6 +238,32 @@ impl StaticPrompt { let result = matter.parse(self.content.as_str()); result.content.clone() } + + pub fn path(&self) -> Option { + if let Some(file_name) = self.file_name() { + let path_str = format!("{}", file_name); + Some(PROMPTS_DIR.join(path_str)) + } else { + None + } + } + + pub async fn save(&self, fs: Arc) -> anyhow::Result<()> { + let file_name = self.file_name(); + let new_file_name = self.new_file_name(); + + let out_name = if let Some(file_name) = file_name { + file_name.to_owned().to_string() + } else { + format!("{}.md", new_file_name) + }; + let path = PROMPTS_DIR.join(&out_name); + let json = self.content.clone(); + + fs.atomic_write(path, json).await?; + + Ok(()) + } } pub fn generate_content_prompt( diff --git a/crates/assistant/src/prompts/prompt_library.rs b/crates/assistant/src/prompts/prompt_library.rs index 50fff5b6ee18c15f908ec50e9ea56ea041f8e4b3..929dbd97169cf13104d4d55e97f480661a289d01 100644 --- a/crates/assistant/src/prompts/prompt_library.rs +++ b/crates/assistant/src/prompts/prompt_library.rs @@ -25,6 +25,16 @@ impl PromptId { pub fn new() -> Self { Self(Uuid::new_v4()) } + + pub fn from_str(id: &str) -> anyhow::Result { + Ok(Self(Uuid::parse_str(id)?)) + } +} + +impl Default for PromptId { + fn default() -> Self { + Self::new() + } } #[derive(Default, Serialize, Deserialize)] @@ -56,13 +66,20 @@ impl PromptLibrary { } } - pub fn prompts(&self) -> Vec<(PromptId, StaticPrompt)> { + pub fn new_prompt(&self) -> StaticPrompt { + StaticPrompt::default() + } + + pub fn add_prompt(&self, prompt: StaticPrompt) { + let mut state = self.state.write(); + let id = *prompt.id(); + state.prompts.insert(id, prompt); + state.version += 1; + } + + pub fn prompts(&self) -> HashMap { let state = self.state.read(); - state - .prompts - .iter() - .map(|(id, prompt)| (*id, prompt.clone())) - .collect() + state.prompts.clone() } pub fn sorted_prompts(&self, sort_order: SortOrder) -> Vec<(PromptId, StaticPrompt)> { @@ -81,36 +98,37 @@ impl PromptLibrary { prompts } + pub fn prompt_by_id(&self, id: PromptId) -> Option { + let state = self.state.read(); + state.prompts.get(&id).cloned() + } + pub fn first_prompt_id(&self) -> Option { let state = self.state.read(); state.prompts.keys().next().cloned() } - pub fn prompt(&self, id: PromptId) -> Option { + pub fn is_dirty(&self, id: &PromptId) -> bool { let state = self.state.read(); - state.prompts.get(&id).cloned() + state.dirty_prompts.contains(&id) } - /// Save the current state of the prompt library to the - /// file system as a JSON file - pub async fn save(&self, fs: Arc) -> anyhow::Result<()> { - fs.create_dir(&PROMPTS_DIR).await?; - - let path = PROMPTS_DIR.join("index.json"); - - let json = { - let state = self.state.read(); - serde_json::to_string(&*state)? - }; - - fs.atomic_write(path, json).await?; - - Ok(()) + pub fn set_dirty(&self, id: PromptId, dirty: bool) { + let mut state = self.state.write(); + if dirty { + if !state.dirty_prompts.contains(&id) { + state.dirty_prompts.push(id); + } + state.version += 1; + } else { + state.dirty_prompts.retain(|&i| i != id); + state.version += 1; + } } /// Load the state of the prompt library from the file system /// or create a new one if it doesn't exist - pub async fn load(fs: Arc) -> anyhow::Result { + pub async fn load_index(fs: Arc) -> anyhow::Result { let path = PROMPTS_DIR.join("index.json"); let state = if fs.is_file(&path).await { @@ -132,9 +150,6 @@ impl PromptLibrary { /// Load all prompts from the file system /// adding them to the library if they don't already exist pub async fn load_prompts(&mut self, fs: Arc) -> anyhow::Result<()> { - // let current_prompts = self.all_prompt_contents().clone(); - - // For now, we'll just clear the prompts and reload them all self.state.get_mut().prompts.clear(); let mut prompt_paths = fs.read_dir(&PROMPTS_DIR).await?; @@ -182,7 +197,48 @@ impl PromptLibrary { } // Write any changes back to the file system - self.save(fs.clone()).await?; + self.save_index(fs.clone()).await?; + + Ok(()) + } + + /// Save the current state of the prompt library to the + /// file system as a JSON file + pub async fn save_index(&self, fs: Arc) -> anyhow::Result<()> { + fs.create_dir(&PROMPTS_DIR).await?; + + let path = PROMPTS_DIR.join("index.json"); + + let json = { + let state = self.state.read(); + serde_json::to_string(&*state)? + }; + + fs.atomic_write(path, json).await?; + + Ok(()) + } + + pub async fn save_prompt( + &self, + prompt_id: PromptId, + updated_content: Option, + fs: Arc, + ) -> anyhow::Result<()> { + if let Some(updated_content) = updated_content { + let mut state = self.state.write(); + if let Some(prompt) = state.prompts.get_mut(&prompt_id) { + prompt.update(prompt_id, updated_content); + state.version += 1; + } + } + + if let Some(prompt) = self.prompt_by_id(prompt_id) { + prompt.save(fs).await?; + self.set_dirty(prompt_id, false); + } else { + log::warn!("Failed to save prompt: {:?}", prompt_id); + } Ok(()) } diff --git a/crates/assistant/src/prompts/prompt_manager.rs b/crates/assistant/src/prompts/prompt_manager.rs index c16c9140fb8ab4cce72675dc2211e2c8b6749fce..ad3862a80057f1b7083ca999d55a0db14563b89f 100644 --- a/crates/assistant/src/prompts/prompt_manager.rs +++ b/crates/assistant/src/prompts/prompt_manager.rs @@ -1,16 +1,17 @@ use collections::HashMap; -use editor::Editor; +use editor::{Editor, EditorEvent}; use fs::Fs; use gpui::{prelude::FluentBuilder, *}; use language::{language_settings, Buffer, LanguageRegistry}; use picker::{Picker, PickerDelegate}; use std::sync::Arc; -use ui::{prelude::*, IconButtonShape, ListItem, ListItemSpacing}; +use ui::{prelude::*, IconButtonShape, Indicator, ListItem, ListItemSpacing, Tooltip}; use util::{ResultExt, TryFutureExt}; use workspace::ModalView; -use super::prompt_library::{PromptId, PromptLibrary, SortOrder}; -use crate::prompts::prompt::StaticPrompt; +use crate::prompts::{PromptId, PromptLibrary, SortOrder, StaticPrompt, PROMPT_DEFAULT_TITLE}; + +actions!(prompt_manager, [NewPrompt, SavePrompt]); pub struct PromptManager { focus_handle: FocusHandle, @@ -21,6 +22,8 @@ pub struct PromptManager { picker: View>, prompt_editors: HashMap>, active_prompt_id: Option, + last_new_prompt_id: Option, + _subscriptions: Vec, } impl PromptManager { @@ -39,6 +42,7 @@ impl PromptManager { matching_prompt_ids: vec![], prompt_library: prompt_library.clone(), selected_index: 0, + _subscriptions: vec![], }, cx, ) @@ -48,6 +52,11 @@ impl PromptManager { let focus_handle = picker.focus_handle(cx); + let subscriptions = vec![ + // cx.on_focus_in(&focus_handle, Self::focus_in), + // cx.on_focus_out(&focus_handle, Self::focus_out), + ]; + let mut manager = Self { focus_handle, prompt_library, @@ -56,6 +65,8 @@ impl PromptManager { picker, prompt_editors: HashMap::default(), active_prompt_id: None, + last_new_prompt_id: None, + _subscriptions: subscriptions, }; manager.active_prompt_id = manager.prompt_library.first_prompt_id(); @@ -63,11 +74,105 @@ impl PromptManager { manager } + fn dispatch_context(&self, cx: &ViewContext) -> KeyContext { + let mut dispatch_context = KeyContext::new_with_defaults(); + dispatch_context.add("PromptManager"); + + let identifier = match self.active_editor() { + Some(active_editor) if active_editor.focus_handle(cx).is_focused(cx) => "editing", + _ => "not_editing", + }; + + dispatch_context.add(identifier); + dispatch_context + } + + pub fn new_prompt(&mut self, _: &NewPrompt, cx: &mut ViewContext) { + // TODO: Why doesn't this prevent making a new prompt if you + // move the picker selection/maybe unfocus the editor? + + // Prevent making a new prompt if the last new prompt is still empty + // + // Instead, we'll focus the last new prompt + if let Some(last_new_prompt_id) = self.last_new_prompt_id() { + if let Some(last_new_prompt) = self.prompt_library.prompt_by_id(last_new_prompt_id) { + let normalized_body = last_new_prompt + .body() + .trim() + .replace(['\r', '\n'], "") + .to_string(); + + if last_new_prompt.title() == PROMPT_DEFAULT_TITLE && normalized_body.is_empty() { + self.set_editor_for_prompt(last_new_prompt_id, cx); + self.focus_active_editor(cx); + } + } + } + + let prompt = self.prompt_library.new_prompt(); + self.set_last_new_prompt_id(Some(prompt.id().to_owned())); + + self.prompt_library.add_prompt(prompt.clone()); + + let id = *prompt.id(); + self.picker.update(cx, |picker, _cx| { + let prompts = self + .prompt_library + .sorted_prompts(SortOrder::Alphabetical) + .clone() + .into_iter(); + + picker.delegate.prompt_library = self.prompt_library.clone(); + picker.delegate.matching_prompts = prompts.clone().map(|(_, p)| Arc::new(p)).collect(); + picker.delegate.matching_prompt_ids = prompts.map(|(id, _)| id).collect(); + picker.delegate.selected_index = picker + .delegate + .matching_prompts + .iter() + .position(|p| p.id() == &id) + .unwrap_or(0); + }); + + self.active_prompt_id = Some(id); + + cx.notify(); + } + + pub fn save_prompt( + &mut self, + fs: Arc, + prompt_id: PromptId, + new_content: String, + cx: &mut ViewContext, + ) -> Result<()> { + let library = self.prompt_library.clone(); + if library.prompt_by_id(prompt_id).is_some() { + cx.spawn(|_, _| async move { + library + .save_prompt(prompt_id, Some(new_content), fs) + .log_err() + .await; + }) + .detach(); + cx.notify(); + } + + Ok(()) + } + pub fn set_active_prompt(&mut self, prompt_id: Option, cx: &mut ViewContext) { self.active_prompt_id = prompt_id; cx.notify(); } + pub fn last_new_prompt_id(&self) -> Option { + self.last_new_prompt_id + } + + pub fn set_last_new_prompt_id(&mut self, id: Option) { + self.last_new_prompt_id = id; + } + pub fn focus_active_editor(&self, cx: &mut ViewContext) { if let Some(active_prompt_id) = self.active_prompt_id { if let Some(editor) = self.prompt_editors.get(&active_prompt_id) { @@ -78,38 +183,9 @@ impl PromptManager { } } - fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { - cx.emit(DismissEvent); - } - - fn render_prompt_list(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let picker = self.picker.clone(); - - v_flex() - .id("prompt-list") - .bg(cx.theme().colors().surface_background) - .h_full() - .w_2_5() - .child( - h_flex() - .bg(cx.theme().colors().background) - .p(Spacing::Small.rems(cx)) - .border_b_1() - .border_color(cx.theme().colors().border) - .h(rems(1.75)) - .w_full() - .flex_none() - .justify_between() - .child(Label::new("Prompt Library").size(LabelSize::Small)) - .child(IconButton::new("new-prompt", IconName::Plus).disabled(true)), - ) - .child( - v_flex() - .h(rems(38.25)) - .flex_grow() - .justify_start() - .child(picker), - ) + pub fn active_editor(&self) -> Option<&View> { + self.active_prompt_id + .and_then(|active_prompt_id| self.prompt_editors.get(&active_prompt_id)) } fn set_editor_for_prompt( @@ -121,7 +197,7 @@ impl PromptManager { let editor_for_prompt = self.prompt_editors.entry(prompt_id).or_insert_with(|| { cx.new_view(|cx| { - let text = if let Some(prompt) = prompt_library.prompt(prompt_id) { + let text = if let Some(prompt) = prompt_library.prompt_by_id(prompt_id) { prompt.content().to_owned() } else { "".to_string() @@ -147,17 +223,76 @@ impl PromptManager { editor }) }); + editor_for_prompt.clone() } + + fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + cx.emit(DismissEvent); + } + + fn render_prompt_list(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let picker = self.picker.clone(); + + v_flex() + .id("prompt-list") + .bg(cx.theme().colors().surface_background) + .h_full() + .w_1_3() + .overflow_hidden() + .child( + h_flex() + .bg(cx.theme().colors().background) + .p(Spacing::Small.rems(cx)) + .border_b_1() + .border_color(cx.theme().colors().border) + .h(rems(1.75)) + .w_full() + .flex_none() + .justify_between() + .child(Label::new("Prompt Library").size(LabelSize::Small)) + .child( + IconButton::new("new-prompt", IconName::Plus) + .shape(IconButtonShape::Square) + .tooltip(move |cx| Tooltip::text("New Prompt", cx)) + .on_click(|_, cx| { + cx.dispatch_action(NewPrompt.boxed_clone()); + }), + ), + ) + .child( + v_flex() + .h(rems(38.25)) + .flex_grow() + .justify_start() + .child(picker), + ) + } } impl Render for PromptManager { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let active_prompt_id = self.active_prompt_id; + let active_prompt = if let Some(active_prompt_id) = active_prompt_id { + self.prompt_library.clone().prompt_by_id(active_prompt_id) + } else { + None + }; + let active_editor = self.active_editor().map(|editor| editor.clone()); + let updated_content = if let Some(editor) = active_editor { + Some(editor.read(cx).text(cx)) + } else { + None + }; + let can_save = active_prompt_id.is_some() && updated_content.is_some(); + let fs = self.fs.clone(); + h_flex() - .key_context("PromptManager") + .id("prompt-manager") + .key_context(self.dispatch_context(cx)) .track_focus(&self.focus_handle) .on_action(cx.listener(Self::dismiss)) - // .on_action(cx.listener(Self::save_active_prompt)) + .on_action(cx.listener(Self::new_prompt)) .elevation_3(cx) .size_full() .flex_none() @@ -166,7 +301,7 @@ impl Render for PromptManager { .overflow_hidden() .child(self.render_prompt_list(cx)) .child( - div().w_3_5().h_full().child( + div().w_2_3().h_full().child( v_flex() .id("prompt-editor") .border_l_1() @@ -176,6 +311,7 @@ impl Render for PromptManager { .flex_none() .min_w_64() .h_full() + .overflow_hidden() .child( h_flex() .bg(cx.theme().colors().background) @@ -185,16 +321,60 @@ impl Render for PromptManager { .h_7() .w_full() .justify_between() - .child(div()) + .child( + h_flex() + .gap(Spacing::XXLarge.rems(cx)) + .child(if can_save { + IconButton::new("save", IconName::Save) + .shape(IconButtonShape::Square) + .tooltip(move |cx| Tooltip::text("Save Prompt", cx)) + .on_click(cx.listener(move |this, _event, cx| { + if let Some(prompt_id) = active_prompt_id { + this.save_prompt( + fs.clone(), + prompt_id, + updated_content.clone().unwrap_or( + "TODO: make unreachable" + .to_string(), + ), + cx, + ) + .log_err(); + } + })) + } else { + IconButton::new("save", IconName::Save) + .shape(IconButtonShape::Square) + .disabled(true) + }) + .when_some(active_prompt, |this, active_prompt| { + let path = active_prompt.path(); + + this.child( + IconButton::new("reveal", IconName::Reveal) + .shape(IconButtonShape::Square) + .disabled(path.is_none()) + .tooltip(move |cx| { + Tooltip::text("Reveal in Finder", cx) + }) + .on_click(cx.listener(move |_, _event, cx| { + if let Some(path) = path.clone() { + cx.reveal_path(&path); + } + })), + ) + }), + ) .child( IconButton::new("dismiss", IconName::Close) .shape(IconButtonShape::Square) + .tooltip(move |cx| Tooltip::text("Close", cx)) .on_click(|_, cx| { cx.dispatch_action(menu::Cancel.boxed_clone()); }), ), ) - .when_some(self.active_prompt_id, |this, active_prompt_id| { + .when_some(active_prompt_id, |this, active_prompt_id| { this.child( h_flex() .flex_1() @@ -210,6 +390,8 @@ impl Render for PromptManager { } impl EventEmitter for PromptManager {} +impl EventEmitter for PromptManager {} + impl ModalView for PromptManager {} impl FocusableView for PromptManager { @@ -224,6 +406,7 @@ pub struct PromptManagerDelegate { matching_prompt_ids: Vec, prompt_library: Arc, selected_index: usize, + _subscriptions: Vec, } impl PickerDelegate for PromptManagerDelegate { @@ -313,15 +496,17 @@ impl PickerDelegate for PromptManagerDelegate { selected: bool, _cx: &mut ViewContext>, ) -> Option { - let matching_prompt = self.matching_prompts.get(ix)?; - let prompt = matching_prompt.clone(); + let prompt = self.matching_prompts.get(ix)?; + + let is_diry = self.prompt_library.is_dirty(prompt.id()); Some( ListItem::new(ix) .inset(true) .spacing(ListItemSpacing::Sparse) .selected(selected) - .child(Label::new(prompt.title())), + .child(Label::new(prompt.title())) + .end_slot(div().when(is_diry, |this| this.child(Indicator::dot()))), ) } } diff --git a/crates/assistant/src/slash_command/prompt_command.rs b/crates/assistant/src/slash_command/prompt_command.rs index 37b73013da637d8776da0d39a5d2e6befead3111..21ce0cd706352aa6968e6f24a259ddf1db4aa284 100644 --- a/crates/assistant/src/slash_command/prompt_command.rs +++ b/crates/assistant/src/slash_command/prompt_command.rs @@ -1,5 +1,5 @@ use super::{SlashCommand, SlashCommandCleanup, SlashCommandInvocation}; -use crate::prompts::prompt_library::PromptLibrary; +use crate::prompts::PromptLibrary; use anyhow::{anyhow, Context, Result}; use futures::channel::oneshot; use fuzzy::StringMatchCandidate; diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index f73b1d161336a6cc88cc017e0512688a8ea17baf..05dc40b868adecc54c1236ef43b257c9d5e29a62 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -164,6 +164,8 @@ pub enum IconName { ReplaceNext, ReplyArrowRight, Return, + Reveal, + Save, Screen, SelectAll, Server, @@ -277,10 +279,12 @@ impl IconName { IconName::Quote => "icons/quote.svg", IconName::Regex => "icons/regex.svg", IconName::Replace => "icons/replace.svg", + IconName::Reveal => "icons/reveal.svg", IconName::ReplaceAll => "icons/replace_all.svg", IconName::ReplaceNext => "icons/replace_next.svg", IconName::ReplyArrowRight => "icons/reply_arrow_right.svg", IconName::Return => "icons/return.svg", + IconName::Save => "icons/save.svg", IconName::Screen => "icons/desktop.svg", IconName::SelectAll => "icons/select_all.svg", IconName::Server => "icons/server.svg",