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 <me@as-cii.com>
This commit is contained in:
Nathan Sobo 2024-06-03 07:58:43 -06:00 committed by GitHub
parent 18e2b43d6d
commit 5f98b9617a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
25 changed files with 1427 additions and 1429 deletions

42
Cargo.lock generated
View File

@ -339,6 +339,7 @@ dependencies = [
"anthropic", "anthropic",
"anyhow", "anyhow",
"assistant_slash_command", "assistant_slash_command",
"async-watch",
"cargo_toml", "cargo_toml",
"chrono", "chrono",
"client", "client",
@ -347,13 +348,12 @@ dependencies = [
"ctor", "ctor",
"editor", "editor",
"env_logger", "env_logger",
"feature_flags",
"file_icons", "file_icons",
"fs", "fs",
"futures 0.3.28", "futures 0.3.28",
"fuzzy", "fuzzy",
"gpui", "gpui",
"gray_matter", "heed",
"http 0.1.0", "http 0.1.0",
"indoc", "indoc",
"language", "language",
@ -824,6 +824,15 @@ dependencies = [
"tungstenite 0.16.0", "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]] [[package]]
name = "async_zip" name = "async_zip"
version = "0.0.17" version = "0.0.17"
@ -3393,7 +3402,7 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412" checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
dependencies = [ dependencies = [
"libloading 0.7.4", "libloading 0.8.0",
] ]
[[package]] [[package]]
@ -4788,18 +4797,6 @@ dependencies = [
"syn 1.0.109", "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]] [[package]]
name = "grid" name = "grid"
version = "0.13.0" version = "0.13.0"
@ -5954,12 +5951,6 @@ dependencies = [
"safemem", "safemem",
] ]
[[package]]
name = "linked-hash-map"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f"
[[package]] [[package]]
name = "linkify" name = "linkify"
version = "0.10.0" version = "0.10.0"
@ -13079,15 +13070,6 @@ dependencies = [
"toml 0.8.10", "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]] [[package]]
name = "yansi" name = "yansi"
version = "0.5.1" version = "0.5.1"

View File

@ -150,6 +150,7 @@ assets = { path = "crates/assets" }
assistant = { path = "crates/assistant" } assistant = { path = "crates/assistant" }
assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_slash_command = { path = "crates/assistant_slash_command" }
assistant_tooling = { path = "crates/assistant_tooling" } assistant_tooling = { path = "crates/assistant_tooling" }
async-watch = "0.3.1"
audio = { path = "crates/audio" } audio = { path = "crates/audio" }
auto_update = { path = "crates/auto_update" } auto_update = { path = "crates/auto_update" }
base64 = "0.13" base64 = "0.13"
@ -166,6 +167,7 @@ color = { path = "crates/color" }
command_palette = { path = "crates/command_palette" } command_palette = { path = "crates/command_palette" }
command_palette_hooks = { path = "crates/command_palette_hooks" } command_palette_hooks = { path = "crates/command_palette_hooks" }
copilot = { path = "crates/copilot" } copilot = { path = "crates/copilot" }
dashmap = "5.5.3"
db = { path = "crates/db" } db = { path = "crates/db" }
diagnostics = { path = "crates/diagnostics" } diagnostics = { path = "crates/diagnostics" }
editor = { path = "crates/editor" } editor = { path = "crates/editor" }

1
assets/icons/star.svg Normal file
View File

@ -0,0 +1 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6.97942 1.25171L6.9585 1.30199L5.58662 4.60039C5.54342 4.70426 5.44573 4.77523 5.3336 4.78422L1.7727 5.0697L1.71841 5.07405L1.38687 5.10063L1.08608 5.12475C0.820085 5.14607 0.712228 5.47802 0.914889 5.65162L1.14406 5.84793L1.39666 6.06431L1.43802 6.09974L4.15105 8.42374C4.23648 8.49692 4.2738 8.61176 4.24769 8.72118L3.41882 12.196L3.40618 12.249L3.32901 12.5725L3.25899 12.866C3.19708 13.1256 3.47945 13.3308 3.70718 13.1917L3.9647 13.0344L4.24854 12.861L4.29502 12.8326L7.34365 10.9705C7.43965 10.9119 7.5604 10.9119 7.6564 10.9705L10.705 12.8326L10.7515 12.861L11.0354 13.0344L11.2929 13.1917C11.5206 13.3308 11.803 13.1256 11.7411 12.866L11.671 12.5725L11.5939 12.249L11.5812 12.196L10.7524 8.72118C10.7263 8.61176 10.7636 8.49692 10.849 8.42374L13.562 6.09974L13.6034 6.06431L13.856 5.84793L14.0852 5.65162C14.2878 5.47802 14.18 5.14607 13.914 5.12475L13.6132 5.10063L13.2816 5.07405L13.2274 5.0697L9.66645 4.78422C9.55432 4.77523 9.45663 4.70426 9.41343 4.60039L8.04155 1.30199L8.02064 1.25171L7.89291 0.944609L7.77702 0.665992C7.67454 0.419604 7.32551 0.419604 7.22303 0.665992L7.10715 0.944609L6.97942 1.25171ZM7.50003 2.60397L6.50994 4.98442C6.32273 5.43453 5.89944 5.74207 5.41351 5.78103L2.84361 5.98705L4.8016 7.66428C5.17183 7.98142 5.33351 8.47903 5.2204 8.95321L4.62221 11.461L6.8224 10.1171C7.23842 9.86302 7.76164 9.86302 8.17766 10.1171L10.3778 11.461L9.77965 8.95321C9.66654 8.47903 9.82822 7.98142 10.1984 7.66428L12.1564 5.98705L9.58654 5.78103C9.10061 5.74207 8.67732 5.43453 8.49011 4.98442L7.50003 2.60397Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M7.22303 0.665992C7.32551 0.419604 7.67454 0.419604 7.77702 0.665992L9.41343 4.60039C9.45663 4.70426 9.55432 4.77523 9.66645 4.78422L13.914 5.12475C14.18 5.14607 14.2878 5.47802 14.0852 5.65162L10.849 8.42374C10.7636 8.49692 10.7263 8.61176 10.7524 8.72118L11.7411 12.866C11.803 13.1256 11.5206 13.3308 11.2929 13.1917L7.6564 10.9705C7.5604 10.9119 7.43965 10.9119 7.34365 10.9705L3.70718 13.1917C3.47945 13.3308 3.19708 13.1256 3.25899 12.866L4.24769 8.72118C4.2738 8.61176 4.23648 8.49692 4.15105 8.42374L0.914889 5.65162C0.712228 5.47802 0.820086 5.14607 1.08608 5.12475L5.3336 4.78422C5.44573 4.77523 5.54342 4.70426 5.58662 4.60039L7.22303 0.665992Z" fill="currentColor"></path></svg>

After

Width:  |  Height:  |  Size: 794 B

View File

@ -216,6 +216,13 @@
"alt-enter": "editor::Newline" "alt-enter": "editor::Newline"
} }
}, },
{
"context": "PromptLibrary",
"bindings": {
"ctrl-n": "prompt_library::NewPrompt",
"ctrl-shift-s": "prompt_library::ToggleDefaultPrompt"
}
},
{ {
"context": "BufferSearchBar", "context": "BufferSearchBar",
"bindings": { "bindings": {

View File

@ -233,6 +233,14 @@
"alt-enter": "editor::Newline" "alt-enter": "editor::Newline"
} }
}, },
{
"context": "PromptLibrary",
"bindings": {
"cmd-n": "prompt_library::NewPrompt",
"cmd-shift-s": "prompt_library::ToggleDefaultPrompt",
"cmd-w": "workspace::CloseWindow"
}
},
{ {
"context": "BufferSearchBar", "context": "BufferSearchBar",
"bindings": { "bindings": {

View File

@ -16,18 +16,19 @@ doctest = false
anyhow.workspace = true anyhow.workspace = true
anthropic = { workspace = true, features = ["schemars"] } anthropic = { workspace = true, features = ["schemars"] }
assistant_slash_command.workspace = true assistant_slash_command.workspace = true
async-watch.workspace = true
cargo_toml.workspace = true cargo_toml.workspace = true
chrono.workspace = true chrono.workspace = true
client.workspace = true client.workspace = true
collections.workspace = true collections.workspace = true
command_palette_hooks.workspace = true command_palette_hooks.workspace = true
editor.workspace = true editor.workspace = true
feature_flags.workspace = true
file_icons.workspace = true file_icons.workspace = true
fs.workspace = true fs.workspace = true
futures.workspace = true futures.workspace = true
fuzzy.workspace = true fuzzy.workspace = true
gpui.workspace = true gpui.workspace = true
heed.workspace = true
http.workspace = true http.workspace = true
indoc.workspace = true indoc.workspace = true
language.workspace = true language.workspace = true
@ -59,7 +60,6 @@ util.workspace = true
uuid.workspace = true uuid.workspace = true
workspace.workspace = true workspace.workspace = true
picker.workspace = true picker.workspace = true
gray_matter = "0.2.7"
[dev-dependencies] [dev-dependencies]
ctor.workspace = true ctor.workspace = true

View File

@ -3,6 +3,7 @@ pub mod assistant_settings;
mod codegen; mod codegen;
mod completion_provider; mod completion_provider;
mod model_selector; mod model_selector;
mod prompt_library;
mod prompts; mod prompts;
mod saved_conversation; mod saved_conversation;
mod search; mod search;
@ -12,15 +13,21 @@ mod streaming_diff;
pub use assistant_panel::AssistantPanel; pub use assistant_panel::AssistantPanel;
use assistant_settings::{AnthropicModel, AssistantSettings, OpenAiModel, ZedDotDevModel}; use assistant_settings::{AnthropicModel, AssistantSettings, OpenAiModel, ZedDotDevModel};
use assistant_slash_command::SlashCommandRegistry;
use client::{proto, Client}; use client::{proto, Client};
use command_palette_hooks::CommandPaletteFilter; use command_palette_hooks::CommandPaletteFilter;
pub(crate) use completion_provider::*; pub(crate) use completion_provider::*;
use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal}; use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal};
pub(crate) use model_selector::*; pub(crate) use model_selector::*;
use prompt_library::PromptStore;
pub(crate) use saved_conversation::*; pub(crate) use saved_conversation::*;
use semantic_index::{CloudEmbeddingProvider, SemanticIndex}; use semantic_index::{CloudEmbeddingProvider, SemanticIndex};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use settings::{Settings, SettingsStore}; use settings::{Settings, SettingsStore};
use slash_command::{
active_command, file_command, project_command, prompt_command, rustdoc_command, search_command,
tabs_command,
};
use std::{ use std::{
fmt::{self, Display}, fmt::{self, Display},
sync::Arc, sync::Arc,
@ -251,8 +258,11 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
} }
}) })
.detach(); .detach();
prompt_library::init(cx);
completion_provider::init(client, cx); completion_provider::init(client, cx);
assistant_slash_command::init(cx); assistant_slash_command::init(cx);
register_slash_commands(cx);
assistant_panel::init(cx); assistant_panel::init(cx);
CommandPaletteFilter::update_global(cx, |filter, _cx| { CommandPaletteFilter::update_global(cx, |filter, _cx| {
@ -266,13 +276,32 @@ pub fn init(client: Arc<Client>, cx: &mut AppContext) {
cx.observe_global::<SettingsStore>(|cx| { cx.observe_global::<SettingsStore>(|cx| {
Assistant::update_global(cx, |assistant, cx| { Assistant::update_global(cx, |assistant, cx| {
let settings = AssistantSettings::get_global(cx); let settings = AssistantSettings::get_global(cx);
assistant.set_enabled(settings.enabled, cx); assistant.set_enabled(settings.enabled, cx);
}); });
}) })
.detach(); .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)] #[cfg(test)]
#[ctor::ctor] #[ctor::ctor]
fn init_logger() { fn init_logger() {

View File

@ -1,19 +1,18 @@
use crate::prompts::{generate_content_prompt, PromptLibrary, PromptManager};
use crate::slash_command::{rustdoc_command, search_command, tabs_command};
use crate::{ use crate::{
assistant_settings::{AssistantDockPosition, AssistantSettings}, assistant_settings::{AssistantDockPosition, AssistantSettings},
codegen::{self, Codegen, CodegenKind}, codegen::{self, Codegen, CodegenKind},
prompt_library::{open_prompt_library, PromptMetadata, PromptStore},
prompts::generate_content_prompt,
search::*, search::*,
slash_command::{ slash_command::{
active_command, file_command, project_command, prompt_command, prompt_command::PromptPlaceholder, SlashCommandCompletionProvider, SlashCommandLine,
SlashCommandCompletionProvider, SlashCommandLine, SlashCommandRegistry, SlashCommandRegistry,
}, },
ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist, ApplyEdit, Assist, CompletionProvider, ConfirmCommand, CycleMessageRole, InlineAssist,
LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus, LanguageModelRequest, LanguageModelRequestMessage, MessageId, MessageMetadata, MessageStatus,
QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata, SavedMessage, ModelSelector, QuoteSelection, ResetKey, Role, SavedConversation, SavedConversationMetadata,
Split, ToggleFocus, ToggleHistory, SavedMessage, Split, ToggleFocus, ToggleHistory, ToggleModelSelector,
}; };
use crate::{ModelSelector, ToggleModelSelector};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use assistant_slash_command::{SlashCommandOutput, SlashCommandOutputSection}; use assistant_slash_command::{SlashCommandOutput, SlashCommandOutputSection};
use client::telemetry::Telemetry; use client::telemetry::Telemetry;
@ -29,19 +28,17 @@ use editor::{
ToOffset as _, ToPoint, ToOffset as _, ToPoint,
}; };
use editor::{display_map::FlapId, FoldPlaceholder}; use editor::{display_map::FlapId, FoldPlaceholder};
use feature_flags::{FeatureFlag, FeatureFlagAppExt, FeatureFlagViewExt};
use file_icons::FileIcons; use file_icons::FileIcons;
use fs::Fs; use fs::Fs;
use futures::future::Shared; use futures::future::Shared;
use futures::{FutureExt, StreamExt}; use futures::{FutureExt, StreamExt};
use gpui::{ use gpui::{
canvas, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext, div, point, relative, rems, uniform_list, Action, AnyElement, AnyView, AppContext,
AsyncAppContext, AsyncWindowContext, AvailableSpace, ClipboardItem, Context, Empty, AsyncAppContext, AsyncWindowContext, ClipboardItem, Context, Empty, EventEmitter, FocusHandle,
EventEmitter, FocusHandle, FocusableView, FontStyle, FontWeight, HighlightStyle, FocusableView, FontStyle, FontWeight, HighlightStyle, InteractiveElement, IntoElement, Model,
InteractiveElement, IntoElement, Model, ModelContext, ParentElement, Pixels, Render, ModelContext, ParentElement, Pixels, Render, SharedString, StatefulInteractiveElement, Styled,
SharedString, StatefulInteractiveElement, Styled, Subscription, Task, TextStyle, Subscription, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext,
UniformListScrollHandle, View, ViewContext, VisualContext, WeakModel, WeakView, WhiteSpace, WeakModel, WeakView, WhiteSpace, WindowContext,
WindowContext,
}; };
use language::LspAdapterDelegate; use language::LspAdapterDelegate;
use language::{ use language::{
@ -111,7 +108,6 @@ pub struct AssistantPanel {
toolbar: View<Toolbar>, toolbar: View<Toolbar>,
languages: Arc<LanguageRegistry>, languages: Arc<LanguageRegistry>,
slash_commands: Arc<SlashCommandRegistry>, slash_commands: Arc<SlashCommandRegistry>,
prompt_library: Arc<PromptLibrary>,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
telemetry: Arc<Telemetry>, telemetry: Arc<Telemetry>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
@ -122,6 +118,14 @@ pub struct AssistantPanel {
_watch_saved_conversations: Task<Result<()>>, _watch_saved_conversations: Task<Result<()>>,
authentication_prompt: Option<AnyView>, authentication_prompt: Option<AnyView>,
model_menu_handle: PopoverMenuHandle<ContextMenu>, model_menu_handle: PopoverMenuHandle<ContextMenu>,
default_prompt: DefaultPrompt,
_watch_prompt_store: Task<()>,
}
#[derive(Default)]
struct DefaultPrompt {
text: String,
sections: Vec<SlashCommandOutputSection<usize>>,
} }
struct ActiveConversationEditor { struct ActiveConversationEditor {
@ -129,12 +133,6 @@ struct ActiveConversationEditor {
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
struct PromptLibraryFeatureFlag;
impl FeatureFlag for PromptLibraryFeatureFlag {
const NAME: &'static str = "prompt-library";
}
impl AssistantPanel { impl AssistantPanel {
const INLINE_PROMPT_HISTORY_MAX_LEN: usize = 20; const INLINE_PROMPT_HISTORY_MAX_LEN: usize = 20;
@ -148,21 +146,13 @@ impl AssistantPanel {
.await .await
.log_err() .log_err()
.unwrap_or_default(); .unwrap_or_default();
let prompt_store = cx.update(|cx| PromptStore::global(cx))?.await?;
let prompt_library = Arc::new( let default_prompts = prompt_store.load_default().await?;
PromptLibrary::load_index(fs.clone())
.await
.log_err()
.unwrap_or_default(),
);
// TODO: deserialize state. // TODO: deserialize state.
let workspace_handle = workspace.clone(); let workspace_handle = workspace.clone();
workspace.update(&mut cx, |workspace, cx| { workspace.update(&mut cx, |workspace, cx| {
cx.new_view::<Self>(|cx| { cx.new_view::<Self>(|cx| {
cx.observe_flag::<PromptLibraryFeatureFlag, _>(|_, _, cx| cx.notify())
.detach();
const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100); const CONVERSATION_WATCH_DURATION: Duration = Duration::from_millis(100);
let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move { let _watch_saved_conversations = cx.spawn(move |this, mut cx| async move {
let mut events = fs let mut events = fs
@ -183,6 +173,22 @@ impl AssistantPanel {
anyhow::Ok(()) 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 toolbar = cx.new_view(|cx| {
let mut toolbar = Toolbar::new(); let mut toolbar = Toolbar::new();
toolbar.set_can_navigate(false, cx); toolbar.set_can_navigate(false, cx);
@ -210,24 +216,7 @@ impl AssistantPanel {
}) })
.detach(); .detach();
let slash_command_registry = SlashCommandRegistry::global(cx); let mut this = Self {
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 {
workspace: workspace_handle, workspace: workspace_handle,
active_conversation_editor: None, active_conversation_editor: None,
show_saved_conversations: false, show_saved_conversations: false,
@ -237,8 +226,7 @@ impl AssistantPanel {
focus_handle, focus_handle,
toolbar, toolbar,
languages: workspace.app_state().languages.clone(), languages: workspace.app_state().languages.clone(),
slash_commands: slash_command_registry, slash_commands: SlashCommandRegistry::global(cx),
prompt_library,
fs: workspace.app_state().fs.clone(), fs: workspace.app_state().fs.clone(),
telemetry: workspace.client().telemetry().clone(), telemetry: workspace.client().telemetry().clone(),
width: None, width: None,
@ -251,7 +239,11 @@ impl AssistantPanel {
_watch_saved_conversations, _watch_saved_conversations,
authentication_prompt: None, authentication_prompt: None,
model_menu_handle: PopoverMenuHandle::default(), 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(); 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( fn completion_provider_changed(
&mut self, &mut self,
prev_settings_version: usize, prev_settings_version: usize,
@ -823,6 +864,7 @@ impl AssistantPanel {
let editor = cx.new_view(|cx| { let editor = cx.new_view(|cx| {
ConversationEditor::new( ConversationEditor::new(
&self.default_prompt,
self.languages.clone(), self.languages.clone(),
self.slash_commands.clone(), self.slash_commands.clone(),
self.fs.clone(), self.fs.clone(),
@ -1154,21 +1196,6 @@ impl AssistantPanel {
}) })
} }
fn show_prompt_manager(&mut self, cx: &mut ViewContext<Self>) {
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<Self>) -> bool { fn is_authenticated(&mut self, cx: &mut ViewContext<Self>) -> bool {
CompletionProvider::global(cx).is_authenticated() CompletionProvider::global(cx).is_authenticated()
} }
@ -1211,15 +1238,17 @@ impl AssistantPanel {
h_flex() h_flex()
.gap_1() .gap_1()
.child(self.render_inject_context_menu(cx)) .child(self.render_inject_context_menu(cx))
.children( .child(
cx.has_flag::<PromptLibraryFeatureFlag>().then_some( IconButton::new("show-prompt-library", IconName::Library)
IconButton::new("show_prompt_manager", IconName::Library) .icon_size(IconSize::Small)
.icon_size(IconSize::Small) .on_click({
.on_click(cx.listener(|this, _event, cx| { let language_registry = self.languages.clone();
this.show_prompt_manager(cx) cx.listener(move |_this, _event, cx| {
})) open_prompt_library(language_registry.clone(), cx)
.tooltip(|cx| Tooltip::text("Prompt Library…", 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 view = cx.view().clone();
let scroll_handle = self.saved_conversations_scroll_handle.clone(); let scroll_handle = self.saved_conversations_scroll_handle.clone();
let conversation_count = self.saved_conversations.len(); let conversation_count = self.saved_conversations.len();
canvas( uniform_list(
move |bounds, cx| { view,
let mut saved_conversations = uniform_list( "saved_conversations",
view, conversation_count,
"saved_conversations", |this, range, cx| {
conversation_count, range
|this, range, cx| { .map(|ix| this.render_saved_conversation(ix, cx))
range .collect()
.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
}, },
|_bounds, mut saved_conversations, cx| saved_conversations.paint(cx),
) )
.size_full() .size_full()
.track_scroll(scroll_handle)
.into_any_element() .into_any_element()
} else if let Some(editor) = self.active_conversation_editor() { } else if let Some(editor) = self.active_conversation_editor() {
let editor = editor.clone(); let editor = editor.clone();
@ -2581,6 +2598,7 @@ pub struct ConversationEditor {
impl ConversationEditor { impl ConversationEditor {
fn new( fn new(
default_prompt: &DefaultPrompt,
language_registry: Arc<LanguageRegistry>, language_registry: Arc<LanguageRegistry>,
slash_command_registry: Arc<SlashCommandRegistry>, slash_command_registry: Arc<SlashCommandRegistry>,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
@ -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( fn for_conversation(
@ -2949,61 +2994,68 @@ impl ConversationEditor {
}) })
} }
ConversationEvent::SlashCommandFinished { sections } => { ConversationEvent::SlashCommandFinished { sections } => {
self.editor.update(cx, |editor, cx| { self.insert_slash_command_output_sections(sections.iter().cloned(), 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 insert_slash_command_output_sections(
&mut self,
sections: impl IntoIterator<Item = SlashCommandOutputSection<language::Anchor>>,
cx: &mut ViewContext<Self>,
) {
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( fn handle_editor_event(
&mut self, &mut self,
_: View<Editor>, _: View<Editor>,
@ -3827,7 +3879,10 @@ fn make_lsp_adapter_delegate(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{FakeCompletionProvider, MessageId}; use crate::{
slash_command::{active_command, file_command},
FakeCompletionProvider, MessageId,
};
use fs::FakeFs; use fs::FakeFs;
use gpui::{AppContext, TestAppContext}; use gpui::{AppContext, TestAppContext};
use rope::Rope; use rope::Rope;
@ -4177,14 +4232,9 @@ mod tests {
) )
.await; .await;
let prompt_library = Arc::new(PromptLibrary::default());
let slash_command_registry = SlashCommandRegistry::new(); let slash_command_registry = SlashCommandRegistry::new();
slash_command_registry.register_command(file_command::FileSlashCommand, false); slash_command_registry.register_command(file_command::FileSlashCommand, false);
slash_command_registry.register_command( slash_command_registry.register_command(active_command::ActiveSlashCommand, false);
prompt_command::PromptSlashCommand::new(prompt_library.clone()),
false,
);
let registry = Arc::new(LanguageRegistry::test(cx.executor())); let registry = Arc::new(LanguageRegistry::test(cx.executor()));
let conversation = cx let conversation = cx

View File

@ -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<LanguageRegistry>,
cx: &mut AppContext,
) -> Task<Result<WindowHandle<PromptLibrary>>> {
let existing_window = cx
.windows()
.into_iter()
.find_map(|window| window.downcast::<PromptLibrary>());
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<PromptStore>,
language_registry: Arc<LanguageRegistry>,
prompt_editors: HashMap<PromptId, PromptEditor>,
active_prompt_id: Option<PromptId>,
picker: View<Picker<PromptPickerDelegate>>,
pending_load: Task<()>,
_subscriptions: Vec<Subscription>,
}
struct PromptEditor {
editor: View<Editor>,
next_body_to_save: Option<Rope>,
pending_save: Option<Task<Option<()>>>,
_subscription: Subscription,
}
struct PromptPickerDelegate {
store: Arc<PromptStore>,
selected_index: usize,
matches: Vec<PromptMetadata>,
}
enum PromptPickerEvent {
Confirmed { prompt_id: PromptId },
Deleted { prompt_id: PromptId },
ToggledDefault { prompt_id: PromptId },
}
impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
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<Picker<Self>>) {
self.selected_index = ix;
}
fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
"Search...".into()
}
fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> 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<Picker<Self>>) {
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<Picker<Self>>) {}
fn render_match(
&self,
ix: usize,
selected: bool,
cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> {
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<PromptStore>,
language_registry: Arc<LanguageRegistry>,
cx: &mut ViewContext<Self>,
) -> 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<Picker<PromptPickerDelegate>>,
event: &PromptPickerEvent,
cx: &mut ViewContext<Self>,
) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>) {
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<Self>,
) {
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<Self>) -> 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<PromptLibrary>) -> gpui::Stateful<Div> {
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<Self>) -> 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<SharedString>,
pub default: bool,
pub saved_at: DateTime<Utc>,
}
#[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<PromptId>, SerdeBincode<String>>,
metadata: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
metadata_cache: RwLock<MetadataCache>,
updates: (Arc<async_watch::Sender<()>>, async_watch::Receiver<()>),
}
#[derive(Default)]
struct MetadataCache {
metadata: Vec<PromptMetadata>,
metadata_by_id: HashMap<PromptId, PromptMetadata>,
}
impl MetadataCache {
fn from_db(
db: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
txn: &RoTxn,
) -> Result<Self> {
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<Output = Result<Arc<Self>>> {
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<Result<Self>> {
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<Result<String>> {
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<Result<Vec<(PromptMetadata, String)>>> {
let default_metadatas = self
.metadata_cache
.read()
.metadata
.iter()
.filter(|metadata| metadata.default)
.cloned()
.collect::<Vec<_>>();
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<Result<()>> {
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<PromptMetadata> {
self.metadata_cache.read().metadata_by_id.get(&id).cloned()
}
pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
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<Vec<PromptMetadata>> {
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::<Vec<_>>();
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<SharedString>,
default: bool,
body: Rope,
) -> Task<Result<()>> {
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<SharedString>,
default: bool,
) -> Task<Result<()>> {
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<PromptId> {
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<BoxFuture<'static, Result<Arc<PromptStore>, Arc<anyhow::Error>>>>,
);
impl Global for GlobalPromptStore {}
fn title_from_body(body: impl IntoIterator<Item = char>) -> Option<SharedString> {
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::<String>().trim().to_string();
if title.is_empty() {
None
} else {
Some(title.into())
}
} else {
None
}
}

View File

@ -1,7 +1,95 @@
mod prompt; use language::BufferSnapshot;
mod prompt_library; use std::{fmt::Write, ops::Range};
mod prompt_manager;
pub use prompt::*; pub fn generate_content_prompt(
pub use prompt_library::*; user_prompt: String,
pub use prompt_manager::*; language_name: Option<&str>,
buffer: BufferSnapshot,
range: Range<usize>,
project_name: Option<String>,
) -> anyhow::Result<String> {
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)
}

View File

@ -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<String>,
#[serde(default)]
dependencies: Vec<String>,
}
impl Default for StaticPromptFrontmatter {
fn default() -> Self {
Self {
title: PROMPT_DEFAULT_TITLE.to_string(),
version: "1.0".to_string(),
author: "You <you@email.com>".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::<Vec<String>>()
.join(", ");
writeln!(frontmatter, "languages: [{}]", languages).unwrap();
}
if !self.dependencies.is_empty() {
let dependencies = self
.dependencies
.iter()
.map(|d| standardize_value(d.clone()))
.collect::<Vec<String>>()
.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 <jane@kim.com
/// languages: ["*"]
/// dependencies: []
/// ---
///
/// Foo and bar are terms used in programming to describe generic concepts.
/// ```
///
/// ### Language-specific prompt
///
/// ```markdown
/// ---
/// title: UI with GPUI
/// version: 1.0
/// author: Nate Butler <iamnbutler@gmail.com>
/// 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<SharedString>,
}
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<String>) -> Self {
let matter = Matter::<YAML>::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::<YAML>::new();
let result = matter.parse(self.content.as_str());
result.content.clone()
}
pub fn path(&self) -> Option<PathBuf> {
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<dyn Fs>) -> 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<usize>,
project_name: Option<String>,
) -> anyhow::Result<String> {
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)
}

View File

@ -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<Self> {
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<PromptId>,
/// All [Prompt]s loaded into the library
prompts: HashMap<PromptId, StaticPrompt>,
/// Prompts that have been changed but haven't been
/// saved back to the file system
dirty_prompts: Vec<PromptId>,
version: usize,
}
pub struct PromptLibrary {
state: RwLock<PromptLibraryState>,
}
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<PromptId, StaticPrompt> {
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::<Vec<_>>();
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<StaticPrompt> {
let state = self.state.read();
state.prompts.get(&id).cloned()
}
pub fn first_prompt_id(&self) -> Option<PromptId> {
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<dyn Fs>) -> anyhow::Result<Self> {
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<dyn Fs>) -> 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::<YAML>::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<dyn Fs>) -> 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<String>,
fs: Arc<dyn Fs>,
) -> 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(())
}
}

View File

@ -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<PromptLibrary>,
language_registry: Arc<LanguageRegistry>,
#[allow(dead_code)]
fs: Arc<dyn Fs>,
picker: View<Picker<PromptManagerDelegate>>,
prompt_editors: HashMap<PromptId, View<Editor>>,
active_prompt_id: Option<PromptId>,
last_new_prompt_id: Option<PromptId>,
_subscriptions: Vec<Subscription>,
}
impl PromptManager {
pub fn new(
prompt_library: Arc<PromptLibrary>,
language_registry: Arc<LanguageRegistry>,
fs: Arc<dyn Fs>,
cx: &mut ViewContext<Self>,
) -> 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<Self>) -> 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<Self>) {
// 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<dyn Fs>,
prompt_id: PromptId,
new_content: String,
cx: &mut ViewContext<Self>,
) -> 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<PromptId>, cx: &mut ViewContext<Self>) {
self.active_prompt_id = prompt_id;
cx.notify();
}
pub fn last_new_prompt_id(&self) -> Option<PromptId> {
self.last_new_prompt_id
}
pub fn set_last_new_prompt_id(&mut self, id: Option<PromptId>) {
self.last_new_prompt_id = id;
}
pub fn focus_active_editor(&self, cx: &mut ViewContext<Self>) {
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<Editor>> {
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<Self>,
) -> 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<Self>) {
cx.emit(DismissEvent);
}
fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> 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<Self>) -> 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<DismissEvent> for PromptManager {}
impl EventEmitter<EditorEvent> 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<PromptManager>,
matching_prompts: Vec<Arc<StaticPrompt>>,
matching_prompt_ids: Vec<PromptId>,
prompt_library: Arc<PromptLibrary>,
selected_index: usize,
_subscriptions: Vec<Subscription>,
}
impl PickerDelegate for PromptManagerDelegate {
type ListItem = ListItem;
fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
"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<Picker<Self>>) {
self.selected_index = ix;
}
fn selected_index_changed(
&self,
ix: usize,
_cx: &mut ViewContext<Picker<Self>>,
) -> Option<Box<dyn Fn(&mut WindowContext) + 'static>> {
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<Picker<Self>>) -> 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::<Vec<_>>();
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<Picker<Self>>) {
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<Picker<Self>>) {
self.prompt_manager
.update(cx, |_, cx| {
cx.emit(DismissEvent);
})
.ok();
}
fn render_match(
&self,
ix: usize,
selected: bool,
_cx: &mut ViewContext<Picker<Self>>,
) -> Option<Self::ListItem> {
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()))),
)
}
}

View File

@ -1,8 +1,7 @@
use super::{SlashCommand, SlashCommandOutput}; use super::{SlashCommand, SlashCommandOutput};
use crate::prompts::PromptLibrary; use crate::prompt_library::PromptStore;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use assistant_slash_command::SlashCommandOutputSection; use assistant_slash_command::SlashCommandOutputSection;
use fuzzy::StringMatchCandidate;
use gpui::{AppContext, Task, WeakView}; use gpui::{AppContext, Task, WeakView};
use language::LspAdapterDelegate; use language::LspAdapterDelegate;
use std::sync::{atomic::AtomicBool, Arc}; use std::sync::{atomic::AtomicBool, Arc};
@ -10,12 +9,12 @@ use ui::{prelude::*, ButtonLike, ElevationIndex};
use workspace::Workspace; use workspace::Workspace;
pub(crate) struct PromptSlashCommand { pub(crate) struct PromptSlashCommand {
library: Arc<PromptLibrary>, store: Arc<PromptStore>,
} }
impl PromptSlashCommand { impl PromptSlashCommand {
pub fn new(library: Arc<PromptLibrary>) -> Self { pub fn new(store: Arc<PromptStore>) -> Self {
Self { library } Self { store }
} }
} }
@ -39,31 +38,16 @@ impl SlashCommand for PromptSlashCommand {
fn complete_argument( fn complete_argument(
&self, &self,
query: String, query: String,
cancellation_flag: Arc<AtomicBool>, _cancellation_flag: Arc<AtomicBool>,
_workspace: WeakView<Workspace>, _workspace: WeakView<Workspace>,
cx: &mut AppContext, cx: &mut AppContext,
) -> Task<Result<Vec<String>>> { ) -> Task<Result<Vec<String>>> {
let library = self.library.clone(); let store = self.store.clone();
let executor = cx.background_executor().clone();
cx.background_executor().spawn(async move { cx.background_executor().spawn(async move {
let candidates = library let prompts = store.search(query).await;
.prompts() Ok(prompts
.into_iter() .into_iter()
.enumerate() .filter_map(|prompt| Some(prompt.title?.to_string()))
.map(|(ix, prompt)| StringMatchCandidate::new(ix, prompt.1.title().to_string()))
.collect::<Vec<_>>();
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())
.collect()) .collect())
}) })
} }
@ -79,19 +63,16 @@ impl SlashCommand for PromptSlashCommand {
return Task::ready(Err(anyhow!("missing prompt name"))); 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 title = SharedString::from(title.to_string());
let prompt = cx.background_executor().spawn({ let prompt = cx.background_executor().spawn({
let title = title.clone(); let title = title.clone();
async move { async move {
let prompt = library let prompt_id = store
.prompts() .id_for_title(&title)
.into_iter() .with_context(|| format!("no prompt found with title {:?}", title))?;
.map(|prompt| (prompt.1.title(), prompt)) let body = store.load(prompt_id).await?;
.find(|(t, _)| t == &title) anyhow::Ok(body)
.with_context(|| format!("no prompt found with title {:?}", title))?
.1;
anyhow::Ok(prompt.1.body())
} }
}); });
cx.foreground_executor().spawn(async move { cx.foreground_executor().spawn(async move {
@ -102,16 +83,34 @@ impl SlashCommand for PromptSlashCommand {
sections: vec![SlashCommandOutputSection { sections: vec![SlashCommandOutputSection {
range, range,
render_placeholder: Arc::new(move |id, unfold, _cx| { render_placeholder: Arc::new(move |id, unfold, _cx| {
ButtonLike::new(id) PromptPlaceholder {
.style(ButtonStyle::Filled) id,
.layer(ElevationIndex::ElevatedSurface) unfold,
.child(Icon::new(IconName::Library)) title: title.clone(),
.child(Label::new(title.clone())) }
.on_click(move |_, cx| unfold(cx)) .into_any_element()
.into_any_element()
}), }),
}], }],
}) })
}) })
} }
} }
#[derive(IntoElement)]
pub struct PromptPlaceholder {
pub title: SharedString,
pub id: ElementId,
pub unfold: Arc<dyn Fn(&mut WindowContext)>,
}
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))
}
}

View File

@ -68,10 +68,10 @@ use gpui::{
div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement, div, impl_actions, point, prelude::*, px, relative, size, uniform_list, Action, AnyElement,
AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem, AppContext, AsyncWindowContext, AvailableSpace, BackgroundExecutor, Bounds, ClipboardItem,
Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusableView, FontId, FontStyle, Context, DispatchPhase, ElementId, EventEmitter, FocusHandle, FocusableView, FontId, FontStyle,
FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, Model, MouseButton, PaintQuad, FontWeight, HighlightStyle, Hsla, InteractiveText, KeyContext, ListSizingBehavior, Model,
ParentElement, Pixels, Render, SharedString, Size, StrikethroughStyle, Styled, StyledText, MouseButton, PaintQuad, ParentElement, Pixels, Render, SharedString, Size, StrikethroughStyle,
Subscription, Task, TextStyle, UnderlineStyle, UniformListScrollHandle, View, ViewContext, Styled, StyledText, Subscription, Task, TextStyle, UnderlineStyle, UniformListScrollHandle,
ViewInputHandler, VisualContext, WeakView, WhiteSpace, WindowContext, View, ViewContext, ViewInputHandler, VisualContext, WeakView, WhiteSpace, WindowContext,
}; };
use highlight_matching_bracket::refresh_matching_bracket_highlights; use highlight_matching_bracket::refresh_matching_bracket_highlights;
use hover_popover::{hide_hover, HoverState}; use hover_popover::{hide_hover, HoverState};
@ -1113,7 +1113,8 @@ impl CompletionsMenu {
.occlude() .occlude()
.max_h(max_height) .max_h(max_height)
.track_scroll(self.scroll_handle.clone()) .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() Popover::new()
.child(list) .child(list)
@ -1460,6 +1461,7 @@ impl CodeActionsMenu {
}) })
.map(|(ix, _)| ix), .map(|(ix, _)| ix),
) )
.with_sizing_behavior(ListSizingBehavior::Infer)
.into_any_element(); .into_any_element();
let cursor_position = if let Some(row) = self.deployed_from_indicator { let cursor_position = if let Some(row) = self.deployed_from_indicator {

View File

@ -12,7 +12,7 @@ use editor::{Editor, EditorElement, EditorStyle};
use extension::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use extension::{ExtensionManifest, ExtensionOperation, ExtensionStore};
use fuzzy::{match_strings, StringMatchCandidate}; use fuzzy::{match_strings, StringMatchCandidate};
use gpui::{ 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, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle,
UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext,
}; };
@ -938,24 +938,10 @@ impl Render for ExtensionsPage {
let view = cx.view().clone(); let view = cx.view().clone();
let scroll_handle = self.list.clone(); let scroll_handle = self.list.clone();
this.child( this.child(
canvas( uniform_list(view, "entries", count, Self::render_extensions)
move |bounds, cx| { .flex_grow()
let mut list = uniform_list::<_, ExtensionCard, _>( .pb_4()
view, .track_scroll(scroll_handle),
"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(),
) )
})) }))
} }

View File

@ -80,7 +80,7 @@ pub struct ListScrollEvent {
} }
/// The sizing behavior to apply during layout. /// 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 { pub enum ListSizingBehavior {
/// The list should calculate its size based on the size of its items. /// The list should calculate its size based on the size of its items.
Infer, Infer,

View File

@ -6,8 +6,9 @@
use crate::{ use crate::{
point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementId, point, px, size, AnyElement, AvailableSpace, Bounds, ContentMask, Element, ElementId,
GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, LayoutId, Pixels, GlobalElementId, Hitbox, InteractiveElement, Interactivity, IntoElement, LayoutId,
Render, ScrollHandle, Size, StyleRefinement, Styled, View, ViewContext, WindowContext, ListSizingBehavior, Pixels, Render, ScrollHandle, Size, StyleRefinement, Styled, View,
ViewContext, WindowContext,
}; };
use smallvec::SmallVec; use smallvec::SmallVec;
use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; use std::{cell::RefCell, cmp, ops::Range, rc::Rc};
@ -55,6 +56,7 @@ where
..Default::default() ..Default::default()
}, },
scroll_handle: None, scroll_handle: None,
sizing_behavior: ListSizingBehavior::default(),
} }
} }
@ -66,6 +68,7 @@ pub struct UniformList {
Box<dyn for<'a> Fn(Range<usize>, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>, Box<dyn for<'a> Fn(Range<usize>, &'a mut WindowContext) -> SmallVec<[AnyElement; 64]>>,
interactivity: Interactivity, interactivity: Interactivity,
scroll_handle: Option<UniformListScrollHandle>, scroll_handle: Option<UniformListScrollHandle>,
sizing_behavior: ListSizingBehavior,
} }
/// Frame state used by the [UniformList]. /// Frame state used by the [UniformList].
@ -120,24 +123,35 @@ impl Element for UniformList {
let item_size = self.measure_item(None, cx); let item_size = self.measure_item(None, cx);
let layout_id = self let layout_id = self
.interactivity .interactivity
.request_layout(global_id, cx, |style, cx| { .request_layout(global_id, cx, |style, cx| match self.sizing_behavior {
cx.request_measured_layout(style, move |known_dimensions, available_space, _cx| { ListSizingBehavior::Infer => {
let desired_height = item_size.height * max_items; cx.with_text_style(style.text_style().cloned(), |cx| {
let width = known_dimensions cx.request_measured_layout(
.width style,
.unwrap_or(match available_space.width { move |known_dimensions, available_space, _cx| {
AvailableSpace::Definite(x) => x, let desired_height = item_size.height * max_items;
AvailableSpace::MinContent | AvailableSpace::MaxContent => { let width = known_dimensions.width.unwrap_or(match available_space
item_size.width .width
} {
}); AvailableSpace::Definite(x) => x,
AvailableSpace::MinContent | AvailableSpace::MaxContent => {
let height = match available_space.height { item_size.width
AvailableSpace::Definite(height) => desired_height.min(height), }
AvailableSpace::MinContent | AvailableSpace::MaxContent => desired_height, });
}; let height = match available_space.height {
size(width, 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 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<Pixels>, cx: &mut WindowContext) -> Size<Pixels> { fn measure_item(&self, list_width: Option<Pixels>, cx: &mut WindowContext) -> Size<Pixels> {
if self.item_count == 0 { if self.item_count == 0 {
return Size::default(); return Size::default();

View File

@ -1,9 +1,9 @@
use editor::{scroll::Autoscroll, Anchor, Editor, ExcerptId}; use editor::{scroll::Autoscroll, Anchor, Editor, ExcerptId};
use gpui::{ use gpui::{
actions, canvas, div, rems, uniform_list, AnyElement, AppContext, Div, EventEmitter, actions, div, rems, uniform_list, AnyElement, AppContext, Div, EventEmitter, FocusHandle,
FocusHandle, FocusableView, Hsla, InteractiveElement, IntoElement, Model, MouseButton, FocusableView, Hsla, InteractiveElement, IntoElement, Model, MouseButton, MouseDownEvent,
MouseDownEvent, MouseMoveEvent, ParentElement, Render, Styled, UniformListScrollHandle, View, MouseMoveEvent, ParentElement, Render, Styled, UniformListScrollHandle, View, ViewContext,
ViewContext, VisualContext, WeakView, WindowContext, VisualContext, WeakView, WindowContext,
}; };
use language::{Buffer, OwnedSyntaxLayer}; use language::{Buffer, OwnedSyntaxLayer};
use std::{mem, ops::Range}; use std::{mem, ops::Range};
@ -281,7 +281,7 @@ impl Render for SyntaxTreeView {
.and_then(|buffer| buffer.active_layer.as_ref()) .and_then(|buffer| buffer.active_layer.as_ref())
{ {
let layer = layer.clone(); let layer = layer.clone();
let mut list = uniform_list( rendered = rendered.child(uniform_list(
cx.view().clone(), cx.view().clone(),
"SyntaxTreeView", "SyntaxTreeView",
layer.node().descendant_count(), layer.node().descendant_count(),
@ -360,18 +360,7 @@ impl Render for SyntaxTreeView {
) )
.size_full() .size_full()
.track_scroll(self.list_scroll_handle.clone()) .track_scroll(self.list_scroll_handle.clone())
.text_bg(cx.theme().colors().background).into_any_element(); .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(),
);
} }
rendered rendered

View File

@ -83,7 +83,8 @@ impl OutlineView {
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> OutlineView { ) -> OutlineView {
let delegate = OutlineViewDelegate::new(cx.view().downgrade(), outline, editor, cx); 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 } OutlineView { picker }
} }
} }

View File

@ -2,8 +2,9 @@ use anyhow::Result;
use editor::{scroll::Autoscroll, Editor}; use editor::{scroll::Autoscroll, Editor};
use gpui::{ use gpui::{
actions, div, impl_actions, list, prelude::*, uniform_list, AnyElement, AppContext, ClickEvent, actions, div, impl_actions, list, prelude::*, uniform_list, AnyElement, AppContext, ClickEvent,
DismissEvent, EventEmitter, FocusHandle, FocusableView, Length, ListState, MouseButton, DismissEvent, EventEmitter, FocusHandle, FocusableView, Length, ListSizingBehavior, ListState,
MouseUpEvent, Render, Task, UniformListScrollHandle, View, ViewContext, WindowContext, MouseButton, MouseUpEvent, Render, Task, UniformListScrollHandle, View, ViewContext,
WindowContext,
}; };
use head::Head; use head::Head;
use serde::Deserialize; use serde::Deserialize;
@ -174,7 +175,7 @@ impl<D: PickerDelegate> Picker<D> {
pending_update_matches: None, pending_update_matches: None,
confirm_on_update: None, confirm_on_update: None,
width: None, width: None,
max_height: None, max_height: Some(rems(18.).into()),
is_modal: true, is_modal: true,
}; };
this.update_matches("".to_string(), cx); this.update_matches("".to_string(), cx);
@ -217,8 +218,8 @@ impl<D: PickerDelegate> Picker<D> {
self self
} }
pub fn max_height(mut self, max_height: impl Into<gpui::Length>) -> Self { pub fn max_height(mut self, max_height: Option<gpui::Length>) -> Self {
self.max_height = Some(max_height.into()); self.max_height = max_height;
self self
} }
@ -491,6 +492,11 @@ impl<D: PickerDelegate> Picker<D> {
} }
fn render_element_container(&self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render_element_container(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
let sizing_behavior = if self.max_height.is_some() {
ListSizingBehavior::Infer
} else {
ListSizingBehavior::Auto
};
match &self.element_container { match &self.element_container {
ElementContainer::UniformList(scroll_handle) => uniform_list( ElementContainer::UniformList(scroll_handle) => uniform_list(
cx.view().clone(), cx.view().clone(),
@ -502,11 +508,14 @@ impl<D: PickerDelegate> Picker<D> {
.collect() .collect()
}, },
) )
.with_sizing_behavior(sizing_behavior)
.flex_grow()
.py_2() .py_2()
.track_scroll(scroll_handle.clone()) .track_scroll(scroll_handle.clone())
.into_any_element(), .into_any_element(),
ElementContainer::List(state) => list(state.clone()) ElementContainer::List(state) => list(state.clone())
.with_sizing_behavior(gpui::ListSizingBehavior::Infer) .with_sizing_behavior(sizing_behavior)
.flex_grow()
.py_2() .py_2()
.into_any_element(), .into_any_element(),
} }
@ -518,7 +527,7 @@ impl<D: PickerDelegate> ModalView for Picker<D> {}
impl<D: PickerDelegate> Render for Picker<D> { impl<D: PickerDelegate> Render for Picker<D> {
fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement { fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
div() v_flex()
.key_context("Picker") .key_context("Picker")
.size_full() .size_full()
.when_some(self.width, |el, width| el.w(width)) .when_some(self.width, |el, width| el.w(width))
@ -554,7 +563,7 @@ impl<D: PickerDelegate> Render for Picker<D> {
el.child( el.child(
v_flex() v_flex()
.flex_grow() .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() .overflow_hidden()
.children(self.delegate.render_header(cx)) .children(self.delegate.render_header(cx))
.child(self.render_element_container(cx)), .child(self.render_element_container(cx)),

View File

@ -12,9 +12,10 @@ use git::repository::GitFileStatus;
use gpui::{ use gpui::{
actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement, actions, anchored, deferred, div, impl_actions, px, uniform_list, Action, AnyElement,
AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, EventEmitter, AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, EventEmitter,
FocusHandle, FocusableView, InteractiveElement, KeyContext, Model, MouseButton, MouseDownEvent, FocusHandle, FocusableView, InteractiveElement, KeyContext, ListSizingBehavior, Model,
ParentElement, Pixels, Point, PromptLevel, Render, Stateful, Styled, Subscription, Task, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Render, Stateful,
UniformListScrollHandle, View, ViewContext, VisualContext as _, WeakView, WindowContext, Styled, Subscription, Task, UniformListScrollHandle, View, ViewContext, VisualContext as _,
WeakView, WindowContext,
}; };
use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev};
use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId}; use project::{Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId};
@ -2217,6 +2218,7 @@ impl Render for ProjectPanel {
}, },
) )
.size_full() .size_full()
.with_sizing_behavior(ListSizingBehavior::Infer)
.track_scroll(self.scroll_handle.clone()), .track_scroll(self.scroll_handle.clone()),
) )
.children(self.context_menu.as_ref().map(|(menu, position, _)| { .children(self.context_menu.as_ref().map(|(menu, position, _)| {

View File

@ -177,6 +177,8 @@ pub enum IconName {
Space, Space,
Spinner, Spinner,
Split, Split,
Star,
StarFilled,
Strikethrough, Strikethrough,
Supermaven, Supermaven,
SupermavenDisabled, SupermavenDisabled,
@ -298,6 +300,8 @@ impl IconName {
IconName::Space => "icons/space.svg", IconName::Space => "icons/space.svg",
IconName::Spinner => "icons/spinner.svg", IconName::Spinner => "icons/spinner.svg",
IconName::Split => "icons/split.svg", IconName::Split => "icons/split.svg",
IconName::Star => "icons/star.svg",
IconName::StarFilled => "icons/star_filled.svg",
IconName::Strikethrough => "icons/strikethrough.svg", IconName::Strikethrough => "icons/strikethrough.svg",
IconName::Supermaven => "icons/supermaven.svg", IconName::Supermaven => "icons/supermaven.svg",
IconName::SupermavenDisabled => "icons/supermaven_disabled.svg", IconName::SupermavenDisabled => "icons/supermaven_disabled.svg",

View File

@ -77,7 +77,7 @@ impl RenderOnce for KeyBinding {
.join(" ") .join(" ")
) )
}) })
.gap(rems(0.125)) .gap(Spacing::Small.rems(cx))
.flex_none() .flex_none()
.children(self.key_binding.keystrokes().iter().map(|keystroke| { .children(self.key_binding.keystrokes().iter().map(|keystroke| {
let key_icon = Self::icon_for_key(keystroke); let key_icon = Self::icon_for_key(keystroke);