From 5f98b9617a30b5e8a85a0393cf021ec1db02e2e5 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 3 Jun 2024 07:58:43 -0600 Subject: [PATCH] Start on a database-backed prompt library (#12468) Using the file system as a database seems like it's easy, but it's actually a real pain. I'd like to use LMDB to store the prompts locally so we have more control. We can always add an export option, but I want the source of truth to be somewhere other than the file system. So far, I have a PromptStore which is global to the application and can be initialized on startup. Then there's a `PromptLibrary` which is intended to be the root of a new kind of Zed window. I haven't actually seen pixels yet, but I've sketched out the basics needed to create a new prompt, save, etc. Still lots to figure out but the foundations of being backed by a DB and rendering in an independent window are in place. /cc @iamnbutler @as-cii Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- Cargo.lock | 42 +- Cargo.toml | 2 + assets/icons/star.svg | 1 + assets/icons/star_filled.svg | 1 + assets/keymaps/default-linux.json | 7 + assets/keymaps/default-macos.json | 8 + crates/assistant/Cargo.toml | 4 +- crates/assistant/src/assistant.rs | 31 +- crates/assistant/src/assistant_panel.rs | 364 ++++--- crates/assistant/src/prompt_library.rs | 935 ++++++++++++++++++ crates/assistant/src/prompts.rs | 100 +- crates/assistant/src/prompts/prompt.rs | 360 ------- .../assistant/src/prompts/prompt_library.rs | 245 ----- .../assistant/src/prompts/prompt_manager.rs | 512 ---------- .../src/slash_command/prompt_command.rs | 81 +- crates/editor/src/editor.rs | 12 +- crates/extensions_ui/src/extensions_ui.rs | 24 +- crates/gpui/src/elements/list.rs | 2 +- crates/gpui/src/elements/uniform_list.rs | 60 +- crates/language_tools/src/syntax_tree_view.rs | 23 +- crates/outline/src/outline.rs | 3 +- crates/picker/src/picker.rs | 25 +- crates/project_panel/src/project_panel.rs | 8 +- crates/ui/src/components/icon.rs | 4 + crates/ui/src/components/keybinding.rs | 2 +- 25 files changed, 1427 insertions(+), 1429 deletions(-) create mode 100644 assets/icons/star.svg create mode 100644 assets/icons/star_filled.svg create mode 100644 crates/assistant/src/prompt_library.rs delete mode 100644 crates/assistant/src/prompts/prompt.rs delete mode 100644 crates/assistant/src/prompts/prompt_library.rs delete mode 100644 crates/assistant/src/prompts/prompt_manager.rs diff --git a/Cargo.lock b/Cargo.lock index 6567bf9f8a..e7ca312a20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,6 +339,7 @@ dependencies = [ "anthropic", "anyhow", "assistant_slash_command", + "async-watch", "cargo_toml", "chrono", "client", @@ -347,13 +348,12 @@ dependencies = [ "ctor", "editor", "env_logger", - "feature_flags", "file_icons", "fs", "futures 0.3.28", "fuzzy", "gpui", - "gray_matter", + "heed", "http 0.1.0", "indoc", "language", @@ -824,6 +824,15 @@ dependencies = [ "tungstenite 0.16.0", ] +[[package]] +name = "async-watch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a078faf4e27c0c6cc0efb20e5da59dcccc04968ebf2801d8e0b2195124cdcdb2" +dependencies = [ + "event-listener 2.5.3", +] + [[package]] name = "async_zip" version = "0.0.17" @@ -3393,7 +3402,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.0", ] [[package]] @@ -4788,18 +4797,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "gray_matter" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7188a951c53316d94711b3d944c28cf79968685d295cbe782494e8811fc75554" -dependencies = [ - "serde", - "serde_json", - "toml 0.5.11", - "yaml-rust", -] - [[package]] name = "grid" version = "0.13.0" @@ -5954,12 +5951,6 @@ dependencies = [ "safemem", ] -[[package]] -name = "linked-hash-map" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" - [[package]] name = "linkify" version = "0.10.0" @@ -13079,15 +13070,6 @@ dependencies = [ "toml 0.8.10", ] -[[package]] -name = "yaml-rust" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56c1936c4cc7a1c9ab21a1ebb602eb942ba868cbd44a99cb7cdc5892335e1c85" -dependencies = [ - "linked-hash-map", -] - [[package]] name = "yansi" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 4100ea2979..7c3cf3762b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -150,6 +150,7 @@ assets = { path = "crates/assets" } assistant = { path = "crates/assistant" } assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_tooling = { path = "crates/assistant_tooling" } +async-watch = "0.3.1" audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } base64 = "0.13" @@ -166,6 +167,7 @@ color = { path = "crates/color" } command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } copilot = { path = "crates/copilot" } +dashmap = "5.5.3" db = { path = "crates/db" } diagnostics = { path = "crates/diagnostics" } editor = { path = "crates/editor" } diff --git a/assets/icons/star.svg b/assets/icons/star.svg new file mode 100644 index 0000000000..71d4f3f7cc --- /dev/null +++ b/assets/icons/star.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/star_filled.svg b/assets/icons/star_filled.svg new file mode 100644 index 0000000000..4aaad4b7fd --- /dev/null +++ b/assets/icons/star_filled.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 26e8b109b7..bc5f14b00e 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -216,6 +216,13 @@ "alt-enter": "editor::Newline" } }, + { + "context": "PromptLibrary", + "bindings": { + "ctrl-n": "prompt_library::NewPrompt", + "ctrl-shift-s": "prompt_library::ToggleDefaultPrompt" + } + }, { "context": "BufferSearchBar", "bindings": { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index bee3b13c54..da035261ab 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -233,6 +233,14 @@ "alt-enter": "editor::Newline" } }, + { + "context": "PromptLibrary", + "bindings": { + "cmd-n": "prompt_library::NewPrompt", + "cmd-shift-s": "prompt_library::ToggleDefaultPrompt", + "cmd-w": "workspace::CloseWindow" + } + }, { "context": "BufferSearchBar", "bindings": { diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index f3d79ba467..57ca921802 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -16,18 +16,19 @@ doctest = false anyhow.workspace = true anthropic = { workspace = true, features = ["schemars"] } assistant_slash_command.workspace = true +async-watch.workspace = true cargo_toml.workspace = true chrono.workspace = true client.workspace = true collections.workspace = true command_palette_hooks.workspace = true editor.workspace = true -feature_flags.workspace = true file_icons.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true +heed.workspace = true http.workspace = true indoc.workspace = true language.workspace = true @@ -59,7 +60,6 @@ util.workspace = true uuid.workspace = true workspace.workspace = true picker.workspace = true -gray_matter = "0.2.7" [dev-dependencies] ctor.workspace = true diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index b6b49da9e1..421199114f 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -3,6 +3,7 @@ pub mod assistant_settings; mod codegen; mod completion_provider; mod model_selector; +mod prompt_library; mod prompts; mod saved_conversation; mod search; @@ -12,15 +13,21 @@ mod streaming_diff; pub use assistant_panel::AssistantPanel; use assistant_settings::{AnthropicModel, AssistantSettings, OpenAiModel, ZedDotDevModel}; +use assistant_slash_command::SlashCommandRegistry; use client::{proto, Client}; use command_palette_hooks::CommandPaletteFilter; pub(crate) use completion_provider::*; use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal}; pub(crate) use model_selector::*; +use prompt_library::PromptStore; pub(crate) use saved_conversation::*; use semantic_index::{CloudEmbeddingProvider, SemanticIndex}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; +use slash_command::{ + active_command, file_command, project_command, prompt_command, rustdoc_command, search_command, + tabs_command, +}; use std::{ fmt::{self, Display}, sync::Arc, @@ -251,8 +258,11 @@ pub fn init(client: Arc, cx: &mut AppContext) { } }) .detach(); + + prompt_library::init(cx); completion_provider::init(client, cx); assistant_slash_command::init(cx); + register_slash_commands(cx); assistant_panel::init(cx); CommandPaletteFilter::update_global(cx, |filter, _cx| { @@ -266,13 +276,32 @@ pub fn init(client: Arc, cx: &mut AppContext) { cx.observe_global::(|cx| { Assistant::update_global(cx, |assistant, cx| { let settings = AssistantSettings::get_global(cx); - assistant.set_enabled(settings.enabled, cx); }); }) .detach(); } +fn register_slash_commands(cx: &mut AppContext) { + let slash_command_registry = SlashCommandRegistry::global(cx); + slash_command_registry.register_command(file_command::FileSlashCommand, true); + slash_command_registry.register_command(active_command::ActiveSlashCommand, true); + slash_command_registry.register_command(tabs_command::TabsSlashCommand, true); + slash_command_registry.register_command(project_command::ProjectSlashCommand, true); + slash_command_registry.register_command(search_command::SearchSlashCommand, true); + slash_command_registry.register_command(rustdoc_command::RustdocSlashCommand, false); + + let store = PromptStore::global(cx); + cx.background_executor() + .spawn(async move { + let store = store.await?; + slash_command_registry + .register_command(prompt_command::PromptSlashCommand::new(store), true); + anyhow::Ok(()) + }) + .detach_and_log_err(cx); +} + #[cfg(test)] #[ctor::ctor] fn init_logger() { diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index ad28b725e6..e1bd2805da 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,19 +1,18 @@ -use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager}; -use crate::slash_command::{rustdoc_command, search_command, tabs_command}; use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings}, codegen::{self, Codegen, CodegenKind}, + prompt_library::{open_prompt_library, PromptMetadata, PromptStore}, + prompts::generate_content_prompt, search::*, slash_command::{ - active_command, file_command, project_command, prompt_command, - SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry, + prompt_command::PromptPlaceholder, SlashCommandCompletionProvider, SlashCommandLine, + SlashCommandRegistry, }, ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus, - QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, SavedMessage, - Split, ToggleFocus, ToggleHistory, + ModelSelector, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, + SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleModelSelector, }; -use crate::{ModelSelector, ToggleModelSelector}; use anyhow::{anyhow, Result}; use assistant_slash_command::{SlashCommandOutput, SlashCommandOutputSection}; use client::telemetry::Telemetry; @@ -29,19 +28,17 @@ use editor::{ ToOffset as _, ToPoint, }; use editor::{display_map::FlapId, FoldPlaceholder}; -use feature_flags::{FeatureFlag, FeatureFlagAppExt, FeatureFlagViewExt}; use file_icons::FileIcons; use fs::Fs; use futures::future::Shared; use futures::{FutureExt, StreamExt}; use gpui::{ - canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext, - AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Empty, - EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle, - InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Pixels, Render, - SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextStyle, - UniformListScrollHandle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, - WindowContext, + div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext, + AsyncAppContext, AsyncWindowContext, ClipboardItem, Context, Empty, EventEmitter, FocusHandle, + FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model, + ModelContext, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, Styled, + Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, + WeakModel, WeakView, WhiteSpace, WindowContext, }; use language::LspAdapterDelegate; use language::{ @@ -111,7 +108,6 @@ pub struct AssistantPanel { toolbar: View, languages: Arc, slash_commands: Arc, - prompt_library: Arc, fs: Arc, telemetry: Arc, _subscriptions: Vec, @@ -122,6 +118,14 @@ pub struct AssistantPanel { _watch_saved_conversations: Task>, authentication_prompt: Option, model_menu_handle: PopoverMenuHandle, + default_prompt: DefaultPrompt, + _watch_prompt_store: Task<()>, +} + +#[derive(Default)] +struct DefaultPrompt { + text: String, + sections: Vec>, } struct ActiveConversationEditor { @@ -129,12 +133,6 @@ struct ActiveConversationEditor { _subscriptions: Vec, } -struct PromptLibraryFeatureFlag; - -impl FeatureFlag for PromptLibraryFeatureFlag { - const NAME: &'static str = "prompt-library"; -} - impl AssistantPanel { const INLINE_PROMPT_HISTORY_MAX_LEN: usize = 20; @@ -148,21 +146,13 @@ impl AssistantPanel { .await .log_err() .unwrap_or_default(); - - let prompt_library = Arc::new( - PromptLibrary::load_index(fs.clone()) - .await - .log_err() - .unwrap_or_default(), - ); + let prompt_store = cx.update(|cx| PromptStore::global(cx))?.await?; + let default_prompts = prompt_store.load_default().await?; // TODO: deserialize state. let workspace_handle = workspace.clone(); workspace.update(&mut cx, |workspace, cx| { cx.new_view::(|cx| { - cx.observe_flag::(|_, _, cx| cx.notify()) - .detach(); - const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100); let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move { let mut events = fs @@ -183,6 +173,22 @@ impl AssistantPanel { anyhow::Ok(()) }); + let _watch_prompt_store = cx.spawn(|this, mut cx| async move { + let mut updates = prompt_store.updates(); + while updates.changed().await.is_ok() { + let Some(prompts) = prompt_store.load_default().await.log_err() else { + continue; + }; + + if this + .update(&mut cx, |this, _cx| this.update_default_prompt(prompts)) + .is_err() + { + break; + } + } + }); + let toolbar = cx.new_view(|cx| { let mut toolbar = Toolbar::new(); toolbar.set_can_navigate(false, cx); @@ -210,24 +216,7 @@ impl AssistantPanel { }) .detach(); - let slash_command_registry = SlashCommandRegistry::global(cx); - - slash_command_registry.register_command(file_command::FileSlashCommand, true); - slash_command_registry.register_command( - prompt_command::PromptSlashCommand::new(prompt_library.clone()), - true, - ); - slash_command_registry - .register_command(active_command::ActiveSlashCommand, true); - slash_command_registry.register_command(tabs_command::TabsSlashCommand, true); - slash_command_registry - .register_command(project_command::ProjectSlashCommand, true); - slash_command_registry - .register_command(search_command::SearchSlashCommand, true); - slash_command_registry - .register_command(rustdoc_command::RustdocSlashCommand, false); - - Self { + let mut this = Self { workspace: workspace_handle, active_conversation_editor: None, show_saved_conversations: false, @@ -237,8 +226,7 @@ impl AssistantPanel { focus_handle, toolbar, languages: workspace.app_state().languages.clone(), - slash_commands: slash_command_registry, - prompt_library, + slash_commands: SlashCommandRegistry::global(cx), fs: workspace.app_state().fs.clone(), telemetry: workspace.client().telemetry().clone(), width: None, @@ -251,7 +239,11 @@ impl AssistantPanel { _watch_saved_conversations, authentication_prompt: None, model_menu_handle: PopoverMenuHandle::default(), - } + default_prompt: DefaultPrompt::default(), + _watch_prompt_store, + }; + this.update_default_prompt(default_prompts); + this }) }) }) @@ -274,6 +266,55 @@ impl AssistantPanel { cx.notify(); } + fn update_default_prompt(&mut self, prompts: Vec<(PromptMetadata, String)>) { + self.default_prompt.text.clear(); + self.default_prompt.sections.clear(); + if !prompts.is_empty() { + self.default_prompt.text.push_str("Default Prompt:\n"); + } + + for (metadata, body) in prompts { + let section_start = self.default_prompt.text.len(); + self.default_prompt.text.push_str(&body); + let section_end = self.default_prompt.text.len(); + self.default_prompt + .sections + .push(SlashCommandOutputSection { + range: section_start..section_end, + render_placeholder: Arc::new(move |id, unfold, _cx| { + PromptPlaceholder { + title: metadata + .title + .clone() + .unwrap_or_else(|| SharedString::from("Untitled")), + id, + unfold, + } + .into_any_element() + }), + }); + self.default_prompt.text.push('\n'); + } + self.default_prompt.text.pop(); + + if !self.default_prompt.text.is_empty() { + self.default_prompt.sections.insert( + 0, + SlashCommandOutputSection { + range: 0..self.default_prompt.text.len(), + render_placeholder: Arc::new(move |id, unfold, _cx| { + PromptPlaceholder { + title: "Default".into(), + id, + unfold, + } + .into_any_element() + }), + }, + ) + } + } + fn completion_provider_changed( &mut self, prev_settings_version: usize, @@ -823,6 +864,7 @@ impl AssistantPanel { let editor = cx.new_view(|cx| { ConversationEditor::new( + &self.default_prompt, self.languages.clone(), self.slash_commands.clone(), self.fs.clone(), @@ -1154,21 +1196,6 @@ impl AssistantPanel { }) } - fn show_prompt_manager(&mut self, cx: &mut ViewContext) { - if let Some(workspace) = self.workspace.upgrade() { - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(cx, |cx| { - PromptManager::new( - self.prompt_library.clone(), - self.languages.clone(), - self.fs.clone(), - cx, - ) - }) - }) - } - } - fn is_authenticated(&mut self, cx: &mut ViewContext) -> bool { CompletionProvider::global(cx).is_authenticated() } @@ -1211,15 +1238,17 @@ impl AssistantPanel { h_flex() .gap_1() .child(self.render_inject_context_menu(cx)) - .children( - cx.has_flag::().then_some( - IconButton::new("show_prompt_manager", IconName::Library) - .icon_size(IconSize::Small) - .on_click(cx.listener(|this, _event, cx| { - this.show_prompt_manager(cx) - })) - .tooltip(|cx| Tooltip::text("Prompt Library…", cx)), - ), + .child( + IconButton::new("show-prompt-library", IconName::Library) + .icon_size(IconSize::Small) + .on_click({ + let language_registry = self.languages.clone(); + cx.listener(move |_this, _event, cx| { + open_prompt_library(language_registry.clone(), cx) + .detach_and_log_err(cx); + }) + }) + .tooltip(|cx| Tooltip::text("Prompt Library…", cx)), ), ), ); @@ -1261,30 +1290,18 @@ impl AssistantPanel { let view = cx.view().clone(); let scroll_handle = self.saved_conversations_scroll_handle.clone(); let conversation_count = self.saved_conversations.len(); - canvas( - move |bounds, cx| { - let mut saved_conversations = uniform_list( - view, - "saved_conversations", - conversation_count, - |this, range, cx| { - range - .map(|ix| this.render_saved_conversation(ix, cx)) - .collect() - }, - ) - .track_scroll(scroll_handle) - .into_any_element(); - saved_conversations.prepaint_as_root( - bounds.origin, - bounds.size.map(AvailableSpace::Definite), - cx, - ); - saved_conversations + uniform_list( + view, + "saved_conversations", + conversation_count, + |this, range, cx| { + range + .map(|ix| this.render_saved_conversation(ix, cx)) + .collect() }, - |_bounds, mut saved_conversations, cx| saved_conversations.paint(cx), ) .size_full() + .track_scroll(scroll_handle) .into_any_element() } else if let Some(editor) = self.active_conversation_editor() { let editor = editor.clone(); @@ -2581,6 +2598,7 @@ pub struct ConversationEditor { impl ConversationEditor { fn new( + default_prompt: &DefaultPrompt, language_registry: Arc, slash_command_registry: Arc, fs: Arc, @@ -2600,7 +2618,34 @@ impl ConversationEditor { ) }); - Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, cx) + let mut this = + Self::for_conversation(conversation, fs, workspace, lsp_adapter_delegate, cx); + + if !default_prompt.text.is_empty() { + this.editor + .update(cx, |editor, cx| editor.insert(&default_prompt.text, cx)); + let snapshot = this.conversation.read(cx).buffer.read(cx).text_snapshot(); + this.insert_slash_command_output_sections( + default_prompt + .sections + .iter() + .map(|section| SlashCommandOutputSection { + range: snapshot.anchor_after(section.range.start) + ..snapshot.anchor_before(section.range.end), + render_placeholder: section.render_placeholder.clone(), + }), + cx, + ); + this.split(&Split, cx); + this.conversation.update(cx, |this, _cx| { + this.messages_metadata + .get_mut(&MessageId::default()) + .unwrap() + .role = Role::System; + }); + } + + this } fn for_conversation( @@ -2949,61 +2994,68 @@ impl ConversationEditor { }) } ConversationEvent::SlashCommandFinished { sections } => { - self.editor.update(cx, |editor, cx| { - let buffer = editor.buffer().read(cx).snapshot(cx); - let excerpt_id = *buffer.as_singleton().unwrap().0; - let mut buffer_rows_to_fold = BTreeSet::new(); - let mut flaps = Vec::new(); - for section in sections { - let start = buffer - .anchor_in_excerpt(excerpt_id, section.range.start) - .unwrap(); - let end = buffer - .anchor_in_excerpt(excerpt_id, section.range.end) - .unwrap(); - let buffer_row = MultiBufferRow(start.to_point(&buffer).row); - buffer_rows_to_fold.insert(buffer_row); - flaps.push(Flap::new( - start..end, - FoldPlaceholder { - render: Arc::new({ - let editor = cx.view().downgrade(); - let render_placeholder = section.render_placeholder.clone(); - move |fold_id, fold_range, cx| { - let editor = editor.clone(); - let unfold = Arc::new(move |cx: &mut WindowContext| { - editor - .update(cx, |editor, cx| { - let buffer_start = fold_range.start.to_point( - &editor.buffer().read(cx).read(cx), - ); - let buffer_row = - MultiBufferRow(buffer_start.row); - editor.unfold_at(&UnfoldAt { buffer_row }, cx); - }) - .ok(); - }); - render_placeholder(fold_id.into(), unfold, cx) - } - }), - constrain_width: false, - merge_adjacent: false, - }, - render_slash_command_output_toggle, - |_, _, _| Empty.into_any_element(), - )); - } - - editor.insert_flaps(flaps, cx); - - for buffer_row in buffer_rows_to_fold.into_iter().rev() { - editor.fold_at(&FoldAt { buffer_row }, cx); - } - }); + self.insert_slash_command_output_sections(sections.iter().cloned(), cx); } } } + fn insert_slash_command_output_sections( + &mut self, + sections: impl IntoIterator>, + cx: &mut ViewContext, + ) { + self.editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx).snapshot(cx); + let excerpt_id = *buffer.as_singleton().unwrap().0; + let mut buffer_rows_to_fold = BTreeSet::new(); + let mut flaps = Vec::new(); + for section in sections { + let start = buffer + .anchor_in_excerpt(excerpt_id, section.range.start) + .unwrap(); + let end = buffer + .anchor_in_excerpt(excerpt_id, section.range.end) + .unwrap(); + let buffer_row = MultiBufferRow(start.to_point(&buffer).row); + buffer_rows_to_fold.insert(buffer_row); + flaps.push(Flap::new( + start..end, + FoldPlaceholder { + render: Arc::new({ + let editor = cx.view().downgrade(); + let render_placeholder = section.render_placeholder.clone(); + move |fold_id, fold_range, cx| { + let editor = editor.clone(); + let unfold = Arc::new(move |cx: &mut WindowContext| { + editor + .update(cx, |editor, cx| { + let buffer_start = fold_range + .start + .to_point(&editor.buffer().read(cx).read(cx)); + let buffer_row = MultiBufferRow(buffer_start.row); + editor.unfold_at(&UnfoldAt { buffer_row }, cx); + }) + .ok(); + }); + render_placeholder(fold_id.into(), unfold, cx) + } + }), + constrain_width: false, + merge_adjacent: false, + }, + render_slash_command_output_toggle, + |_, _, _| Empty.into_any_element(), + )); + } + + editor.insert_flaps(flaps, cx); + + for buffer_row in buffer_rows_to_fold.into_iter().rev() { + editor.fold_at(&FoldAt { buffer_row }, cx); + } + }); + } + fn handle_editor_event( &mut self, _: View, @@ -3827,7 +3879,10 @@ fn make_lsp_adapter_delegate( #[cfg(test)] mod tests { use super::*; - use crate::{FakeCompletionProvider, MessageId}; + use crate::{ + slash_command::{active_command, file_command}, + FakeCompletionProvider, MessageId, + }; use fs::FakeFs; use gpui::{AppContext, TestAppContext}; use rope::Rope; @@ -4177,14 +4232,9 @@ mod tests { ) .await; - let prompt_library = Arc::new(PromptLibrary::default()); let slash_command_registry = SlashCommandRegistry::new(); - slash_command_registry.register_command(file_command::FileSlashCommand, false); - slash_command_registry.register_command( - prompt_command::PromptSlashCommand::new(prompt_library.clone()), - false, - ); + slash_command_registry.register_command(active_command::ActiveSlashCommand, false); let registry = Arc::new(LanguageRegistry::test(cx.executor())); let conversation = cx diff --git a/crates/assistant/src/prompt_library.rs b/crates/assistant/src/prompt_library.rs new file mode 100644 index 0000000000..6426a80aa7 --- /dev/null +++ b/crates/assistant/src/prompt_library.rs @@ -0,0 +1,935 @@ +use anyhow::{anyhow, Result}; +use chrono::{DateTime, Utc}; +use collections::HashMap; +use editor::{Editor, EditorEvent}; +use futures::{ + future::{self, BoxFuture, Shared}, + FutureExt, +}; +use fuzzy::StringMatchCandidate; +use gpui::{ + actions, point, size, AppContext, BackgroundExecutor, Bounds, DevicePixels, Empty, + EventEmitter, Global, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions, View, + WindowBounds, WindowHandle, WindowOptions, +}; +use heed::{types::SerdeBincode, Database, RoTxn}; +use language::{language_settings::SoftWrap, Buffer, LanguageRegistry}; +use parking_lot::RwLock; +use picker::{Picker, PickerDelegate}; +use rope::Rope; +use serde::{Deserialize, Serialize}; +use std::{ + cmp::Reverse, + future::Future, + path::PathBuf, + sync::{atomic::AtomicBool, Arc}, + time::Duration, +}; +use ui::{ + div, prelude::*, IconButtonShape, ListItem, ListItemSpacing, ParentElement, Render, + SharedString, Styled, TitleBar, Tooltip, ViewContext, VisualContext, +}; +use util::{paths::PROMPTS_DIR, ResultExt, TryFutureExt}; +use uuid::Uuid; + +actions!( + prompt_library, + [NewPrompt, DeletePrompt, ToggleDefaultPrompt] +); + +/// Init starts loading the PromptStore in the background and assigns +/// a shared future to a global. +pub fn init(cx: &mut AppContext) { + let db_path = PROMPTS_DIR.join("prompts-library-db.0.mdb"); + let prompt_store_future = PromptStore::new(db_path, cx.background_executor().clone()) + .then(|result| future::ready(result.map(Arc::new).map_err(Arc::new))) + .boxed() + .shared(); + cx.set_global(GlobalPromptStore(prompt_store_future)) +} + +/// This function opens a new prompt library window if one doesn't exist already. +/// If one exists, it brings it to the foreground. +/// +/// Note that, when opening a new window, this waits for the PromptStore to be +/// initialized. If it was initialized successfully, it returns a window handle +/// to a prompt library. +pub fn open_prompt_library( + language_registry: Arc, + cx: &mut AppContext, +) -> Task>> { + let existing_window = cx + .windows() + .into_iter() + .find_map(|window| window.downcast::()); + if let Some(existing_window) = existing_window { + existing_window + .update(cx, |_, cx| cx.activate_window()) + .ok(); + Task::ready(Ok(existing_window)) + } else { + let store = PromptStore::global(cx); + cx.spawn(|cx| async move { + let store = store.await?; + cx.update(|cx| { + let bounds = Bounds::centered( + None, + size(DevicePixels::from(1024), DevicePixels::from(768)), + cx, + ); + cx.open_window( + WindowOptions { + titlebar: Some(TitlebarOptions { + title: None, + appears_transparent: true, + traffic_light_position: Some(point(px(9.0), px(9.0))), + }), + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |cx| cx.new_view(|cx| PromptLibrary::new(store, language_registry, cx)), + ) + }) + }) + } +} + +pub struct PromptLibrary { + store: Arc, + language_registry: Arc, + prompt_editors: HashMap, + active_prompt_id: Option, + picker: View>, + pending_load: Task<()>, + _subscriptions: Vec, +} + +struct PromptEditor { + editor: View, + next_body_to_save: Option, + pending_save: Option>>, + _subscription: Subscription, +} + +struct PromptPickerDelegate { + store: Arc, + selected_index: usize, + matches: Vec, +} + +enum PromptPickerEvent { + Confirmed { prompt_id: PromptId }, + Deleted { prompt_id: PromptId }, + ToggledDefault { prompt_id: PromptId }, +} + +impl EventEmitter for Picker {} + +impl PickerDelegate for PromptPickerDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext>) { + self.selected_index = ix; + } + + fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { + "Search...".into() + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { + let search = self.store.search(query); + cx.spawn(|this, mut cx| async move { + let matches = search.await; + this.update(&mut cx, |this, cx| { + this.delegate.selected_index = 0; + this.delegate.matches = matches; + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + if let Some(prompt) = self.matches.get(self.selected_index) { + cx.emit(PromptPickerEvent::Confirmed { + prompt_id: prompt.id, + }); + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Option { + let prompt = self.matches.get(ix)?; + let default = prompt.default; + let prompt_id = prompt.id; + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .selected(selected) + .child(Label::new( + prompt.title.clone().unwrap_or("Untitled".into()), + )) + .end_slot(if default { + IconButton::new("toggle-default-prompt", IconName::StarFilled) + .shape(IconButtonShape::Square) + .into_any_element() + } else { + Empty.into_any() + }) + .end_hover_slot( + h_flex() + .gap_2() + .child( + IconButton::new("delete-prompt", IconName::Trash) + .shape(IconButtonShape::Square) + .tooltip(move |cx| Tooltip::text("Delete Prompt", cx)) + .on_click(cx.listener(move |_, _, cx| { + cx.emit(PromptPickerEvent::Deleted { prompt_id }) + })), + ) + .child( + IconButton::new( + "toggle-default-prompt", + if default { + IconName::StarFilled + } else { + IconName::Star + }, + ) + .shape(IconButtonShape::Square) + .tooltip(move |cx| { + Tooltip::text( + if default { + "Remove from Default Prompt" + } else { + "Add to Default Prompt" + }, + cx, + ) + }) + .on_click(cx.listener(move |_, _, cx| { + cx.emit(PromptPickerEvent::ToggledDefault { prompt_id }) + })), + ), + ), + ) + } +} + +impl PromptLibrary { + fn new( + store: Arc, + language_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + let delegate = PromptPickerDelegate { + store: store.clone(), + selected_index: 0, + matches: Vec::new(), + }; + + let picker = cx.new_view(|cx| { + let picker = Picker::uniform_list(delegate, cx) + .modal(false) + .max_height(None); + picker.focus(cx); + picker + }); + let mut this = Self { + store: store.clone(), + language_registry, + prompt_editors: HashMap::default(), + active_prompt_id: None, + pending_load: Task::ready(()), + _subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)], + picker, + }; + if let Some(prompt_id) = store.most_recently_saved() { + this.load_prompt(prompt_id, false, cx); + } + this + } + + fn handle_picker_event( + &mut self, + _: View>, + event: &PromptPickerEvent, + cx: &mut ViewContext, + ) { + match event { + PromptPickerEvent::Confirmed { prompt_id } => { + self.load_prompt(*prompt_id, true, cx); + } + PromptPickerEvent::ToggledDefault { prompt_id } => { + self.toggle_default_for_prompt(*prompt_id, cx); + } + PromptPickerEvent::Deleted { prompt_id } => { + self.delete_prompt(*prompt_id, cx); + } + } + } + + pub fn new_prompt(&mut self, cx: &mut ViewContext) { + let prompt_id = PromptId::new(); + let save = self.store.save(prompt_id, None, false, "".into()); + self.picker.update(cx, |picker, cx| picker.refresh(cx)); + cx.spawn(|this, mut cx| async move { + save.await?; + this.update(&mut cx, |this, cx| this.load_prompt(prompt_id, true, cx)) + }) + .detach_and_log_err(cx); + } + + pub fn save_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { + const SAVE_THROTTLE: Duration = Duration::from_millis(500); + + let prompt_metadata = self.store.metadata(prompt_id).unwrap(); + let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap(); + let body = prompt_editor.editor.update(cx, |editor, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .as_rope() + .clone() + }); + + let store = self.store.clone(); + let executor = cx.background_executor().clone(); + + prompt_editor.next_body_to_save = Some(body); + if prompt_editor.pending_save.is_none() { + prompt_editor.pending_save = Some(cx.spawn(|this, mut cx| { + async move { + loop { + let next_body_to_save = this.update(&mut cx, |this, _| { + this.prompt_editors + .get_mut(&prompt_id)? + .next_body_to_save + .take() + })?; + + if let Some(body) = next_body_to_save { + let title = title_from_body(body.chars_at(0)); + store + .save(prompt_id, title, prompt_metadata.default, body) + .await + .log_err(); + this.update(&mut cx, |this, cx| { + this.picker.update(cx, |picker, cx| picker.refresh(cx)); + cx.notify(); + })?; + + executor.timer(SAVE_THROTTLE).await; + } else { + break; + } + } + + this.update(&mut cx, |this, _cx| { + if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) { + prompt_editor.pending_save = None; + } + }) + } + .log_err() + })); + } + } + + pub fn delete_active_prompt(&mut self, cx: &mut ViewContext) { + if let Some(active_prompt_id) = self.active_prompt_id { + self.delete_prompt(active_prompt_id, cx); + } + } + + pub fn toggle_default_for_active_prompt(&mut self, cx: &mut ViewContext) { + if let Some(active_prompt_id) = self.active_prompt_id { + self.toggle_default_for_prompt(active_prompt_id, cx); + } + } + + pub fn toggle_default_for_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { + if let Some(prompt_metadata) = self.store.metadata(prompt_id) { + self.store + .save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default) + .detach_and_log_err(cx); + self.picker.update(cx, |picker, cx| picker.refresh(cx)); + cx.notify(); + } + } + + pub fn load_prompt(&mut self, prompt_id: PromptId, focus: bool, cx: &mut ViewContext) { + if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) { + if focus { + prompt_editor + .editor + .update(cx, |editor, cx| editor.focus(cx)); + } + self.active_prompt_id = Some(prompt_id); + } else { + let language_registry = self.language_registry.clone(); + let prompt = self.store.load(prompt_id); + self.pending_load = cx.spawn(|this, mut cx| async move { + let prompt = prompt.await; + let markdown = language_registry.language_for_name("Markdown").await; + this.update(&mut cx, |this, cx| match prompt { + Ok(prompt) => { + let buffer = cx.new_model(|cx| { + let mut buffer = Buffer::local(prompt, cx); + buffer.set_language(markdown.log_err(), cx); + buffer.set_language_registry(language_registry); + buffer + }); + let editor = cx.new_view(|cx| { + let mut editor = Editor::for_buffer(buffer, None, cx); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + editor.set_show_gutter(false, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_indent_guides(false, cx); + if focus { + editor.focus(cx); + } + editor + }); + let _subscription = + cx.subscribe(&editor, move |this, _editor, event, cx| { + this.handle_prompt_editor_event(prompt_id, event, cx) + }); + this.prompt_editors.insert( + prompt_id, + PromptEditor { + editor, + next_body_to_save: None, + pending_save: None, + _subscription, + }, + ); + this.active_prompt_id = Some(prompt_id); + cx.notify(); + } + Err(error) => { + // TODO: we should show the error in the UI. + log::error!("error while loading prompt: {:?}", error); + } + }) + .ok(); + }); + } + } + + pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext) { + if let Some(metadata) = self.store.metadata(prompt_id) { + let confirmation = cx.prompt( + PromptLevel::Warning, + &format!( + "Are you sure you want to delete {}", + metadata.title.unwrap_or("Untitled".into()) + ), + None, + &["Delete", "Cancel"], + ); + + cx.spawn(|this, mut cx| async move { + if confirmation.await.ok() == Some(0) { + this.update(&mut cx, |this, cx| { + if this.active_prompt_id == Some(prompt_id) { + this.active_prompt_id = None; + } + this.prompt_editors.remove(&prompt_id); + this.store.delete(prompt_id).detach_and_log_err(cx); + this.picker.update(cx, |picker, cx| picker.refresh(cx)); + cx.notify(); + })?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } + + fn handle_prompt_editor_event( + &mut self, + prompt_id: PromptId, + event: &EditorEvent, + cx: &mut ViewContext, + ) { + if let EditorEvent::BufferEdited = event { + let prompt_editor = self.prompt_editors.get(&prompt_id).unwrap(); + let buffer = prompt_editor + .editor + .read(cx) + .buffer() + .read(cx) + .as_singleton() + .unwrap(); + + buffer.update(cx, |buffer, cx| { + let mut chars = buffer.chars_at(0); + match chars.next() { + Some('#') => { + if chars.next() != Some(' ') { + drop(chars); + buffer.edit([(1..1, " ")], None, cx); + } + } + Some(' ') => { + drop(chars); + buffer.edit([(0..0, "#")], None, cx); + } + _ => { + drop(chars); + buffer.edit([(0..0, "# ")], None, cx); + } + } + }); + + self.save_prompt(prompt_id, cx); + } + } + + fn render_prompt_list(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .id("prompt-list") + .bg(cx.theme().colors().panel_background) + .h_full() + .w_1_3() + .overflow_x_hidden() + .child( + h_flex() + .p(Spacing::Small.rems(cx)) + .border_b_1() + .border_color(cx.theme().colors().border) + .h(TitleBar::height(cx)) + .w_full() + .flex_none() + .justify_end() + .child( + IconButton::new("new-prompt", IconName::Plus) + .shape(IconButtonShape::Square) + .tooltip(move |cx| Tooltip::for_action("New Prompt", &NewPrompt, cx)) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(NewPrompt)); + }), + ), + ) + .child(div().flex_grow().child(self.picker.clone())) + } + + fn render_active_prompt(&mut self, cx: &mut ViewContext) -> gpui::Stateful
{ + div() + .w_2_3() + .h_full() + .id("prompt-editor") + .border_l_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_background) + .flex_none() + .min_w_64() + .children(self.active_prompt_id.and_then(|prompt_id| { + let prompt_metadata = self.store.metadata(prompt_id)?; + let editor = self.prompt_editors[&prompt_id].editor.clone(); + Some( + v_flex() + .size_full() + .child( + h_flex() + .h(TitleBar::height(cx)) + .px(Spacing::Large.rems(cx)) + .justify_end() + .child( + h_flex() + .gap_4() + .child( + IconButton::new( + "toggle-default-prompt", + if prompt_metadata.default { + IconName::StarFilled + } else { + IconName::Star + }, + ) + .shape(IconButtonShape::Square) + .tooltip(move |cx| { + Tooltip::for_action( + if prompt_metadata.default { + "Remove from Default Prompt" + } else { + "Add to Default Prompt" + }, + &ToggleDefaultPrompt, + cx, + ) + }) + .on_click( + |_, cx| { + cx.dispatch_action(Box::new( + ToggleDefaultPrompt, + )); + }, + ), + ) + .child( + IconButton::new("delete-prompt", IconName::Trash) + .shape(IconButtonShape::Square) + .tooltip(move |cx| { + Tooltip::for_action( + "Delete Prompt", + &DeletePrompt, + cx, + ) + }) + .on_click(|_, cx| { + cx.dispatch_action(Box::new(DeletePrompt)); + }), + ), + ), + ) + .child(div().flex_grow().p(Spacing::Large.rems(cx)).child(editor)), + ) + })) + } +} + +impl Render for PromptLibrary { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + h_flex() + .id("prompt-manager") + .key_context("PromptLibrary") + .on_action(cx.listener(|this, &NewPrompt, cx| this.new_prompt(cx))) + .on_action(cx.listener(|this, &DeletePrompt, cx| this.delete_active_prompt(cx))) + .on_action(cx.listener(|this, &ToggleDefaultPrompt, cx| { + this.toggle_default_for_active_prompt(cx) + })) + .size_full() + .overflow_hidden() + .child(self.render_prompt_list(cx)) + .child(self.render_active_prompt(cx)) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct PromptMetadata { + pub id: PromptId, + pub title: Option, + pub default: bool, + pub saved_at: DateTime, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct PromptId(Uuid); + +impl PromptId { + pub fn new() -> PromptId { + PromptId(Uuid::new_v4()) + } +} + +pub struct PromptStore { + executor: BackgroundExecutor, + env: heed::Env, + bodies: Database, SerdeBincode>, + metadata: Database, SerdeBincode>, + metadata_cache: RwLock, + updates: (Arc>, async_watch::Receiver<()>), +} + +#[derive(Default)] +struct MetadataCache { + metadata: Vec, + metadata_by_id: HashMap, +} + +impl MetadataCache { + fn from_db( + db: Database, SerdeBincode>, + txn: &RoTxn, + ) -> Result { + let mut cache = MetadataCache::default(); + for result in db.iter(txn)? { + let (prompt_id, metadata) = result?; + cache.metadata.push(metadata.clone()); + cache.metadata_by_id.insert(prompt_id, metadata); + } + cache + .metadata + .sort_unstable_by_key(|metadata| Reverse(metadata.saved_at)); + Ok(cache) + } + + fn insert(&mut self, metadata: PromptMetadata) { + self.metadata_by_id.insert(metadata.id, metadata.clone()); + if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) { + *old_metadata = metadata; + } else { + self.metadata.push(metadata); + } + self.metadata.sort_by_key(|m| Reverse(m.saved_at)); + } + + fn remove(&mut self, id: PromptId) { + self.metadata.retain(|metadata| metadata.id != id); + self.metadata_by_id.remove(&id); + } +} + +impl PromptStore { + pub fn global(cx: &AppContext) -> impl Future>> { + let store = GlobalPromptStore::global(cx).0.clone(); + async move { store.await.map_err(|err| anyhow!(err)) } + } + + pub fn new(db_path: PathBuf, executor: BackgroundExecutor) -> Task> { + executor.spawn({ + let executor = executor.clone(); + async move { + std::fs::create_dir_all(&db_path)?; + + let db_env = unsafe { + heed::EnvOpenOptions::new() + .map_size(1024 * 1024 * 1024) // 1GB + .max_dbs(2) // bodies and metadata + .open(db_path)? + }; + + let mut txn = db_env.write_txn()?; + let bodies = db_env.create_database(&mut txn, Some("bodies"))?; + let metadata = db_env.create_database(&mut txn, Some("metadata"))?; + let metadata_cache = MetadataCache::from_db(metadata, &txn)?; + txn.commit()?; + + let (updates_tx, updates_rx) = async_watch::channel(()); + Ok(PromptStore { + executor, + env: db_env, + bodies, + metadata, + metadata_cache: RwLock::new(metadata_cache), + updates: (Arc::new(updates_tx), updates_rx), + }) + } + }) + } + + pub fn updates(&self) -> async_watch::Receiver<()> { + self.updates.1.clone() + } + + pub fn load(&self, id: PromptId) -> Task> { + let env = self.env.clone(); + let bodies = self.bodies; + self.executor.spawn(async move { + let txn = env.read_txn()?; + bodies + .get(&txn, &id)? + .ok_or_else(|| anyhow!("prompt not found")) + }) + } + + pub fn load_default(&self) -> Task>> { + let default_metadatas = self + .metadata_cache + .read() + .metadata + .iter() + .filter(|metadata| metadata.default) + .cloned() + .collect::>(); + let env = self.env.clone(); + let bodies = self.bodies; + self.executor.spawn(async move { + let txn = env.read_txn()?; + + let mut default_prompts = Vec::new(); + for metadata in default_metadatas { + if let Some(body) = bodies.get(&txn, &metadata.id)? { + if !body.is_empty() { + default_prompts.push((metadata, body)); + } + } + } + + default_prompts.sort_unstable_by_key(|(metadata, _)| metadata.saved_at); + Ok(default_prompts) + }) + } + + pub fn delete(&self, id: PromptId) -> Task> { + self.metadata_cache.write().remove(id); + + let db_connection = self.env.clone(); + let bodies = self.bodies; + let metadata = self.metadata; + + self.executor.spawn(async move { + let mut txn = db_connection.write_txn()?; + + metadata.delete(&mut txn, &id)?; + bodies.delete(&mut txn, &id)?; + + txn.commit()?; + Ok(()) + }) + } + + fn metadata(&self, id: PromptId) -> Option { + self.metadata_cache.read().metadata_by_id.get(&id).cloned() + } + + pub fn id_for_title(&self, title: &str) -> Option { + let metadata_cache = self.metadata_cache.read(); + let metadata = metadata_cache + .metadata + .iter() + .find(|metadata| metadata.title.as_ref().map(|title| &***title) == Some(title))?; + Some(metadata.id) + } + + pub fn search(&self, query: String) -> Task> { + let cached_metadata = self.metadata_cache.read().metadata.clone(); + let executor = self.executor.clone(); + self.executor.spawn(async move { + if query.is_empty() { + cached_metadata + } else { + let candidates = cached_metadata + .iter() + .enumerate() + .filter_map(|(ix, metadata)| { + Some(StringMatchCandidate::new( + ix, + metadata.title.as_ref()?.to_string(), + )) + }) + .collect::>(); + let matches = fuzzy::match_strings( + &candidates, + &query, + false, + 100, + &AtomicBool::default(), + executor, + ) + .await; + matches + .into_iter() + .map(|mat| cached_metadata[mat.candidate_id].clone()) + .collect() + } + }) + } + + fn save( + &self, + id: PromptId, + title: Option, + default: bool, + body: Rope, + ) -> Task> { + let prompt_metadata = PromptMetadata { + id, + title, + default, + saved_at: Utc::now(), + }; + self.metadata_cache.write().insert(prompt_metadata.clone()); + + let db_connection = self.env.clone(); + let bodies = self.bodies; + let metadata = self.metadata; + let updates = self.updates.0.clone(); + + self.executor.spawn(async move { + let mut txn = db_connection.write_txn()?; + + metadata.put(&mut txn, &id, &prompt_metadata)?; + bodies.put(&mut txn, &id, &body.to_string())?; + + txn.commit()?; + updates.send(()).ok(); + + Ok(()) + }) + } + + fn save_metadata( + &self, + id: PromptId, + title: Option, + default: bool, + ) -> Task> { + let prompt_metadata = PromptMetadata { + id, + title, + default, + saved_at: Utc::now(), + }; + self.metadata_cache.write().insert(prompt_metadata.clone()); + + let db_connection = self.env.clone(); + let metadata = self.metadata; + let updates = self.updates.0.clone(); + + self.executor.spawn(async move { + let mut txn = db_connection.write_txn()?; + metadata.put(&mut txn, &id, &prompt_metadata)?; + txn.commit()?; + updates.send(()).ok(); + + Ok(()) + }) + } + + fn most_recently_saved(&self) -> Option { + self.metadata_cache + .read() + .metadata + .first() + .map(|metadata| metadata.id) + } +} + +/// Wraps a shared future to a prompt store so it can be assigned as a context global. +pub struct GlobalPromptStore( + Shared, Arc>>>, +); + +impl Global for GlobalPromptStore {} + +fn title_from_body(body: impl IntoIterator) -> Option { + let mut chars = body.into_iter().take_while(|c| *c != '\n').peekable(); + + let mut level = 0; + while let Some('#') = chars.peek() { + level += 1; + chars.next(); + } + + if level > 0 { + let title = chars.collect::().trim().to_string(); + if title.is_empty() { + None + } else { + Some(title.into()) + } + } else { + None + } +} diff --git a/crates/assistant/src/prompts.rs b/crates/assistant/src/prompts.rs index a124b99a1e..80dfc45c4f 100644 --- a/crates/assistant/src/prompts.rs +++ b/crates/assistant/src/prompts.rs @@ -1,7 +1,95 @@ -mod prompt; -mod prompt_library; -mod prompt_manager; +use language::BufferSnapshot; +use std::{fmt::Write, ops::Range}; -pub use prompt::*; -pub use prompt_library::*; -pub use prompt_manager::*; +pub fn generate_content_prompt( + user_prompt: String, + language_name: Option<&str>, + buffer: BufferSnapshot, + range: Range, + project_name: Option, +) -> anyhow::Result { + let mut prompt = String::new(); + + let content_type = match language_name { + None | Some("Markdown" | "Plain Text") => { + writeln!(prompt, "You are an expert engineer.")?; + "Text" + } + Some(language_name) => { + writeln!(prompt, "You are an expert {language_name} engineer.")?; + writeln!( + prompt, + "Your answer MUST always and only be valid {}.", + language_name + )?; + "Code" + } + }; + + if let Some(project_name) = project_name { + writeln!( + prompt, + "You are currently working inside the '{project_name}' project in code editor Zed." + )?; + } + + // Include file content. + for chunk in buffer.text_for_range(0..range.start) { + prompt.push_str(chunk); + } + + if range.is_empty() { + prompt.push_str("<|START|>"); + } else { + prompt.push_str("<|START|"); + } + + for chunk in buffer.text_for_range(range.clone()) { + prompt.push_str(chunk); + } + + if !range.is_empty() { + prompt.push_str("|END|>"); + } + + for chunk in buffer.text_for_range(range.end..buffer.len()) { + prompt.push_str(chunk); + } + + prompt.push('\n'); + + if range.is_empty() { + writeln!( + prompt, + "Assume the cursor is located where the `<|START|>` span is." + ) + .unwrap(); + writeln!( + prompt, + "{content_type} can't be replaced, so assume your answer will be inserted at the cursor.", + ) + .unwrap(); + writeln!( + prompt, + "Generate {content_type} based on the users prompt: {user_prompt}", + ) + .unwrap(); + } else { + writeln!(prompt, "Modify the user's selected {content_type} based upon the users prompt: '{user_prompt}'").unwrap(); + writeln!(prompt, "You must reply with only the adjusted {content_type} (within the '<|START|' and '|END|>' spans) not the entire file.").unwrap(); + writeln!( + prompt, + "Double check that you only return code and not the '<|START|' and '|END|'> spans" + ) + .unwrap(); + } + + writeln!(prompt, "Never make remarks about the output.").unwrap(); + writeln!( + prompt, + "Do not return anything else, except the generated {content_type}." + ) + .unwrap(); + + Ok(prompt) +} diff --git a/crates/assistant/src/prompts/prompt.rs b/crates/assistant/src/prompts/prompt.rs deleted file mode 100644 index d93e8140a3..0000000000 --- a/crates/assistant/src/prompts/prompt.rs +++ /dev/null @@ -1,360 +0,0 @@ -use fs::Fs; -use language::BufferSnapshot; -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, - version: String, - author: String, - #[serde(default)] - languages: Vec, - #[serde(default)] - dependencies: Vec, -} - -impl Default for StaticPromptFrontmatter { - fn default() -> Self { - Self { - title: PROMPT_DEFAULT_TITLE.to_string(), - version: "1.0".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 -/// -/// Examples: -/// -/// ### Globally available prompt -/// -/// ```markdown -/// --- -/// title: Foo -/// version: 1.0 -/// author: Jane Kim -/// languages: ["rust"] -/// dependencies: ["gpui"] -/// --- -/// -/// When building a UI with GPUI, ensure you... -/// ``` -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] -pub struct StaticPrompt { - #[serde(skip_deserializing)] - id: PromptId, - #[serde(skip)] - metadata: StaticPromptFrontmatter, - content: String, - 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 - .map_or_else( - || Err(anyhow::anyhow!("Failed to parse frontmatter")), - |data| { - let front_matter: StaticPromptFrontmatter = data.deserialize()?; - Ok(front_matter) - }, - ) - .unwrap_or_else(|e| { - if let Some(file_name) = &file_name { - log::error!("Failed to parse frontmatter for {}: {}", file_name, e); - } else { - log::error!("Failed to parse frontmatter: {}", e); - } - 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 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 - pub fn content(&self) -> &String { - &self.content - } - - /// Returns the prompt's metadata - pub fn _metadata(&self) -> &StaticPromptFrontmatter { - &self.metadata - } - - /// Returns the prompt's title - pub fn title(&self) -> SharedString { - self.metadata.title.clone().into() - } - - pub fn body(&self) -> String { - let matter = Matter::::new(); - 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( - user_prompt: String, - language_name: Option<&str>, - buffer: BufferSnapshot, - range: Range, - project_name: Option, -) -> anyhow::Result { - let mut prompt = String::new(); - - let content_type = match language_name { - None | Some("Markdown" | "Plain Text") => { - writeln!(prompt, "You are an expert engineer.")?; - "Text" - } - Some(language_name) => { - writeln!(prompt, "You are an expert {language_name} engineer.")?; - writeln!( - prompt, - "Your answer MUST always and only be valid {}.", - language_name - )?; - "Code" - } - }; - - if let Some(project_name) = project_name { - writeln!( - prompt, - "You are currently working inside the '{project_name}' project in code editor Zed." - )?; - } - - // Include file content. - for chunk in buffer.text_for_range(0..range.start) { - prompt.push_str(chunk); - } - - if range.is_empty() { - prompt.push_str("<|START|>"); - } else { - prompt.push_str("<|START|"); - } - - for chunk in buffer.text_for_range(range.clone()) { - prompt.push_str(chunk); - } - - if !range.is_empty() { - prompt.push_str("|END|>"); - } - - for chunk in buffer.text_for_range(range.end..buffer.len()) { - prompt.push_str(chunk); - } - - prompt.push('\n'); - - if range.is_empty() { - writeln!( - prompt, - "Assume the cursor is located where the `<|START|>` span is." - ) - .unwrap(); - writeln!( - prompt, - "{content_type} can't be replaced, so assume your answer will be inserted at the cursor.", - ) - .unwrap(); - writeln!( - prompt, - "Generate {content_type} based on the users prompt: {user_prompt}", - ) - .unwrap(); - } else { - writeln!(prompt, "Modify the user's selected {content_type} based upon the users prompt: '{user_prompt}'").unwrap(); - writeln!(prompt, "You must reply with only the adjusted {content_type} (within the '<|START|' and '|END|>' spans) not the entire file.").unwrap(); - writeln!( - prompt, - "Double check that you only return code and not the '<|START|' and '|END|'> spans" - ) - .unwrap(); - } - - writeln!(prompt, "Never make remarks about the output.").unwrap(); - writeln!( - prompt, - "Do not return anything else, except the generated {content_type}." - ) - .unwrap(); - - Ok(prompt) -} diff --git a/crates/assistant/src/prompts/prompt_library.rs b/crates/assistant/src/prompts/prompt_library.rs deleted file mode 100644 index 929dbd9716..0000000000 --- a/crates/assistant/src/prompts/prompt_library.rs +++ /dev/null @@ -1,245 +0,0 @@ -use anyhow::Context; -use collections::HashMap; -use fs::Fs; - -use gray_matter::{engine::YAML, Matter}; -use parking_lot::RwLock; -use serde::{Deserialize, Serialize}; -use smol::stream::StreamExt; -use std::sync::Arc; -use util::paths::PROMPTS_DIR; -use uuid::Uuid; - -use super::prompt::StaticPrompt; - -#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)] -pub struct PromptId(pub Uuid); - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -pub enum SortOrder { - Alphabetical, -} - -#[allow(unused)] -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)] -pub struct PromptLibraryState { - /// A set of prompts that all assistant contexts will start with - default_prompt: Vec, - /// All [Prompt]s loaded into the library - prompts: HashMap, - /// Prompts that have been changed but haven't been - /// saved back to the file system - dirty_prompts: Vec, - version: usize, -} - -pub struct PromptLibrary { - state: RwLock, -} - -impl Default for PromptLibrary { - fn default() -> Self { - Self::new() - } -} - -impl PromptLibrary { - fn new() -> Self { - Self { - state: RwLock::new(PromptLibraryState::default()), - } - } - - 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.clone() - } - - pub fn sorted_prompts(&self, sort_order: SortOrder) -> Vec<(PromptId, StaticPrompt)> { - let state = self.state.read(); - - let mut prompts = state - .prompts - .iter() - .map(|(id, prompt)| (*id, prompt.clone())) - .collect::>(); - - match sort_order { - SortOrder::Alphabetical => prompts.sort_by(|(_, a), (_, b)| a.title().cmp(&b.title())), - }; - - 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 is_dirty(&self, id: &PromptId) -> bool { - let state = self.state.read(); - state.dirty_prompts.contains(&id) - } - - 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_index(fs: Arc) -> anyhow::Result { - let path = PROMPTS_DIR.join("index.json"); - - let state = if fs.is_file(&path).await { - let json = fs.load(&path).await?; - serde_json::from_str(&json)? - } else { - PromptLibraryState::default() - }; - - let mut prompt_library = Self { - state: RwLock::new(state), - }; - - prompt_library.load_prompts(fs).await?; - - Ok(prompt_library) - } - - /// 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<()> { - self.state.get_mut().prompts.clear(); - - let mut prompt_paths = fs.read_dir(&PROMPTS_DIR).await?; - - while let Some(prompt_path) = prompt_paths.next().await { - let prompt_path = prompt_path.with_context(|| "Failed to read prompt path")?; - let file_name_lossy = if prompt_path.file_name().is_some() { - Some( - prompt_path - .file_name() - .unwrap() - .to_string_lossy() - .to_string(), - ) - } else { - None - }; - - if !fs.is_file(&prompt_path).await - || prompt_path.extension().and_then(|ext| ext.to_str()) != Some("md") - { - continue; - } - - let json = fs - .load(&prompt_path) - .await - .with_context(|| format!("Failed to load prompt {:?}", prompt_path))?; - - // Check that the prompt is valid - let matter = Matter::::new(); - let result = matter.parse(&json); - if result.data.is_none() { - log::warn!("Invalid prompt: {:?}", prompt_path); - continue; - } - - let static_prompt = StaticPrompt::new(json, file_name_lossy.clone()); - - let state = self.state.get_mut(); - - let id = Uuid::new_v4(); - state.prompts.insert(PromptId(id), static_prompt); - state.version += 1; - } - - // Write any changes back to the file system - 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 deleted file mode 100644 index ad3862a800..0000000000 --- a/crates/assistant/src/prompts/prompt_manager.rs +++ /dev/null @@ -1,512 +0,0 @@ -use collections::HashMap; -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, Indicator, ListItem, ListItemSpacing, Tooltip}; -use util::{ResultExt, TryFutureExt}; -use workspace::ModalView; - -use crate::prompts::{PromptId, PromptLibrary, SortOrder, StaticPrompt, PROMPT_DEFAULT_TITLE}; - -actions!(prompt_manager, [NewPrompt, SavePrompt]); - -pub struct PromptManager { - focus_handle: FocusHandle, - prompt_library: Arc, - language_registry: Arc, - #[allow(dead_code)] - fs: Arc, - picker: View>, - prompt_editors: HashMap>, - active_prompt_id: Option, - last_new_prompt_id: Option, - _subscriptions: Vec, -} - -impl PromptManager { - pub fn new( - prompt_library: Arc, - language_registry: Arc, - fs: Arc, - cx: &mut ViewContext, - ) -> Self { - let prompt_manager = cx.view().downgrade(); - let picker = cx.new_view(|cx| { - Picker::uniform_list( - PromptManagerDelegate { - prompt_manager, - matching_prompts: vec![], - matching_prompt_ids: vec![], - prompt_library: prompt_library.clone(), - selected_index: 0, - _subscriptions: vec![], - }, - cx, - ) - .max_height(rems(35.75)) - .modal(false) - }); - - 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, - language_registry, - fs, - 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(); - - 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) { - let focus_handle = editor.focus_handle(cx); - - cx.focus(&focus_handle) - } - } - } - - 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( - &mut self, - prompt_id: PromptId, - cx: &mut ViewContext, - ) -> impl IntoElement { - let prompt_library = self.prompt_library.clone(); - - 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_by_id(prompt_id) { - prompt.content().to_owned() - } else { - "".to_string() - }; - - let buffer = cx.new_model(|cx| { - let mut buffer = Buffer::local(text, cx); - let markdown = self.language_registry.language_for_name("Markdown"); - cx.spawn(|buffer, mut cx| async move { - if let Some(markdown) = markdown.await.log_err() { - _ = buffer.update(&mut cx, |buffer, cx| { - buffer.set_language(Some(markdown), cx); - }); - } - }) - .detach(); - buffer.set_language_registry(self.language_registry.clone()); - buffer - }); - let mut editor = Editor::for_buffer(buffer, None, cx); - editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); - editor.set_show_gutter(false, cx); - 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() - .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::new_prompt)) - .elevation_3(cx) - .size_full() - .flex_none() - .w(rems(64.)) - .h(rems(40.)) - .overflow_hidden() - .child(self.render_prompt_list(cx)) - .child( - div().w_2_3().h_full().child( - v_flex() - .id("prompt-editor") - .border_l_1() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().editor_background) - .size_full() - .flex_none() - .min_w_64() - .h_full() - .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_7() - .w_full() - .justify_between() - .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(active_prompt_id, |this, active_prompt_id| { - this.child( - h_flex() - .flex_1() - .w_full() - .py(Spacing::Large.rems(cx)) - .px(Spacing::XLarge.rems(cx)) - .child(self.set_editor_for_prompt(active_prompt_id, cx)), - ) - }), - ), - ) - } -} - -impl EventEmitter for PromptManager {} -impl EventEmitter for PromptManager {} - -impl ModalView for PromptManager {} - -impl FocusableView for PromptManager { - fn focus_handle(&self, _cx: &AppContext) -> gpui::FocusHandle { - self.focus_handle.clone() - } -} - -pub struct PromptManagerDelegate { - prompt_manager: WeakView, - matching_prompts: Vec>, - matching_prompt_ids: Vec, - prompt_library: Arc, - selected_index: usize, - _subscriptions: Vec, -} - -impl PickerDelegate for PromptManagerDelegate { - type ListItem = ListItem; - - fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { - "Find a prompt…".into() - } - - fn match_count(&self) -> usize { - self.matching_prompt_ids.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext>) { - self.selected_index = ix; - } - - fn selected_index_changed( - &self, - ix: usize, - _cx: &mut ViewContext>, - ) -> Option> { - let prompt_id = self.matching_prompt_ids.get(ix).copied()?; - let prompt_manager = self.prompt_manager.upgrade()?; - - Some(Box::new(move |cx| { - prompt_manager.update(cx, |manager, cx| { - manager.set_active_prompt(Some(prompt_id), cx); - }) - })) - } - - fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { - let prompt_library = self.prompt_library.clone(); - cx.spawn(|picker, mut cx| async move { - async { - let prompts = prompt_library.sorted_prompts(SortOrder::Alphabetical); - let matching_prompts = prompts - .into_iter() - .filter(|(_, prompt)| { - prompt - .content() - .to_lowercase() - .contains(&query.to_lowercase()) - }) - .collect::>(); - picker.update(&mut cx, |picker, cx| { - picker.delegate.matching_prompt_ids = - matching_prompts.iter().map(|(id, _)| *id).collect(); - picker.delegate.matching_prompts = matching_prompts - .into_iter() - .map(|(_, prompt)| Arc::new(prompt)) - .collect(); - cx.notify(); - })?; - anyhow::Ok(()) - } - .log_err() - .await; - }) - } - - fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { - let prompt_manager = self.prompt_manager.upgrade().unwrap(); - prompt_manager.update(cx, move |manager, cx| manager.focus_active_editor(cx)); - } - - fn should_dismiss(&self) -> bool { - false - } - - fn dismissed(&mut self, cx: &mut ViewContext>) { - self.prompt_manager - .update(cx, |_, cx| { - cx.emit(DismissEvent); - }) - .ok(); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _cx: &mut ViewContext>, - ) -> Option { - 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())) - .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 6ff3410208..6a1b8e74ad 100644 --- a/crates/assistant/src/slash_command/prompt_command.rs +++ b/crates/assistant/src/slash_command/prompt_command.rs @@ -1,8 +1,7 @@ use super::{SlashCommand, SlashCommandOutput}; -use crate::prompts::PromptLibrary; +use crate::prompt_library::PromptStore; use anyhow::{anyhow, Context, Result}; use assistant_slash_command::SlashCommandOutputSection; -use fuzzy::StringMatchCandidate; use gpui::{AppContext, Task, WeakView}; use language::LspAdapterDelegate; use std::sync::{atomic::AtomicBool, Arc}; @@ -10,12 +9,12 @@ use ui::{prelude::*, ButtonLike, ElevationIndex}; use workspace::Workspace; pub(crate) struct PromptSlashCommand { - library: Arc, + store: Arc, } impl PromptSlashCommand { - pub fn new(library: Arc) -> Self { - Self { library } + pub fn new(store: Arc) -> Self { + Self { store } } } @@ -39,31 +38,16 @@ impl SlashCommand for PromptSlashCommand { fn complete_argument( &self, query: String, - cancellation_flag: Arc, + _cancellation_flag: Arc, _workspace: WeakView, cx: &mut AppContext, ) -> Task>> { - let library = self.library.clone(); - let executor = cx.background_executor().clone(); + let store = self.store.clone(); cx.background_executor().spawn(async move { - let candidates = library - .prompts() + let prompts = store.search(query).await; + Ok(prompts .into_iter() - .enumerate() - .map(|(ix, prompt)| StringMatchCandidate::new(ix, prompt.1.title().to_string())) - .collect::>(); - let matches = fuzzy::match_strings( - &candidates, - &query, - false, - 100, - &cancellation_flag, - executor, - ) - .await; - Ok(matches - .into_iter() - .map(|mat| candidates[mat.candidate_id].string.clone()) + .filter_map(|prompt| Some(prompt.title?.to_string())) .collect()) }) } @@ -79,19 +63,16 @@ impl SlashCommand for PromptSlashCommand { return Task::ready(Err(anyhow!("missing prompt name"))); }; - let library = self.library.clone(); + let store = self.store.clone(); let title = SharedString::from(title.to_string()); let prompt = cx.background_executor().spawn({ let title = title.clone(); async move { - let prompt = library - .prompts() - .into_iter() - .map(|prompt| (prompt.1.title(), prompt)) - .find(|(t, _)| t == &title) - .with_context(|| format!("no prompt found with title {:?}", title))? - .1; - anyhow::Ok(prompt.1.body()) + let prompt_id = store + .id_for_title(&title) + .with_context(|| format!("no prompt found with title {:?}", title))?; + let body = store.load(prompt_id).await?; + anyhow::Ok(body) } }); cx.foreground_executor().spawn(async move { @@ -102,16 +83,34 @@ impl SlashCommand for PromptSlashCommand { sections: vec![SlashCommandOutputSection { range, render_placeholder: Arc::new(move |id, unfold, _cx| { - ButtonLike::new(id) - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ElevatedSurface) - .child(Icon::new(IconName::Library)) - .child(Label::new(title.clone())) - .on_click(move |_, cx| unfold(cx)) - .into_any_element() + PromptPlaceholder { + id, + unfold, + title: title.clone(), + } + .into_any_element() }), }], }) }) } } + +#[derive(IntoElement)] +pub struct PromptPlaceholder { + pub title: SharedString, + pub id: ElementId, + pub unfold: Arc, +} + +impl RenderOnce for PromptPlaceholder { + fn render(self, _cx: &mut WindowContext) -> impl IntoElement { + let unfold = self.unfold; + ButtonLike::new(self.id) + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ElevatedSurface) + .child(Icon::new(IconName::Library)) + .child(Label::new(self.title)) + .on_click(move |_, cx| unfold(cx)) + } +} diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 987b39a44c..23272ae1c8 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -68,10 +68,10 @@ use gpui::{ div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement, AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem, Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusableView, FontId, FontStyle, - FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, Model, MouseButton, PaintQuad, - ParentElement, Pixels, Render, SharedString, Size, StrikethroughStyle, Styled, StyledText, - Subscription, Task, TextStyle, UnderlineStyle, UniformListScrollHandle, View, ViewContext, - ViewInputHandler, VisualContext, WeakView, WhiteSpace, WindowContext, + FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, ListSizingBehavior, Model, + MouseButton, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, StrikethroughStyle, + Styled, StyledText, Subscription, Task, TextStyle, UnderlineStyle, UniformListScrollHandle, + View, ViewContext, ViewInputHandler, VisualContext, WeakView, WhiteSpace, WindowContext, }; use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; @@ -1113,7 +1113,8 @@ impl CompletionsMenu { .occlude() .max_h(max_height) .track_scroll(self.scroll_handle.clone()) - .with_width_from_item(widest_completion_ix); + .with_width_from_item(widest_completion_ix) + .with_sizing_behavior(ListSizingBehavior::Infer); Popover::new() .child(list) @@ -1460,6 +1461,7 @@ impl CodeActionsMenu { }) .map(|(ix, _)| ix), ) + .with_sizing_behavior(ListSizingBehavior::Infer) .into_any_element(); let cursor_position = if let Some(row) = self.deployed_from_indicator { diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 0ec675d46f..9ef43f8bd5 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -12,7 +12,7 @@ use editor::{Editor, EditorElement, EditorStyle}; use extension::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, canvas, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle, + actions, uniform_list, AnyElement, AppContext, EventEmitter, FocusableView, FontStyle, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext, }; @@ -938,24 +938,10 @@ impl Render for ExtensionsPage { let view = cx.view().clone(); let scroll_handle = self.list.clone(); this.child( - canvas( - move |bounds, cx| { - let mut list = uniform_list::<_, ExtensionCard, _>( - view, - "entries", - count, - Self::render_extensions, - ) - .size_full() - .pb_4() - .track_scroll(scroll_handle) - .into_any_element(); - list.prepaint_as_root(bounds.origin, bounds.size.into(), cx); - list - }, - |_bounds, mut list, cx| list.paint(cx), - ) - .size_full(), + uniform_list(view, "entries", count, Self::render_extensions) + .flex_grow() + .pb_4() + .track_scroll(scroll_handle), ) })) } diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index 0d4cade684..0036e21cc1 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -80,7 +80,7 @@ pub struct ListScrollEvent { } /// The sizing behavior to apply during layout. -#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum ListSizingBehavior { /// The list should calculate its size based on the size of its items. Infer, diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index d922e421b1..eafb6b2d72 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -6,8 +6,9 @@ use crate::{ point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementId, - GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, - Render, ScrollHandle, Size, StyleRefinement, Styled, View, ViewContext, WindowContext, + GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, LayoutId, + ListSizingBehavior, Pixels, Render, ScrollHandle, Size, StyleRefinement, Styled, View, + ViewContext, WindowContext, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; @@ -55,6 +56,7 @@ where ..Default::default() }, scroll_handle: None, + sizing_behavior: ListSizingBehavior::default(), } } @@ -66,6 +68,7 @@ pub struct UniformList { Box Fn(Range, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>, interactivity: Interactivity, scroll_handle: Option, + sizing_behavior: ListSizingBehavior, } /// Frame state used by the [UniformList]. @@ -120,24 +123,35 @@ impl Element for UniformList { let item_size = self.measure_item(None, cx); let layout_id = self .interactivity - .request_layout(global_id, cx, |style, cx| { - cx.request_measured_layout(style, move |known_dimensions, available_space, _cx| { - let desired_height = item_size.height * max_items; - let width = known_dimensions - .width - .unwrap_or(match available_space.width { - AvailableSpace::Definite(x) => x, - AvailableSpace::MinContent | AvailableSpace::MaxContent => { - item_size.width - } - }); - - let height = match available_space.height { - AvailableSpace::Definite(height) => desired_height.min(height), - AvailableSpace::MinContent | AvailableSpace::MaxContent => desired_height, - }; - size(width, height) - }) + .request_layout(global_id, cx, |style, cx| match self.sizing_behavior { + ListSizingBehavior::Infer => { + cx.with_text_style(style.text_style().cloned(), |cx| { + cx.request_measured_layout( + style, + move |known_dimensions, available_space, _cx| { + let desired_height = item_size.height * max_items; + let width = known_dimensions.width.unwrap_or(match available_space + .width + { + AvailableSpace::Definite(x) => x, + AvailableSpace::MinContent | AvailableSpace::MaxContent => { + item_size.width + } + }); + let height = match available_space.height { + AvailableSpace::Definite(height) => desired_height.min(height), + AvailableSpace::MinContent | AvailableSpace::MaxContent => { + desired_height + } + }; + size(width, height) + }, + ) + }) + } + ListSizingBehavior::Auto => cx.with_text_style(style.text_style().cloned(), |cx| { + cx.request_layout(style, None) + }), }); ( @@ -280,6 +294,12 @@ impl UniformList { self } + /// Sets the sizing behavior, similar to the `List` element. + pub fn with_sizing_behavior(mut self, behavior: ListSizingBehavior) -> Self { + self.sizing_behavior = behavior; + self + } + fn measure_item(&self, list_width: Option, cx: &mut WindowContext) -> Size { if self.item_count == 0 { return Size::default(); diff --git a/crates/language_tools/src/syntax_tree_view.rs b/crates/language_tools/src/syntax_tree_view.rs index aef7f258d2..4e29a1b7d2 100644 --- a/crates/language_tools/src/syntax_tree_view.rs +++ b/crates/language_tools/src/syntax_tree_view.rs @@ -1,9 +1,9 @@ use editor::{scroll::Autoscroll, Anchor, Editor, ExcerptId}; use gpui::{ - actions, canvas, div, rems, uniform_list, AnyElement, AppContext, Div, EventEmitter, - FocusHandle, FocusableView, Hsla, InteractiveElement, IntoElement, Model, MouseButton, - MouseDownEvent, MouseMoveEvent, ParentElement, Render, Styled, UniformListScrollHandle, View, - ViewContext, VisualContext, WeakView, WindowContext, + actions, div, rems, uniform_list, AnyElement, AppContext, Div, EventEmitter, FocusHandle, + FocusableView, Hsla, InteractiveElement, IntoElement, Model, MouseButton, MouseDownEvent, + MouseMoveEvent, ParentElement, Render, Styled, UniformListScrollHandle, View, ViewContext, + VisualContext, WeakView, WindowContext, }; use language::{Buffer, OwnedSyntaxLayer}; use std::{mem, ops::Range}; @@ -281,7 +281,7 @@ impl Render for SyntaxTreeView { .and_then(|buffer| buffer.active_layer.as_ref()) { let layer = layer.clone(); - let mut list = uniform_list( + rendered = rendered.child(uniform_list( cx.view().clone(), "SyntaxTreeView", layer.node().descendant_count(), @@ -360,18 +360,7 @@ impl Render for SyntaxTreeView { ) .size_full() .track_scroll(self.list_scroll_handle.clone()) - .text_bg(cx.theme().colors().background).into_any_element(); - - rendered = rendered.child( - canvas( - move |bounds, cx| { - list.prepaint_as_root(bounds.origin, bounds.size.into(), cx); - list - }, - |_, mut list, cx| list.paint(cx), - ) - .size_full(), - ); + .text_bg(cx.theme().colors().background).into_any_element()); } rendered diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index 8b2e374e3b..18d9446f4b 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -83,7 +83,8 @@ impl OutlineView { cx: &mut ViewContext, ) -> OutlineView { let delegate = OutlineViewDelegate::new(cx.view().downgrade(), outline, editor, cx); - let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx).max_height(vh(0.75, cx))); + let picker = + cx.new_view(|cx| Picker::uniform_list(delegate, cx).max_height(Some(vh(0.75, cx)))); OutlineView { picker } } } diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 72fc1e525a..994cdb74d6 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -2,8 +2,9 @@ use anyhow::Result; use editor::{scroll::Autoscroll, Editor}; use gpui::{ actions, div, impl_actions, list, prelude::*, uniform_list, AnyElement, AppContext, ClickEvent, - DismissEvent, EventEmitter, FocusHandle, FocusableView, Length, ListState, MouseButton, - MouseUpEvent, Render, Task, UniformListScrollHandle, View, ViewContext, WindowContext, + DismissEvent, EventEmitter, FocusHandle, FocusableView, Length, ListSizingBehavior, ListState, + MouseButton, MouseUpEvent, Render, Task, UniformListScrollHandle, View, ViewContext, + WindowContext, }; use head::Head; use serde::Deserialize; @@ -174,7 +175,7 @@ impl Picker { pending_update_matches: None, confirm_on_update: None, width: None, - max_height: None, + max_height: Some(rems(18.).into()), is_modal: true, }; this.update_matches("".to_string(), cx); @@ -217,8 +218,8 @@ impl Picker { self } - pub fn max_height(mut self, max_height: impl Into) -> Self { - self.max_height = Some(max_height.into()); + pub fn max_height(mut self, max_height: Option) -> Self { + self.max_height = max_height; self } @@ -491,6 +492,11 @@ impl Picker { } fn render_element_container(&self, cx: &mut ViewContext) -> impl IntoElement { + let sizing_behavior = if self.max_height.is_some() { + ListSizingBehavior::Infer + } else { + ListSizingBehavior::Auto + }; match &self.element_container { ElementContainer::UniformList(scroll_handle) => uniform_list( cx.view().clone(), @@ -502,11 +508,14 @@ impl Picker { .collect() }, ) + .with_sizing_behavior(sizing_behavior) + .flex_grow() .py_2() .track_scroll(scroll_handle.clone()) .into_any_element(), ElementContainer::List(state) => list(state.clone()) - .with_sizing_behavior(gpui::ListSizingBehavior::Infer) + .with_sizing_behavior(sizing_behavior) + .flex_grow() .py_2() .into_any_element(), } @@ -518,7 +527,7 @@ impl ModalView for Picker {} impl Render for Picker { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() + v_flex() .key_context("Picker") .size_full() .when_some(self.width, |el, width| el.w(width)) @@ -554,7 +563,7 @@ impl Render for Picker { el.child( v_flex() .flex_grow() - .max_h(self.max_height.unwrap_or(rems(18.).into())) + .when_some(self.max_height, |div, max_h| div.max_h(max_h)) .overflow_hidden() .children(self.delegate.render_header(cx)) .child(self.render_element_container(cx)), diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 5eafa215e1..e25fbb5d72 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -12,9 +12,10 @@ use git::repository::GitFileStatus; use gpui::{ actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement, AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, EventEmitter, - FocusHandle, FocusableView, InteractiveElement, KeyContext, Model, MouseButton, MouseDownEvent, - ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task, - UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext, + FocusHandle, FocusableView, InteractiveElement, KeyContext, ListSizingBehavior, Model, + MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful, + Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _, + WeakView, WindowContext, }; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}; use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId}; @@ -2217,6 +2218,7 @@ impl Render for ProjectPanel { }, ) .size_full() + .with_sizing_behavior(ListSizingBehavior::Infer) .track_scroll(self.scroll_handle.clone()), ) .children(self.context_menu.as_ref().map(|(menu, position, _)| { diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 3810e32b7b..571f28f7a0 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -177,6 +177,8 @@ pub enum IconName { Space, Spinner, Split, + Star, + StarFilled, Strikethrough, Supermaven, SupermavenDisabled, @@ -298,6 +300,8 @@ impl IconName { IconName::Space => "icons/space.svg", IconName::Spinner => "icons/spinner.svg", IconName::Split => "icons/split.svg", + IconName::Star => "icons/star.svg", + IconName::StarFilled => "icons/star_filled.svg", IconName::Strikethrough => "icons/strikethrough.svg", IconName::Supermaven => "icons/supermaven.svg", IconName::SupermavenDisabled => "icons/supermaven_disabled.svg", diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 8c8156337e..44f05ffecc 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -77,7 +77,7 @@ impl RenderOnce for KeyBinding { .join(" ") ) }) - .gap(rems(0.125)) + .gap(Spacing::Small.rems(cx)) .flex_none() .children(self.key_binding.keystrokes().iter().map(|keystroke| { let key_icon = Self::icon_for_key(keystroke);