1use crate::{slash_command::SlashCommandCompletionProvider, AssistantPanel, InlineAssistant};
2use anyhow::{anyhow, Result};
3use chrono::{DateTime, Utc};
4use collections::{HashMap, HashSet};
5use editor::{actions::Tab, CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle};
6use futures::{
7 future::{self, BoxFuture, Shared},
8 FutureExt,
9};
10use fuzzy::StringMatchCandidate;
11use gpui::{
12 actions, point, size, transparent_black, Action, AppContext, BackgroundExecutor, Bounds,
13 EventEmitter, Global, HighlightStyle, PromptLevel, ReadGlobal, Subscription, Task, TextStyle,
14 TitlebarOptions, UpdateGlobal, View, WindowBounds, WindowHandle, WindowOptions,
15};
16use heed::{
17 types::{SerdeBincode, SerdeJson, Str},
18 Database, RoTxn,
19};
20use language::{language_settings::SoftWrap, Buffer, LanguageRegistry};
21use language_model::{
22 LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
23};
24use parking_lot::RwLock;
25use picker::{Picker, PickerDelegate};
26use release_channel::ReleaseChannel;
27use rope::Rope;
28use serde::{Deserialize, Serialize};
29use settings::Settings;
30use std::{
31 cmp::Reverse,
32 future::Future,
33 path::PathBuf,
34 sync::{atomic::AtomicBool, Arc},
35 time::Duration,
36};
37use text::LineEnding;
38use theme::ThemeSettings;
39use ui::{
40 div, prelude::*, IconButtonShape, KeyBinding, ListItem, ListItemSpacing, ParentElement, Render,
41 SharedString, Styled, Tooltip, ViewContext, VisualContext,
42};
43use util::{ResultExt, TryFutureExt};
44use uuid::Uuid;
45use workspace::Workspace;
46use zed_actions::InlineAssist;
47
48actions!(
49 prompt_library,
50 [
51 NewPrompt,
52 DeletePrompt,
53 DuplicatePrompt,
54 ToggleDefaultPrompt
55 ]
56);
57
58/// Init starts loading the PromptStore in the background and assigns
59/// a shared future to a global.
60pub fn init(cx: &mut AppContext) {
61 let db_path = paths::prompts_dir().join("prompts-library-db.0.mdb");
62 let prompt_store_future = PromptStore::new(db_path, cx.background_executor().clone())
63 .then(|result| future::ready(result.map(Arc::new).map_err(Arc::new)))
64 .boxed()
65 .shared();
66 cx.set_global(GlobalPromptStore(prompt_store_future))
67}
68
69const BUILT_IN_TOOLTIP_TEXT: &'static str = concat!(
70 "This prompt supports special functionality.\n",
71 "It's read-only, but you can remove it from your default prompt."
72);
73
74/// This function opens a new prompt library window if one doesn't exist already.
75/// If one exists, it brings it to the foreground.
76///
77/// Note that, when opening a new window, this waits for the PromptStore to be
78/// initialized. If it was initialized successfully, it returns a window handle
79/// to a prompt library.
80pub fn open_prompt_library(
81 language_registry: Arc<LanguageRegistry>,
82 cx: &mut AppContext,
83) -> Task<Result<WindowHandle<PromptLibrary>>> {
84 let existing_window = cx
85 .windows()
86 .into_iter()
87 .find_map(|window| window.downcast::<PromptLibrary>());
88 if let Some(existing_window) = existing_window {
89 existing_window
90 .update(cx, |_, cx| cx.activate_window())
91 .ok();
92 Task::ready(Ok(existing_window))
93 } else {
94 let store = PromptStore::global(cx);
95 cx.spawn(|cx| async move {
96 let store = store.await?;
97 cx.update(|cx| {
98 let app_id = ReleaseChannel::global(cx).app_id();
99 let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
100 cx.open_window(
101 WindowOptions {
102 titlebar: Some(TitlebarOptions {
103 title: Some("Prompt Library".into()),
104 appears_transparent: cfg!(target_os = "macos"),
105 traffic_light_position: Some(point(px(9.0), px(9.0))),
106 }),
107 app_id: Some(app_id.to_owned()),
108 window_bounds: Some(WindowBounds::Windowed(bounds)),
109 ..Default::default()
110 },
111 |cx| cx.new_view(|cx| PromptLibrary::new(store, language_registry, cx)),
112 )
113 })?
114 })
115 }
116}
117
118pub struct PromptLibrary {
119 store: Arc<PromptStore>,
120 language_registry: Arc<LanguageRegistry>,
121 prompt_editors: HashMap<PromptId, PromptEditor>,
122 active_prompt_id: Option<PromptId>,
123 picker: View<Picker<PromptPickerDelegate>>,
124 pending_load: Task<()>,
125 _subscriptions: Vec<Subscription>,
126}
127
128struct PromptEditor {
129 title_editor: View<Editor>,
130 body_editor: View<Editor>,
131 token_count: Option<usize>,
132 pending_token_count: Task<Option<()>>,
133 next_title_and_body_to_save: Option<(String, Rope)>,
134 pending_save: Option<Task<Option<()>>>,
135 _subscriptions: Vec<Subscription>,
136}
137
138struct PromptPickerDelegate {
139 store: Arc<PromptStore>,
140 selected_index: usize,
141 matches: Vec<PromptMetadata>,
142}
143
144enum PromptPickerEvent {
145 Selected { prompt_id: PromptId },
146 Confirmed { prompt_id: PromptId },
147 Deleted { prompt_id: PromptId },
148 ToggledDefault { prompt_id: PromptId },
149}
150
151impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
152
153impl PickerDelegate for PromptPickerDelegate {
154 type ListItem = ListItem;
155
156 fn match_count(&self) -> usize {
157 self.matches.len()
158 }
159
160 fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString {
161 if self.store.prompt_count() == 0 {
162 "No prompts.".into()
163 } else {
164 "No prompts found matching your search.".into()
165 }
166 }
167
168 fn selected_index(&self) -> usize {
169 self.selected_index
170 }
171
172 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
173 self.selected_index = ix;
174 if let Some(prompt) = self.matches.get(self.selected_index) {
175 cx.emit(PromptPickerEvent::Selected {
176 prompt_id: prompt.id,
177 });
178 }
179 }
180
181 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
182 "Search...".into()
183 }
184
185 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
186 let search = self.store.search(query);
187 let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id);
188 cx.spawn(|this, mut cx| async move {
189 let (matches, selected_index) = cx
190 .background_executor()
191 .spawn(async move {
192 let matches = search.await;
193
194 let selected_index = prev_prompt_id
195 .and_then(|prev_prompt_id| {
196 matches.iter().position(|entry| entry.id == prev_prompt_id)
197 })
198 .unwrap_or(0);
199 (matches, selected_index)
200 })
201 .await;
202
203 this.update(&mut cx, |this, cx| {
204 this.delegate.matches = matches;
205 this.delegate.set_selected_index(selected_index, cx);
206 cx.notify();
207 })
208 .ok();
209 })
210 }
211
212 fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
213 if let Some(prompt) = self.matches.get(self.selected_index) {
214 cx.emit(PromptPickerEvent::Confirmed {
215 prompt_id: prompt.id,
216 });
217 }
218 }
219
220 fn dismissed(&mut self, _cx: &mut ViewContext<Picker<Self>>) {}
221
222 fn render_match(
223 &self,
224 ix: usize,
225 selected: bool,
226 cx: &mut ViewContext<Picker<Self>>,
227 ) -> Option<Self::ListItem> {
228 let prompt = self.matches.get(ix)?;
229 let default = prompt.default;
230 let prompt_id = prompt.id;
231 let element = ListItem::new(ix)
232 .inset(true)
233 .spacing(ListItemSpacing::Sparse)
234 .selected(selected)
235 .child(h_flex().h_5().line_height(relative(1.)).child(Label::new(
236 prompt.title.clone().unwrap_or("Untitled".into()),
237 )))
238 .end_slot::<IconButton>(default.then(|| {
239 IconButton::new("toggle-default-prompt", IconName::SparkleFilled)
240 .selected(true)
241 .icon_color(Color::Accent)
242 .shape(IconButtonShape::Square)
243 .tooltip(move |cx| Tooltip::text("Remove from Default Prompt", cx))
244 .on_click(cx.listener(move |_, _, cx| {
245 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
246 }))
247 }))
248 .end_hover_slot(
249 h_flex()
250 .gap_2()
251 .child(if prompt_id.is_built_in() {
252 div()
253 .id("built-in-prompt")
254 .child(Icon::new(IconName::FileLock).color(Color::Muted))
255 .tooltip(move |cx| {
256 Tooltip::with_meta(
257 "Built-in prompt",
258 None,
259 BUILT_IN_TOOLTIP_TEXT,
260 cx,
261 )
262 })
263 .into_any()
264 } else {
265 IconButton::new("delete-prompt", IconName::Trash)
266 .icon_color(Color::Muted)
267 .shape(IconButtonShape::Square)
268 .tooltip(move |cx| Tooltip::text("Delete Prompt", cx))
269 .on_click(cx.listener(move |_, _, cx| {
270 cx.emit(PromptPickerEvent::Deleted { prompt_id })
271 }))
272 .into_any_element()
273 })
274 .child(
275 IconButton::new("toggle-default-prompt", IconName::Sparkle)
276 .selected(default)
277 .selected_icon(IconName::SparkleFilled)
278 .icon_color(if default { Color::Accent } else { Color::Muted })
279 .shape(IconButtonShape::Square)
280 .tooltip(move |cx| {
281 Tooltip::text(
282 if default {
283 "Remove from Default Prompt"
284 } else {
285 "Add to Default Prompt"
286 },
287 cx,
288 )
289 })
290 .on_click(cx.listener(move |_, _, cx| {
291 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
292 })),
293 ),
294 );
295 Some(element)
296 }
297
298 fn render_editor(&self, editor: &View<Editor>, cx: &mut ViewContext<Picker<Self>>) -> Div {
299 h_flex()
300 .bg(cx.theme().colors().editor_background)
301 .rounded_md()
302 .overflow_hidden()
303 .flex_none()
304 .py_1()
305 .px_2()
306 .mx_1()
307 .child(editor.clone())
308 }
309}
310
311impl PromptLibrary {
312 fn new(
313 store: Arc<PromptStore>,
314 language_registry: Arc<LanguageRegistry>,
315 cx: &mut ViewContext<Self>,
316 ) -> Self {
317 let delegate = PromptPickerDelegate {
318 store: store.clone(),
319 selected_index: 0,
320 matches: Vec::new(),
321 };
322
323 let picker = cx.new_view(|cx| {
324 let picker = Picker::uniform_list(delegate, cx)
325 .modal(false)
326 .max_height(None);
327 picker.focus(cx);
328 picker
329 });
330 Self {
331 store: store.clone(),
332 language_registry,
333 prompt_editors: HashMap::default(),
334 active_prompt_id: None,
335 pending_load: Task::ready(()),
336 _subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)],
337 picker,
338 }
339 }
340
341 fn handle_picker_event(
342 &mut self,
343 _: View<Picker<PromptPickerDelegate>>,
344 event: &PromptPickerEvent,
345 cx: &mut ViewContext<Self>,
346 ) {
347 match event {
348 PromptPickerEvent::Selected { prompt_id } => {
349 self.load_prompt(*prompt_id, false, cx);
350 }
351 PromptPickerEvent::Confirmed { prompt_id } => {
352 self.load_prompt(*prompt_id, true, cx);
353 }
354 PromptPickerEvent::ToggledDefault { prompt_id } => {
355 self.toggle_default_for_prompt(*prompt_id, cx);
356 }
357 PromptPickerEvent::Deleted { prompt_id } => {
358 self.delete_prompt(*prompt_id, cx);
359 }
360 }
361 }
362
363 pub fn new_prompt(&mut self, cx: &mut ViewContext<Self>) {
364 // If we already have an untitled prompt, use that instead
365 // of creating a new one.
366 if let Some(metadata) = self.store.first() {
367 if metadata.title.is_none() {
368 self.load_prompt(metadata.id, true, cx);
369 return;
370 }
371 }
372
373 let prompt_id = PromptId::new();
374 let save = self.store.save(prompt_id, None, false, "".into());
375 self.picker.update(cx, |picker, cx| picker.refresh(cx));
376 cx.spawn(|this, mut cx| async move {
377 save.await?;
378 this.update(&mut cx, |this, cx| this.load_prompt(prompt_id, true, cx))
379 })
380 .detach_and_log_err(cx);
381 }
382
383 pub fn save_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
384 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
385
386 if prompt_id.is_built_in() {
387 return;
388 }
389
390 let prompt_metadata = self.store.metadata(prompt_id).unwrap();
391 let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
392 let title = prompt_editor.title_editor.read(cx).text(cx);
393 let body = prompt_editor.body_editor.update(cx, |editor, cx| {
394 editor
395 .buffer()
396 .read(cx)
397 .as_singleton()
398 .unwrap()
399 .read(cx)
400 .as_rope()
401 .clone()
402 });
403
404 let store = self.store.clone();
405 let executor = cx.background_executor().clone();
406
407 prompt_editor.next_title_and_body_to_save = Some((title, body));
408 if prompt_editor.pending_save.is_none() {
409 prompt_editor.pending_save = Some(cx.spawn(|this, mut cx| {
410 async move {
411 loop {
412 let title_and_body = this.update(&mut cx, |this, _| {
413 this.prompt_editors
414 .get_mut(&prompt_id)?
415 .next_title_and_body_to_save
416 .take()
417 })?;
418
419 if let Some((title, body)) = title_and_body {
420 let title = if title.trim().is_empty() {
421 None
422 } else {
423 Some(SharedString::from(title))
424 };
425 store
426 .save(prompt_id, title, prompt_metadata.default, body)
427 .await
428 .log_err();
429 this.update(&mut cx, |this, cx| {
430 this.picker.update(cx, |picker, cx| picker.refresh(cx));
431 cx.notify();
432 })?;
433
434 executor.timer(SAVE_THROTTLE).await;
435 } else {
436 break;
437 }
438 }
439
440 this.update(&mut cx, |this, _cx| {
441 if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) {
442 prompt_editor.pending_save = None;
443 }
444 })
445 }
446 .log_err()
447 }));
448 }
449 }
450
451 pub fn delete_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
452 if let Some(active_prompt_id) = self.active_prompt_id {
453 self.delete_prompt(active_prompt_id, cx);
454 }
455 }
456
457 pub fn duplicate_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
458 if let Some(active_prompt_id) = self.active_prompt_id {
459 self.duplicate_prompt(active_prompt_id, cx);
460 }
461 }
462
463 pub fn toggle_default_for_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
464 if let Some(active_prompt_id) = self.active_prompt_id {
465 self.toggle_default_for_prompt(active_prompt_id, cx);
466 }
467 }
468
469 pub fn toggle_default_for_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
470 if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
471 self.store
472 .save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default)
473 .detach_and_log_err(cx);
474 self.picker.update(cx, |picker, cx| picker.refresh(cx));
475 cx.notify();
476 }
477 }
478
479 pub fn load_prompt(&mut self, prompt_id: PromptId, focus: bool, cx: &mut ViewContext<Self>) {
480 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
481 if focus {
482 prompt_editor
483 .body_editor
484 .update(cx, |editor, cx| editor.focus(cx));
485 }
486 self.set_active_prompt(Some(prompt_id), cx);
487 } else if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
488 let language_registry = self.language_registry.clone();
489 let prompt = self.store.load(prompt_id);
490 self.pending_load = cx.spawn(|this, mut cx| async move {
491 let prompt = prompt.await;
492 let markdown = language_registry.language_for_name("Markdown").await;
493 this.update(&mut cx, |this, cx| match prompt {
494 Ok(prompt) => {
495 let title_editor = cx.new_view(|cx| {
496 let mut editor = Editor::auto_width(cx);
497 editor.set_placeholder_text("Untitled", cx);
498 editor.set_text(prompt_metadata.title.unwrap_or_default(), cx);
499 if prompt_id.is_built_in() {
500 editor.set_read_only(true);
501 editor.set_show_inline_completions(Some(false), cx);
502 }
503 editor
504 });
505 let body_editor = cx.new_view(|cx| {
506 let buffer = cx.new_model(|cx| {
507 let mut buffer = Buffer::local(prompt, cx);
508 buffer.set_language(markdown.log_err(), cx);
509 buffer.set_language_registry(language_registry);
510 buffer
511 });
512
513 let mut editor = Editor::for_buffer(buffer, None, cx);
514 if prompt_id.is_built_in() {
515 editor.set_read_only(true);
516 editor.set_show_inline_completions(Some(false), cx);
517 }
518 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
519 editor.set_show_gutter(false, cx);
520 editor.set_show_wrap_guides(false, cx);
521 editor.set_show_indent_guides(false, cx);
522 editor.set_use_modal_editing(false);
523 editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
524 editor.set_completion_provider(Some(Box::new(
525 SlashCommandCompletionProvider::new(None, None),
526 )));
527 if focus {
528 editor.focus(cx);
529 }
530 editor
531 });
532 let _subscriptions = vec![
533 cx.subscribe(&title_editor, move |this, editor, event, cx| {
534 this.handle_prompt_title_editor_event(prompt_id, editor, event, cx)
535 }),
536 cx.subscribe(&body_editor, move |this, editor, event, cx| {
537 this.handle_prompt_body_editor_event(prompt_id, editor, event, cx)
538 }),
539 ];
540 this.prompt_editors.insert(
541 prompt_id,
542 PromptEditor {
543 title_editor,
544 body_editor,
545 next_title_and_body_to_save: None,
546 pending_save: None,
547 token_count: None,
548 pending_token_count: Task::ready(None),
549 _subscriptions,
550 },
551 );
552 this.set_active_prompt(Some(prompt_id), cx);
553 this.count_tokens(prompt_id, cx);
554 }
555 Err(error) => {
556 // TODO: we should show the error in the UI.
557 log::error!("error while loading prompt: {:?}", error);
558 }
559 })
560 .ok();
561 });
562 }
563 }
564
565 fn set_active_prompt(&mut self, prompt_id: Option<PromptId>, cx: &mut ViewContext<Self>) {
566 self.active_prompt_id = prompt_id;
567 self.picker.update(cx, |picker, cx| {
568 if let Some(prompt_id) = prompt_id {
569 if picker
570 .delegate
571 .matches
572 .get(picker.delegate.selected_index())
573 .map_or(true, |old_selected_prompt| {
574 old_selected_prompt.id != prompt_id
575 })
576 {
577 if let Some(ix) = picker
578 .delegate
579 .matches
580 .iter()
581 .position(|mat| mat.id == prompt_id)
582 {
583 picker.set_selected_index(ix, true, cx);
584 }
585 }
586 } else {
587 picker.focus(cx);
588 }
589 });
590 cx.notify();
591 }
592
593 pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
594 if let Some(metadata) = self.store.metadata(prompt_id) {
595 let confirmation = cx.prompt(
596 PromptLevel::Warning,
597 &format!(
598 "Are you sure you want to delete {}",
599 metadata.title.unwrap_or("Untitled".into())
600 ),
601 None,
602 &["Delete", "Cancel"],
603 );
604
605 cx.spawn(|this, mut cx| async move {
606 if confirmation.await.ok() == Some(0) {
607 this.update(&mut cx, |this, cx| {
608 if this.active_prompt_id == Some(prompt_id) {
609 this.set_active_prompt(None, cx);
610 }
611 this.prompt_editors.remove(&prompt_id);
612 this.store.delete(prompt_id).detach_and_log_err(cx);
613 this.picker.update(cx, |picker, cx| picker.refresh(cx));
614 cx.notify();
615 })?;
616 }
617 anyhow::Ok(())
618 })
619 .detach_and_log_err(cx);
620 }
621 }
622
623 pub fn duplicate_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
624 if let Some(prompt) = self.prompt_editors.get(&prompt_id) {
625 const DUPLICATE_SUFFIX: &str = " copy";
626 let title_to_duplicate = prompt.title_editor.read(cx).text(cx);
627 let existing_titles = self
628 .prompt_editors
629 .iter()
630 .filter(|&(&id, _)| id != prompt_id)
631 .map(|(_, prompt_editor)| prompt_editor.title_editor.read(cx).text(cx))
632 .filter(|title| title.starts_with(&title_to_duplicate))
633 .collect::<HashSet<_>>();
634
635 let title = if existing_titles.is_empty() {
636 title_to_duplicate + DUPLICATE_SUFFIX
637 } else {
638 let mut i = 1;
639 loop {
640 let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
641 if !existing_titles.contains(&new_title) {
642 break new_title;
643 }
644 i += 1;
645 }
646 };
647
648 let new_id = PromptId::new();
649 let body = prompt.body_editor.read(cx).text(cx);
650 let save = self
651 .store
652 .save(new_id, Some(title.into()), false, body.into());
653 self.picker.update(cx, |picker, cx| picker.refresh(cx));
654 cx.spawn(|this, mut cx| async move {
655 save.await?;
656 this.update(&mut cx, |prompt_library, cx| {
657 prompt_library.load_prompt(new_id, true, cx)
658 })
659 })
660 .detach_and_log_err(cx);
661 }
662 }
663
664 fn focus_active_prompt(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
665 if let Some(active_prompt) = self.active_prompt_id {
666 self.prompt_editors[&active_prompt]
667 .body_editor
668 .update(cx, |editor, cx| editor.focus(cx));
669 cx.stop_propagation();
670 }
671 }
672
673 fn focus_picker(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
674 self.picker.update(cx, |picker, cx| picker.focus(cx));
675 }
676
677 pub fn inline_assist(&mut self, action: &InlineAssist, cx: &mut ViewContext<Self>) {
678 let Some(active_prompt_id) = self.active_prompt_id else {
679 cx.propagate();
680 return;
681 };
682
683 let prompt_editor = &self.prompt_editors[&active_prompt_id].body_editor;
684 let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else {
685 return;
686 };
687
688 let initial_prompt = action.prompt.clone();
689 if provider.is_authenticated(cx) {
690 InlineAssistant::update_global(cx, |assistant, cx| {
691 assistant.assist(&prompt_editor, None, None, initial_prompt, cx)
692 })
693 } else {
694 for window in cx.windows() {
695 if let Some(workspace) = window.downcast::<Workspace>() {
696 let panel = workspace
697 .update(cx, |workspace, cx| {
698 cx.activate_window();
699 workspace.focus_panel::<AssistantPanel>(cx)
700 })
701 .ok()
702 .flatten();
703 if panel.is_some() {
704 return;
705 }
706 }
707 }
708 }
709 }
710
711 fn move_down_from_title(&mut self, _: &editor::actions::MoveDown, cx: &mut ViewContext<Self>) {
712 if let Some(prompt_id) = self.active_prompt_id {
713 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
714 cx.focus_view(&prompt_editor.body_editor);
715 }
716 }
717 }
718
719 fn move_up_from_body(&mut self, _: &editor::actions::MoveUp, cx: &mut ViewContext<Self>) {
720 if let Some(prompt_id) = self.active_prompt_id {
721 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
722 cx.focus_view(&prompt_editor.title_editor);
723 }
724 }
725 }
726
727 fn handle_prompt_title_editor_event(
728 &mut self,
729 prompt_id: PromptId,
730 title_editor: View<Editor>,
731 event: &EditorEvent,
732 cx: &mut ViewContext<Self>,
733 ) {
734 match event {
735 EditorEvent::BufferEdited => {
736 self.save_prompt(prompt_id, cx);
737 self.count_tokens(prompt_id, cx);
738 }
739 EditorEvent::Blurred => {
740 title_editor.update(cx, |title_editor, cx| {
741 title_editor.change_selections(None, cx, |selections| {
742 let cursor = selections.oldest_anchor().head();
743 selections.select_anchor_ranges([cursor..cursor]);
744 });
745 });
746 }
747 _ => {}
748 }
749 }
750
751 fn handle_prompt_body_editor_event(
752 &mut self,
753 prompt_id: PromptId,
754 body_editor: View<Editor>,
755 event: &EditorEvent,
756 cx: &mut ViewContext<Self>,
757 ) {
758 match event {
759 EditorEvent::BufferEdited => {
760 self.save_prompt(prompt_id, cx);
761 self.count_tokens(prompt_id, cx);
762 }
763 EditorEvent::Blurred => {
764 body_editor.update(cx, |body_editor, cx| {
765 body_editor.change_selections(None, cx, |selections| {
766 let cursor = selections.oldest_anchor().head();
767 selections.select_anchor_ranges([cursor..cursor]);
768 });
769 });
770 }
771 _ => {}
772 }
773 }
774
775 fn count_tokens(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
776 let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else {
777 return;
778 };
779 if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) {
780 let editor = &prompt.body_editor.read(cx);
781 let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
782 let body = buffer.as_rope().clone();
783 prompt.pending_token_count = cx.spawn(|this, mut cx| {
784 async move {
785 const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
786
787 cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
788 let token_count = cx
789 .update(|cx| {
790 model.count_tokens(
791 LanguageModelRequest {
792 messages: vec![LanguageModelRequestMessage {
793 role: Role::System,
794 content: vec![body.to_string().into()],
795 cache: false,
796 }],
797 tools: Vec::new(),
798 stop: Vec::new(),
799 temperature: None,
800 },
801 cx,
802 )
803 })?
804 .await?;
805
806 this.update(&mut cx, |this, cx| {
807 let prompt_editor = this.prompt_editors.get_mut(&prompt_id).unwrap();
808 prompt_editor.token_count = Some(token_count);
809 cx.notify();
810 })
811 }
812 .log_err()
813 });
814 }
815 }
816
817 fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
818 v_flex()
819 .id("prompt-list")
820 .capture_action(cx.listener(Self::focus_active_prompt))
821 .bg(cx.theme().colors().panel_background)
822 .h_full()
823 .px_1()
824 .w_1_3()
825 .overflow_x_hidden()
826 .child(
827 h_flex()
828 .p(Spacing::Small.rems(cx))
829 .h_9()
830 .w_full()
831 .flex_none()
832 .justify_end()
833 .child(
834 IconButton::new("new-prompt", IconName::Plus)
835 .style(ButtonStyle::Transparent)
836 .shape(IconButtonShape::Square)
837 .tooltip(move |cx| Tooltip::for_action("New Prompt", &NewPrompt, cx))
838 .on_click(|_, cx| {
839 cx.dispatch_action(Box::new(NewPrompt));
840 }),
841 ),
842 )
843 .child(div().flex_grow().child(self.picker.clone()))
844 }
845
846 fn render_active_prompt(&mut self, cx: &mut ViewContext<PromptLibrary>) -> gpui::Stateful<Div> {
847 div()
848 .w_2_3()
849 .h_full()
850 .id("prompt-editor")
851 .border_l_1()
852 .border_color(cx.theme().colors().border)
853 .bg(cx.theme().colors().editor_background)
854 .flex_none()
855 .min_w_64()
856 .children(self.active_prompt_id.and_then(|prompt_id| {
857 let prompt_metadata = self.store.metadata(prompt_id)?;
858 let prompt_editor = &self.prompt_editors[&prompt_id];
859 let focus_handle = prompt_editor.body_editor.focus_handle(cx);
860 let model = LanguageModelRegistry::read_global(cx).active_model();
861 let settings = ThemeSettings::get_global(cx);
862
863 Some(
864 v_flex()
865 .id("prompt-editor-inner")
866 .size_full()
867 .relative()
868 .overflow_hidden()
869 .pl(Spacing::XXLarge.rems(cx))
870 .pt(Spacing::Large.rems(cx))
871 .on_click(cx.listener(move |_, _, cx| {
872 cx.focus(&focus_handle);
873 }))
874 .child(
875 h_flex()
876 .group("active-editor-header")
877 .pr(Spacing::XXLarge.rems(cx))
878 .pt(Spacing::XSmall.rems(cx))
879 .pb(Spacing::Large.rems(cx))
880 .justify_between()
881 .child(
882 h_flex().gap_1().child(
883 div()
884 .max_w_80()
885 .on_action(cx.listener(Self::move_down_from_title))
886 .border_1()
887 .border_color(transparent_black())
888 .rounded_md()
889 .group_hover("active-editor-header", |this| {
890 this.border_color(
891 cx.theme().colors().border_variant,
892 )
893 })
894 .child(EditorElement::new(
895 &prompt_editor.title_editor,
896 EditorStyle {
897 background: cx.theme().system().transparent,
898 local_player: cx.theme().players().local(),
899 text: TextStyle {
900 color: cx
901 .theme()
902 .colors()
903 .editor_foreground,
904 font_family: settings
905 .ui_font
906 .family
907 .clone(),
908 font_features: settings
909 .ui_font
910 .features
911 .clone(),
912 font_size: HeadlineSize::Large
913 .rems()
914 .into(),
915 font_weight: settings.ui_font.weight,
916 line_height: relative(
917 settings.buffer_line_height.value(),
918 ),
919 ..Default::default()
920 },
921 scrollbar_width: Pixels::ZERO,
922 syntax: cx.theme().syntax().clone(),
923 status: cx.theme().status().clone(),
924 inlay_hints_style:
925 editor::make_inlay_hints_style(cx),
926 suggestions_style: HighlightStyle {
927 color: Some(cx.theme().status().predictive),
928 ..HighlightStyle::default()
929 },
930 ..EditorStyle::default()
931 },
932 )),
933 ),
934 )
935 .child(
936 h_flex()
937 .h_full()
938 .child(
939 h_flex()
940 .h_full()
941 .gap(Spacing::XXLarge.rems(cx))
942 .child(div()),
943 )
944 .child(
945 h_flex()
946 .h_full()
947 .gap(Spacing::XXLarge.rems(cx))
948 .children(prompt_editor.token_count.map(
949 |token_count| {
950 let token_count: SharedString =
951 token_count.to_string().into();
952 let label_token_count: SharedString =
953 token_count.to_string().into();
954
955 h_flex()
956 .id("token_count")
957 .tooltip(move |cx| {
958 let token_count =
959 token_count.clone();
960
961 Tooltip::with_meta(
962 format!(
963 "{} tokens",
964 token_count.clone()
965 ),
966 None,
967 format!(
968 "Model: {}",
969 model
970 .as_ref()
971 .map(|model| model
972 .name()
973 .0)
974 .unwrap_or_default()
975 ),
976 cx,
977 )
978 })
979 .child(
980 Label::new(format!(
981 "{} tokens",
982 label_token_count.clone()
983 ))
984 .color(Color::Muted),
985 )
986 },
987 ))
988 .child(if prompt_id.is_built_in() {
989 div()
990 .id("built-in-prompt")
991 .child(
992 Icon::new(IconName::FileLock)
993 .color(Color::Muted),
994 )
995 .tooltip(move |cx| {
996 Tooltip::with_meta(
997 "Built-in prompt",
998 None,
999 BUILT_IN_TOOLTIP_TEXT,
1000 cx,
1001 )
1002 })
1003 .into_any()
1004 } else {
1005 IconButton::new(
1006 "delete-prompt",
1007 IconName::Trash,
1008 )
1009 .size(ButtonSize::Large)
1010 .style(ButtonStyle::Transparent)
1011 .shape(IconButtonShape::Square)
1012 .size(ButtonSize::Large)
1013 .tooltip(move |cx| {
1014 Tooltip::for_action(
1015 "Delete Prompt",
1016 &DeletePrompt,
1017 cx,
1018 )
1019 })
1020 .on_click(|_, cx| {
1021 cx.dispatch_action(Box::new(DeletePrompt));
1022 })
1023 .into_any_element()
1024 })
1025 .child(
1026 IconButton::new(
1027 "duplicate-prompt",
1028 IconName::BookCopy,
1029 )
1030 .size(ButtonSize::Large)
1031 .style(ButtonStyle::Transparent)
1032 .shape(IconButtonShape::Square)
1033 .size(ButtonSize::Large)
1034 .tooltip(move |cx| {
1035 Tooltip::for_action(
1036 "Duplicate Prompt",
1037 &DuplicatePrompt,
1038 cx,
1039 )
1040 })
1041 .on_click(|_, cx| {
1042 cx.dispatch_action(Box::new(
1043 DuplicatePrompt,
1044 ));
1045 }),
1046 )
1047 .child(
1048 IconButton::new(
1049 "toggle-default-prompt",
1050 IconName::Sparkle,
1051 )
1052 .style(ButtonStyle::Transparent)
1053 .selected(prompt_metadata.default)
1054 .selected_icon(IconName::SparkleFilled)
1055 .icon_color(if prompt_metadata.default {
1056 Color::Accent
1057 } else {
1058 Color::Muted
1059 })
1060 .shape(IconButtonShape::Square)
1061 .size(ButtonSize::Large)
1062 .tooltip(move |cx| {
1063 Tooltip::text(
1064 if prompt_metadata.default {
1065 "Remove from Default Prompt"
1066 } else {
1067 "Add to Default Prompt"
1068 },
1069 cx,
1070 )
1071 })
1072 .on_click(|_, cx| {
1073 cx.dispatch_action(Box::new(
1074 ToggleDefaultPrompt,
1075 ));
1076 }),
1077 ),
1078 ),
1079 ),
1080 )
1081 .child(
1082 div()
1083 .on_action(cx.listener(Self::focus_picker))
1084 .on_action(cx.listener(Self::inline_assist))
1085 .on_action(cx.listener(Self::move_up_from_body))
1086 .flex_grow()
1087 .h_full()
1088 .child(prompt_editor.body_editor.clone()),
1089 ),
1090 )
1091 }))
1092 }
1093}
1094
1095impl Render for PromptLibrary {
1096 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
1097 let ui_font = theme::setup_ui_font(cx);
1098 let theme = cx.theme().clone();
1099
1100 h_flex()
1101 .id("prompt-manager")
1102 .key_context("PromptLibrary")
1103 .on_action(cx.listener(|this, &NewPrompt, cx| this.new_prompt(cx)))
1104 .on_action(cx.listener(|this, &DeletePrompt, cx| this.delete_active_prompt(cx)))
1105 .on_action(cx.listener(|this, &DuplicatePrompt, cx| this.duplicate_active_prompt(cx)))
1106 .on_action(cx.listener(|this, &ToggleDefaultPrompt, cx| {
1107 this.toggle_default_for_active_prompt(cx)
1108 }))
1109 .size_full()
1110 .overflow_hidden()
1111 .font(ui_font)
1112 .text_color(theme.colors().text)
1113 .child(self.render_prompt_list(cx))
1114 .map(|el| {
1115 if self.store.prompt_count() == 0 {
1116 el.child(
1117 v_flex()
1118 .w_2_3()
1119 .h_full()
1120 .items_center()
1121 .justify_center()
1122 .gap_4()
1123 .bg(cx.theme().colors().editor_background)
1124 .child(
1125 h_flex()
1126 .gap_2()
1127 .child(
1128 Icon::new(IconName::Book)
1129 .size(IconSize::Medium)
1130 .color(Color::Muted),
1131 )
1132 .child(
1133 Label::new("No prompts yet")
1134 .size(LabelSize::Large)
1135 .color(Color::Muted),
1136 ),
1137 )
1138 .child(
1139 h_flex()
1140 .child(h_flex())
1141 .child(
1142 v_flex()
1143 .gap_1()
1144 .child(Label::new("Create your first prompt:"))
1145 .child(
1146 Button::new("create-prompt", "New Prompt")
1147 .full_width()
1148 .key_binding(KeyBinding::for_action(
1149 &NewPrompt, cx,
1150 ))
1151 .on_click(|_, cx| {
1152 cx.dispatch_action(NewPrompt.boxed_clone())
1153 }),
1154 ),
1155 )
1156 .child(h_flex()),
1157 ),
1158 )
1159 } else {
1160 el.child(self.render_active_prompt(cx))
1161 }
1162 })
1163 }
1164}
1165
1166#[derive(Clone, Debug, Serialize, Deserialize)]
1167pub struct PromptMetadata {
1168 pub id: PromptId,
1169 pub title: Option<SharedString>,
1170 pub default: bool,
1171 pub saved_at: DateTime<Utc>,
1172}
1173
1174#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
1175#[serde(tag = "kind")]
1176pub enum PromptId {
1177 User { uuid: Uuid },
1178 EditWorkflow,
1179}
1180
1181impl PromptId {
1182 pub fn new() -> PromptId {
1183 PromptId::User {
1184 uuid: Uuid::new_v4(),
1185 }
1186 }
1187
1188 pub fn is_built_in(&self) -> bool {
1189 !matches!(self, PromptId::User { .. })
1190 }
1191}
1192
1193pub struct PromptStore {
1194 executor: BackgroundExecutor,
1195 env: heed::Env,
1196 metadata_cache: RwLock<MetadataCache>,
1197 metadata: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
1198 bodies: Database<SerdeJson<PromptId>, Str>,
1199}
1200
1201#[derive(Default)]
1202struct MetadataCache {
1203 metadata: Vec<PromptMetadata>,
1204 metadata_by_id: HashMap<PromptId, PromptMetadata>,
1205}
1206
1207impl MetadataCache {
1208 fn from_db(
1209 db: Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
1210 txn: &RoTxn,
1211 ) -> Result<Self> {
1212 let mut cache = MetadataCache::default();
1213 for result in db.iter(txn)? {
1214 let (prompt_id, metadata) = result?;
1215 cache.metadata.push(metadata.clone());
1216 cache.metadata_by_id.insert(prompt_id, metadata);
1217 }
1218 cache.sort();
1219 Ok(cache)
1220 }
1221
1222 fn insert(&mut self, metadata: PromptMetadata) {
1223 self.metadata_by_id.insert(metadata.id, metadata.clone());
1224 if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) {
1225 *old_metadata = metadata;
1226 } else {
1227 self.metadata.push(metadata);
1228 }
1229 self.sort();
1230 }
1231
1232 fn remove(&mut self, id: PromptId) {
1233 self.metadata.retain(|metadata| metadata.id != id);
1234 self.metadata_by_id.remove(&id);
1235 }
1236
1237 fn sort(&mut self) {
1238 self.metadata.sort_unstable_by(|a, b| {
1239 a.title
1240 .cmp(&b.title)
1241 .then_with(|| b.saved_at.cmp(&a.saved_at))
1242 });
1243 }
1244}
1245
1246impl PromptStore {
1247 pub fn global(cx: &AppContext) -> impl Future<Output = Result<Arc<Self>>> {
1248 let store = GlobalPromptStore::global(cx).0.clone();
1249 async move { store.await.map_err(|err| anyhow!(err)) }
1250 }
1251
1252 pub fn new(db_path: PathBuf, executor: BackgroundExecutor) -> Task<Result<Self>> {
1253 executor.spawn({
1254 let executor = executor.clone();
1255 async move {
1256 std::fs::create_dir_all(&db_path)?;
1257
1258 let db_env = unsafe {
1259 heed::EnvOpenOptions::new()
1260 .map_size(1024 * 1024 * 1024) // 1GB
1261 .max_dbs(4) // Metadata and bodies (possibly v1 of both as well)
1262 .open(db_path)?
1263 };
1264
1265 let mut txn = db_env.write_txn()?;
1266 let metadata = db_env.create_database(&mut txn, Some("metadata.v2"))?;
1267 let bodies = db_env.create_database(&mut txn, Some("bodies.v2"))?;
1268
1269 // Remove edit workflow prompt, as we decided to opt into it using
1270 // a slash command instead.
1271 metadata.delete(&mut txn, &PromptId::EditWorkflow).ok();
1272 bodies.delete(&mut txn, &PromptId::EditWorkflow).ok();
1273
1274 txn.commit()?;
1275
1276 Self::upgrade_dbs(&db_env, metadata, bodies).log_err();
1277
1278 let txn = db_env.read_txn()?;
1279 let metadata_cache = MetadataCache::from_db(metadata, &txn)?;
1280 txn.commit()?;
1281
1282 Ok(PromptStore {
1283 executor,
1284 env: db_env,
1285 metadata_cache: RwLock::new(metadata_cache),
1286 metadata,
1287 bodies,
1288 })
1289 }
1290 })
1291 }
1292
1293 fn upgrade_dbs(
1294 env: &heed::Env,
1295 metadata_db: heed::Database<SerdeJson<PromptId>, SerdeJson<PromptMetadata>>,
1296 bodies_db: heed::Database<SerdeJson<PromptId>, Str>,
1297 ) -> Result<()> {
1298 #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)]
1299 pub struct PromptIdV1(Uuid);
1300
1301 #[derive(Clone, Debug, Serialize, Deserialize)]
1302 pub struct PromptMetadataV1 {
1303 pub id: PromptIdV1,
1304 pub title: Option<SharedString>,
1305 pub default: bool,
1306 pub saved_at: DateTime<Utc>,
1307 }
1308
1309 let mut txn = env.write_txn()?;
1310 let Some(bodies_v1_db) = env
1311 .open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<String>>(
1312 &txn,
1313 Some("bodies"),
1314 )?
1315 else {
1316 return Ok(());
1317 };
1318 let mut bodies_v1 = bodies_v1_db
1319 .iter(&txn)?
1320 .collect::<heed::Result<HashMap<_, _>>>()?;
1321
1322 let Some(metadata_v1_db) = env
1323 .open_database::<SerdeBincode<PromptIdV1>, SerdeBincode<PromptMetadataV1>>(
1324 &txn,
1325 Some("metadata"),
1326 )?
1327 else {
1328 return Ok(());
1329 };
1330 let metadata_v1 = metadata_v1_db
1331 .iter(&txn)?
1332 .collect::<heed::Result<HashMap<_, _>>>()?;
1333
1334 for (prompt_id_v1, metadata_v1) in metadata_v1 {
1335 let prompt_id_v2 = PromptId::User {
1336 uuid: prompt_id_v1.0,
1337 };
1338 let Some(body_v1) = bodies_v1.remove(&prompt_id_v1) else {
1339 continue;
1340 };
1341
1342 if metadata_db
1343 .get(&txn, &prompt_id_v2)?
1344 .map_or(true, |metadata_v2| {
1345 metadata_v1.saved_at > metadata_v2.saved_at
1346 })
1347 {
1348 metadata_db.put(
1349 &mut txn,
1350 &prompt_id_v2,
1351 &PromptMetadata {
1352 id: prompt_id_v2,
1353 title: metadata_v1.title.clone(),
1354 default: metadata_v1.default,
1355 saved_at: metadata_v1.saved_at,
1356 },
1357 )?;
1358 bodies_db.put(&mut txn, &prompt_id_v2, &body_v1)?;
1359 }
1360 }
1361
1362 txn.commit()?;
1363
1364 Ok(())
1365 }
1366
1367 pub fn load(&self, id: PromptId) -> Task<Result<String>> {
1368 let env = self.env.clone();
1369 let bodies = self.bodies;
1370 self.executor.spawn(async move {
1371 let txn = env.read_txn()?;
1372 let mut prompt = bodies
1373 .get(&txn, &id)?
1374 .ok_or_else(|| anyhow!("prompt not found"))?
1375 .into();
1376 LineEnding::normalize(&mut prompt);
1377 Ok(prompt)
1378 })
1379 }
1380
1381 pub fn default_prompt_metadata(&self) -> Vec<PromptMetadata> {
1382 return self
1383 .metadata_cache
1384 .read()
1385 .metadata
1386 .iter()
1387 .filter(|metadata| metadata.default)
1388 .cloned()
1389 .collect::<Vec<_>>();
1390 }
1391
1392 pub fn delete(&self, id: PromptId) -> Task<Result<()>> {
1393 self.metadata_cache.write().remove(id);
1394
1395 let db_connection = self.env.clone();
1396 let bodies = self.bodies;
1397 let metadata = self.metadata;
1398
1399 self.executor.spawn(async move {
1400 let mut txn = db_connection.write_txn()?;
1401
1402 metadata.delete(&mut txn, &id)?;
1403 bodies.delete(&mut txn, &id)?;
1404
1405 txn.commit()?;
1406 Ok(())
1407 })
1408 }
1409
1410 /// Returns the number of prompts in the store.
1411 fn prompt_count(&self) -> usize {
1412 self.metadata_cache.read().metadata.len()
1413 }
1414
1415 fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
1416 self.metadata_cache.read().metadata_by_id.get(&id).cloned()
1417 }
1418
1419 pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
1420 let metadata_cache = self.metadata_cache.read();
1421 let metadata = metadata_cache
1422 .metadata
1423 .iter()
1424 .find(|metadata| metadata.title.as_ref().map(|title| &***title) == Some(title))?;
1425 Some(metadata.id)
1426 }
1427
1428 pub fn search(&self, query: String) -> Task<Vec<PromptMetadata>> {
1429 let cached_metadata = self.metadata_cache.read().metadata.clone();
1430 let executor = self.executor.clone();
1431 self.executor.spawn(async move {
1432 let mut matches = if query.is_empty() {
1433 cached_metadata
1434 } else {
1435 let candidates = cached_metadata
1436 .iter()
1437 .enumerate()
1438 .filter_map(|(ix, metadata)| {
1439 Some(StringMatchCandidate::new(
1440 ix,
1441 metadata.title.as_ref()?.to_string(),
1442 ))
1443 })
1444 .collect::<Vec<_>>();
1445 let matches = fuzzy::match_strings(
1446 &candidates,
1447 &query,
1448 false,
1449 100,
1450 &AtomicBool::default(),
1451 executor,
1452 )
1453 .await;
1454 matches
1455 .into_iter()
1456 .map(|mat| cached_metadata[mat.candidate_id].clone())
1457 .collect()
1458 };
1459 matches.sort_by_key(|metadata| Reverse(metadata.default));
1460 matches
1461 })
1462 }
1463
1464 fn save(
1465 &self,
1466 id: PromptId,
1467 title: Option<SharedString>,
1468 default: bool,
1469 body: Rope,
1470 ) -> Task<Result<()>> {
1471 if id.is_built_in() {
1472 return Task::ready(Err(anyhow!("built-in prompts cannot be saved")));
1473 }
1474
1475 let prompt_metadata = PromptMetadata {
1476 id,
1477 title,
1478 default,
1479 saved_at: Utc::now(),
1480 };
1481 self.metadata_cache.write().insert(prompt_metadata.clone());
1482
1483 let db_connection = self.env.clone();
1484 let bodies = self.bodies;
1485 let metadata = self.metadata;
1486
1487 self.executor.spawn(async move {
1488 let mut txn = db_connection.write_txn()?;
1489
1490 metadata.put(&mut txn, &id, &prompt_metadata)?;
1491 bodies.put(&mut txn, &id, &body.to_string())?;
1492
1493 txn.commit()?;
1494
1495 Ok(())
1496 })
1497 }
1498
1499 fn save_metadata(
1500 &self,
1501 id: PromptId,
1502 mut title: Option<SharedString>,
1503 default: bool,
1504 ) -> Task<Result<()>> {
1505 let mut cache = self.metadata_cache.write();
1506
1507 if id.is_built_in() {
1508 title = cache
1509 .metadata_by_id
1510 .get(&id)
1511 .and_then(|metadata| metadata.title.clone());
1512 }
1513
1514 let prompt_metadata = PromptMetadata {
1515 id,
1516 title,
1517 default,
1518 saved_at: Utc::now(),
1519 };
1520
1521 cache.insert(prompt_metadata.clone());
1522
1523 let db_connection = self.env.clone();
1524 let metadata = self.metadata;
1525
1526 self.executor.spawn(async move {
1527 let mut txn = db_connection.write_txn()?;
1528 metadata.put(&mut txn, &id, &prompt_metadata)?;
1529 txn.commit()?;
1530
1531 Ok(())
1532 })
1533 }
1534
1535 fn first(&self) -> Option<PromptMetadata> {
1536 self.metadata_cache.read().metadata.first().cloned()
1537 }
1538}
1539
1540/// Wraps a shared future to a prompt store so it can be assigned as a context global.
1541pub struct GlobalPromptStore(
1542 Shared<BoxFuture<'static, Result<Arc<PromptStore>, Arc<anyhow::Error>>>>,
1543);
1544
1545impl Global for GlobalPromptStore {}