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