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