1use anyhow::{anyhow, Result};
2use chrono::{DateTime, Utc};
3use collections::HashMap;
4use editor::{Editor, EditorEvent};
5use futures::{
6 future::{self, BoxFuture, Shared},
7 FutureExt,
8};
9use fuzzy::StringMatchCandidate;
10use gpui::{
11 actions, point, size, AppContext, BackgroundExecutor, Bounds, DevicePixels, Empty,
12 EventEmitter, Global, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions, View,
13 WindowBounds, WindowHandle, WindowOptions,
14};
15use heed::{types::SerdeBincode, Database, RoTxn};
16use language::{language_settings::SoftWrap, Buffer, LanguageRegistry};
17use parking_lot::RwLock;
18use picker::{Picker, PickerDelegate};
19use rope::Rope;
20use serde::{Deserialize, Serialize};
21use std::{
22 cmp::Reverse,
23 future::Future,
24 path::PathBuf,
25 sync::{atomic::AtomicBool, Arc},
26 time::Duration,
27};
28use ui::{
29 div, prelude::*, IconButtonShape, ListItem, ListItemSpacing, ParentElement, Render,
30 SharedString, Styled, TitleBar, Tooltip, ViewContext, VisualContext,
31};
32use util::{paths::PROMPTS_DIR, ResultExt, TryFutureExt};
33use uuid::Uuid;
34
35actions!(
36 prompt_library,
37 [NewPrompt, DeletePrompt, ToggleDefaultPrompt]
38);
39
40/// Init starts loading the PromptStore in the background and assigns
41/// a shared future to a global.
42pub fn init(cx: &mut AppContext) {
43 let db_path = PROMPTS_DIR.join("prompts-library-db.0.mdb");
44 let prompt_store_future = PromptStore::new(db_path, cx.background_executor().clone())
45 .then(|result| future::ready(result.map(Arc::new).map_err(Arc::new)))
46 .boxed()
47 .shared();
48 cx.set_global(GlobalPromptStore(prompt_store_future))
49}
50
51/// This function opens a new prompt library window if one doesn't exist already.
52/// If one exists, it brings it to the foreground.
53///
54/// Note that, when opening a new window, this waits for the PromptStore to be
55/// initialized. If it was initialized successfully, it returns a window handle
56/// to a prompt library.
57pub fn open_prompt_library(
58 language_registry: Arc<LanguageRegistry>,
59 cx: &mut AppContext,
60) -> Task<Result<WindowHandle<PromptLibrary>>> {
61 let existing_window = cx
62 .windows()
63 .into_iter()
64 .find_map(|window| window.downcast::<PromptLibrary>());
65 if let Some(existing_window) = existing_window {
66 existing_window
67 .update(cx, |_, cx| cx.activate_window())
68 .ok();
69 Task::ready(Ok(existing_window))
70 } else {
71 let store = PromptStore::global(cx);
72 cx.spawn(|cx| async move {
73 let store = store.await?;
74 cx.update(|cx| {
75 let bounds = Bounds::centered(
76 None,
77 size(DevicePixels::from(1024), DevicePixels::from(768)),
78 cx,
79 );
80 cx.open_window(
81 WindowOptions {
82 titlebar: Some(TitlebarOptions {
83 title: None,
84 appears_transparent: true,
85 traffic_light_position: Some(point(px(9.0), px(9.0))),
86 }),
87 window_bounds: Some(WindowBounds::Windowed(bounds)),
88 ..Default::default()
89 },
90 |cx| cx.new_view(|cx| PromptLibrary::new(store, language_registry, cx)),
91 )
92 })
93 })
94 }
95}
96
97pub struct PromptLibrary {
98 store: Arc<PromptStore>,
99 language_registry: Arc<LanguageRegistry>,
100 prompt_editors: HashMap<PromptId, PromptEditor>,
101 active_prompt_id: Option<PromptId>,
102 picker: View<Picker<PromptPickerDelegate>>,
103 pending_load: Task<()>,
104 _subscriptions: Vec<Subscription>,
105}
106
107struct PromptEditor {
108 editor: View<Editor>,
109 next_body_to_save: Option<Rope>,
110 pending_save: Option<Task<Option<()>>>,
111 _subscription: Subscription,
112}
113
114struct PromptPickerDelegate {
115 store: Arc<PromptStore>,
116 selected_index: usize,
117 matches: Vec<PromptMetadata>,
118}
119
120enum PromptPickerEvent {
121 Confirmed { prompt_id: PromptId },
122 Deleted { prompt_id: PromptId },
123 ToggledDefault { prompt_id: PromptId },
124}
125
126impl EventEmitter<PromptPickerEvent> for Picker<PromptPickerDelegate> {}
127
128impl PickerDelegate for PromptPickerDelegate {
129 type ListItem = ListItem;
130
131 fn match_count(&self) -> usize {
132 self.matches.len()
133 }
134
135 fn selected_index(&self) -> usize {
136 self.selected_index
137 }
138
139 fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
140 self.selected_index = ix;
141 }
142
143 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
144 "Search...".into()
145 }
146
147 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
148 let search = self.store.search(query);
149 cx.spawn(|this, mut cx| async move {
150 let matches = search.await;
151 this.update(&mut cx, |this, cx| {
152 this.delegate.selected_index = 0;
153 this.delegate.matches = matches;
154 cx.notify();
155 })
156 .ok();
157 })
158 }
159
160 fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
161 if let Some(prompt) = self.matches.get(self.selected_index) {
162 cx.emit(PromptPickerEvent::Confirmed {
163 prompt_id: prompt.id,
164 });
165 }
166 }
167
168 fn dismissed(&mut self, _cx: &mut ViewContext<Picker<Self>>) {}
169
170 fn render_match(
171 &self,
172 ix: usize,
173 selected: bool,
174 cx: &mut ViewContext<Picker<Self>>,
175 ) -> Option<Self::ListItem> {
176 let prompt = self.matches.get(ix)?;
177 let default = prompt.default;
178 let prompt_id = prompt.id;
179 Some(
180 ListItem::new(ix)
181 .inset(true)
182 .spacing(ListItemSpacing::Sparse)
183 .selected(selected)
184 .child(Label::new(
185 prompt.title.clone().unwrap_or("Untitled".into()),
186 ))
187 .end_slot(if default {
188 IconButton::new("toggle-default-prompt", IconName::StarFilled)
189 .shape(IconButtonShape::Square)
190 .into_any_element()
191 } else {
192 Empty.into_any()
193 })
194 .end_hover_slot(
195 h_flex()
196 .gap_2()
197 .child(
198 IconButton::new("delete-prompt", IconName::Trash)
199 .shape(IconButtonShape::Square)
200 .tooltip(move |cx| Tooltip::text("Delete Prompt", cx))
201 .on_click(cx.listener(move |_, _, cx| {
202 cx.emit(PromptPickerEvent::Deleted { prompt_id })
203 })),
204 )
205 .child(
206 IconButton::new(
207 "toggle-default-prompt",
208 if default {
209 IconName::StarFilled
210 } else {
211 IconName::Star
212 },
213 )
214 .shape(IconButtonShape::Square)
215 .tooltip(move |cx| {
216 Tooltip::text(
217 if default {
218 "Remove from Default Prompt"
219 } else {
220 "Add to Default Prompt"
221 },
222 cx,
223 )
224 })
225 .on_click(cx.listener(move |_, _, cx| {
226 cx.emit(PromptPickerEvent::ToggledDefault { prompt_id })
227 })),
228 ),
229 ),
230 )
231 }
232}
233
234impl PromptLibrary {
235 fn new(
236 store: Arc<PromptStore>,
237 language_registry: Arc<LanguageRegistry>,
238 cx: &mut ViewContext<Self>,
239 ) -> Self {
240 let delegate = PromptPickerDelegate {
241 store: store.clone(),
242 selected_index: 0,
243 matches: Vec::new(),
244 };
245
246 let picker = cx.new_view(|cx| {
247 let picker = Picker::uniform_list(delegate, cx)
248 .modal(false)
249 .max_height(None);
250 picker.focus(cx);
251 picker
252 });
253 let mut this = Self {
254 store: store.clone(),
255 language_registry,
256 prompt_editors: HashMap::default(),
257 active_prompt_id: None,
258 pending_load: Task::ready(()),
259 _subscriptions: vec![cx.subscribe(&picker, Self::handle_picker_event)],
260 picker,
261 };
262 if let Some(prompt_id) = store.most_recently_saved() {
263 this.load_prompt(prompt_id, false, cx);
264 }
265 this
266 }
267
268 fn handle_picker_event(
269 &mut self,
270 _: View<Picker<PromptPickerDelegate>>,
271 event: &PromptPickerEvent,
272 cx: &mut ViewContext<Self>,
273 ) {
274 match event {
275 PromptPickerEvent::Confirmed { prompt_id } => {
276 self.load_prompt(*prompt_id, true, cx);
277 }
278 PromptPickerEvent::ToggledDefault { prompt_id } => {
279 self.toggle_default_for_prompt(*prompt_id, cx);
280 }
281 PromptPickerEvent::Deleted { prompt_id } => {
282 self.delete_prompt(*prompt_id, cx);
283 }
284 }
285 }
286
287 pub fn new_prompt(&mut self, cx: &mut ViewContext<Self>) {
288 let prompt_id = PromptId::new();
289 let save = self.store.save(prompt_id, None, false, "".into());
290 self.picker.update(cx, |picker, cx| picker.refresh(cx));
291 cx.spawn(|this, mut cx| async move {
292 save.await?;
293 this.update(&mut cx, |this, cx| this.load_prompt(prompt_id, true, cx))
294 })
295 .detach_and_log_err(cx);
296 }
297
298 pub fn save_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
299 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
300
301 let prompt_metadata = self.store.metadata(prompt_id).unwrap();
302 let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
303 let body = prompt_editor.editor.update(cx, |editor, cx| {
304 editor
305 .buffer()
306 .read(cx)
307 .as_singleton()
308 .unwrap()
309 .read(cx)
310 .as_rope()
311 .clone()
312 });
313
314 let store = self.store.clone();
315 let executor = cx.background_executor().clone();
316
317 prompt_editor.next_body_to_save = Some(body);
318 if prompt_editor.pending_save.is_none() {
319 prompt_editor.pending_save = Some(cx.spawn(|this, mut cx| {
320 async move {
321 loop {
322 let next_body_to_save = this.update(&mut cx, |this, _| {
323 this.prompt_editors
324 .get_mut(&prompt_id)?
325 .next_body_to_save
326 .take()
327 })?;
328
329 if let Some(body) = next_body_to_save {
330 let title = title_from_body(body.chars_at(0));
331 store
332 .save(prompt_id, title, prompt_metadata.default, body)
333 .await
334 .log_err();
335 this.update(&mut cx, |this, cx| {
336 this.picker.update(cx, |picker, cx| picker.refresh(cx));
337 cx.notify();
338 })?;
339
340 executor.timer(SAVE_THROTTLE).await;
341 } else {
342 break;
343 }
344 }
345
346 this.update(&mut cx, |this, _cx| {
347 if let Some(prompt_editor) = this.prompt_editors.get_mut(&prompt_id) {
348 prompt_editor.pending_save = None;
349 }
350 })
351 }
352 .log_err()
353 }));
354 }
355 }
356
357 pub fn delete_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
358 if let Some(active_prompt_id) = self.active_prompt_id {
359 self.delete_prompt(active_prompt_id, cx);
360 }
361 }
362
363 pub fn toggle_default_for_active_prompt(&mut self, cx: &mut ViewContext<Self>) {
364 if let Some(active_prompt_id) = self.active_prompt_id {
365 self.toggle_default_for_prompt(active_prompt_id, cx);
366 }
367 }
368
369 pub fn toggle_default_for_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
370 if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
371 self.store
372 .save_metadata(prompt_id, prompt_metadata.title, !prompt_metadata.default)
373 .detach_and_log_err(cx);
374 self.picker.update(cx, |picker, cx| picker.refresh(cx));
375 cx.notify();
376 }
377 }
378
379 pub fn load_prompt(&mut self, prompt_id: PromptId, focus: bool, cx: &mut ViewContext<Self>) {
380 if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
381 if focus {
382 prompt_editor
383 .editor
384 .update(cx, |editor, cx| editor.focus(cx));
385 }
386 self.active_prompt_id = Some(prompt_id);
387 } else {
388 let language_registry = self.language_registry.clone();
389 let prompt = self.store.load(prompt_id);
390 self.pending_load = cx.spawn(|this, mut cx| async move {
391 let prompt = prompt.await;
392 let markdown = language_registry.language_for_name("Markdown").await;
393 this.update(&mut cx, |this, cx| match prompt {
394 Ok(prompt) => {
395 let buffer = cx.new_model(|cx| {
396 let mut buffer = Buffer::local(prompt, cx);
397 buffer.set_language(markdown.log_err(), cx);
398 buffer.set_language_registry(language_registry);
399 buffer
400 });
401 let editor = cx.new_view(|cx| {
402 let mut editor = Editor::for_buffer(buffer, None, cx);
403 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
404 editor.set_show_gutter(false, cx);
405 editor.set_show_wrap_guides(false, cx);
406 editor.set_show_indent_guides(false, cx);
407 if focus {
408 editor.focus(cx);
409 }
410 editor
411 });
412 let _subscription =
413 cx.subscribe(&editor, move |this, _editor, event, cx| {
414 this.handle_prompt_editor_event(prompt_id, event, cx)
415 });
416 this.prompt_editors.insert(
417 prompt_id,
418 PromptEditor {
419 editor,
420 next_body_to_save: None,
421 pending_save: None,
422 _subscription,
423 },
424 );
425 this.active_prompt_id = Some(prompt_id);
426 cx.notify();
427 }
428 Err(error) => {
429 // TODO: we should show the error in the UI.
430 log::error!("error while loading prompt: {:?}", error);
431 }
432 })
433 .ok();
434 });
435 }
436 }
437
438 pub fn delete_prompt(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
439 if let Some(metadata) = self.store.metadata(prompt_id) {
440 let confirmation = cx.prompt(
441 PromptLevel::Warning,
442 &format!(
443 "Are you sure you want to delete {}",
444 metadata.title.unwrap_or("Untitled".into())
445 ),
446 None,
447 &["Delete", "Cancel"],
448 );
449
450 cx.spawn(|this, mut cx| async move {
451 if confirmation.await.ok() == Some(0) {
452 this.update(&mut cx, |this, cx| {
453 if this.active_prompt_id == Some(prompt_id) {
454 this.active_prompt_id = None;
455 }
456 this.prompt_editors.remove(&prompt_id);
457 this.store.delete(prompt_id).detach_and_log_err(cx);
458 this.picker.update(cx, |picker, cx| picker.refresh(cx));
459 cx.notify();
460 })?;
461 }
462 anyhow::Ok(())
463 })
464 .detach_and_log_err(cx);
465 }
466 }
467
468 fn handle_prompt_editor_event(
469 &mut self,
470 prompt_id: PromptId,
471 event: &EditorEvent,
472 cx: &mut ViewContext<Self>,
473 ) {
474 if let EditorEvent::BufferEdited = event {
475 let prompt_editor = self.prompt_editors.get(&prompt_id).unwrap();
476 let buffer = prompt_editor
477 .editor
478 .read(cx)
479 .buffer()
480 .read(cx)
481 .as_singleton()
482 .unwrap();
483
484 buffer.update(cx, |buffer, cx| {
485 let mut chars = buffer.chars_at(0);
486 match chars.next() {
487 Some('#') => {
488 if chars.next() != Some(' ') {
489 drop(chars);
490 buffer.edit([(1..1, " ")], None, cx);
491 }
492 }
493 Some(' ') => {
494 drop(chars);
495 buffer.edit([(0..0, "#")], None, cx);
496 }
497 _ => {
498 drop(chars);
499 buffer.edit([(0..0, "# ")], None, cx);
500 }
501 }
502 });
503
504 self.save_prompt(prompt_id, cx);
505 }
506 }
507
508 fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
509 v_flex()
510 .id("prompt-list")
511 .bg(cx.theme().colors().panel_background)
512 .h_full()
513 .w_1_3()
514 .overflow_x_hidden()
515 .child(
516 h_flex()
517 .p(Spacing::Small.rems(cx))
518 .border_b_1()
519 .border_color(cx.theme().colors().border)
520 .h(TitleBar::height(cx))
521 .w_full()
522 .flex_none()
523 .justify_end()
524 .child(
525 IconButton::new("new-prompt", IconName::Plus)
526 .shape(IconButtonShape::Square)
527 .tooltip(move |cx| Tooltip::for_action("New Prompt", &NewPrompt, cx))
528 .on_click(|_, cx| {
529 cx.dispatch_action(Box::new(NewPrompt));
530 }),
531 ),
532 )
533 .child(div().flex_grow().child(self.picker.clone()))
534 }
535
536 fn render_active_prompt(&mut self, cx: &mut ViewContext<PromptLibrary>) -> gpui::Stateful<Div> {
537 div()
538 .w_2_3()
539 .h_full()
540 .id("prompt-editor")
541 .border_l_1()
542 .border_color(cx.theme().colors().border)
543 .bg(cx.theme().colors().editor_background)
544 .flex_none()
545 .min_w_64()
546 .children(self.active_prompt_id.and_then(|prompt_id| {
547 let prompt_metadata = self.store.metadata(prompt_id)?;
548 let editor = self.prompt_editors[&prompt_id].editor.clone();
549 Some(
550 v_flex()
551 .size_full()
552 .child(
553 h_flex()
554 .h(TitleBar::height(cx))
555 .px(Spacing::Large.rems(cx))
556 .justify_end()
557 .child(
558 h_flex()
559 .gap_4()
560 .child(
561 IconButton::new(
562 "toggle-default-prompt",
563 if prompt_metadata.default {
564 IconName::StarFilled
565 } else {
566 IconName::Star
567 },
568 )
569 .shape(IconButtonShape::Square)
570 .tooltip(move |cx| {
571 Tooltip::for_action(
572 if prompt_metadata.default {
573 "Remove from Default Prompt"
574 } else {
575 "Add to Default Prompt"
576 },
577 &ToggleDefaultPrompt,
578 cx,
579 )
580 })
581 .on_click(
582 |_, cx| {
583 cx.dispatch_action(Box::new(
584 ToggleDefaultPrompt,
585 ));
586 },
587 ),
588 )
589 .child(
590 IconButton::new("delete-prompt", IconName::Trash)
591 .shape(IconButtonShape::Square)
592 .tooltip(move |cx| {
593 Tooltip::for_action(
594 "Delete Prompt",
595 &DeletePrompt,
596 cx,
597 )
598 })
599 .on_click(|_, cx| {
600 cx.dispatch_action(Box::new(DeletePrompt));
601 }),
602 ),
603 ),
604 )
605 .child(div().flex_grow().p(Spacing::Large.rems(cx)).child(editor)),
606 )
607 }))
608 }
609}
610
611impl Render for PromptLibrary {
612 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
613 h_flex()
614 .id("prompt-manager")
615 .key_context("PromptLibrary")
616 .on_action(cx.listener(|this, &NewPrompt, cx| this.new_prompt(cx)))
617 .on_action(cx.listener(|this, &DeletePrompt, cx| this.delete_active_prompt(cx)))
618 .on_action(cx.listener(|this, &ToggleDefaultPrompt, cx| {
619 this.toggle_default_for_active_prompt(cx)
620 }))
621 .size_full()
622 .overflow_hidden()
623 .child(self.render_prompt_list(cx))
624 .child(self.render_active_prompt(cx))
625 }
626}
627
628#[derive(Clone, Debug, Serialize, Deserialize)]
629pub struct PromptMetadata {
630 pub id: PromptId,
631 pub title: Option<SharedString>,
632 pub default: bool,
633 pub saved_at: DateTime<Utc>,
634}
635
636#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
637pub struct PromptId(Uuid);
638
639impl PromptId {
640 pub fn new() -> PromptId {
641 PromptId(Uuid::new_v4())
642 }
643}
644
645pub struct PromptStore {
646 executor: BackgroundExecutor,
647 env: heed::Env,
648 bodies: Database<SerdeBincode<PromptId>, SerdeBincode<String>>,
649 metadata: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
650 metadata_cache: RwLock<MetadataCache>,
651 updates: (Arc<async_watch::Sender<()>>, async_watch::Receiver<()>),
652}
653
654#[derive(Default)]
655struct MetadataCache {
656 metadata: Vec<PromptMetadata>,
657 metadata_by_id: HashMap<PromptId, PromptMetadata>,
658}
659
660impl MetadataCache {
661 fn from_db(
662 db: Database<SerdeBincode<PromptId>, SerdeBincode<PromptMetadata>>,
663 txn: &RoTxn,
664 ) -> Result<Self> {
665 let mut cache = MetadataCache::default();
666 for result in db.iter(txn)? {
667 let (prompt_id, metadata) = result?;
668 cache.metadata.push(metadata.clone());
669 cache.metadata_by_id.insert(prompt_id, metadata);
670 }
671 cache
672 .metadata
673 .sort_unstable_by_key(|metadata| Reverse(metadata.saved_at));
674 Ok(cache)
675 }
676
677 fn insert(&mut self, metadata: PromptMetadata) {
678 self.metadata_by_id.insert(metadata.id, metadata.clone());
679 if let Some(old_metadata) = self.metadata.iter_mut().find(|m| m.id == metadata.id) {
680 *old_metadata = metadata;
681 } else {
682 self.metadata.push(metadata);
683 }
684 self.metadata.sort_by_key(|m| Reverse(m.saved_at));
685 }
686
687 fn remove(&mut self, id: PromptId) {
688 self.metadata.retain(|metadata| metadata.id != id);
689 self.metadata_by_id.remove(&id);
690 }
691}
692
693impl PromptStore {
694 pub fn global(cx: &AppContext) -> impl Future<Output = Result<Arc<Self>>> {
695 let store = GlobalPromptStore::global(cx).0.clone();
696 async move { store.await.map_err(|err| anyhow!(err)) }
697 }
698
699 pub fn new(db_path: PathBuf, executor: BackgroundExecutor) -> Task<Result<Self>> {
700 executor.spawn({
701 let executor = executor.clone();
702 async move {
703 std::fs::create_dir_all(&db_path)?;
704
705 let db_env = unsafe {
706 heed::EnvOpenOptions::new()
707 .map_size(1024 * 1024 * 1024) // 1GB
708 .max_dbs(2) // bodies and metadata
709 .open(db_path)?
710 };
711
712 let mut txn = db_env.write_txn()?;
713 let bodies = db_env.create_database(&mut txn, Some("bodies"))?;
714 let metadata = db_env.create_database(&mut txn, Some("metadata"))?;
715 let metadata_cache = MetadataCache::from_db(metadata, &txn)?;
716 txn.commit()?;
717
718 let (updates_tx, updates_rx) = async_watch::channel(());
719 Ok(PromptStore {
720 executor,
721 env: db_env,
722 bodies,
723 metadata,
724 metadata_cache: RwLock::new(metadata_cache),
725 updates: (Arc::new(updates_tx), updates_rx),
726 })
727 }
728 })
729 }
730
731 pub fn updates(&self) -> async_watch::Receiver<()> {
732 self.updates.1.clone()
733 }
734
735 pub fn load(&self, id: PromptId) -> Task<Result<String>> {
736 let env = self.env.clone();
737 let bodies = self.bodies;
738 self.executor.spawn(async move {
739 let txn = env.read_txn()?;
740 bodies
741 .get(&txn, &id)?
742 .ok_or_else(|| anyhow!("prompt not found"))
743 })
744 }
745
746 pub fn load_default(&self) -> Task<Result<Vec<(PromptMetadata, String)>>> {
747 let default_metadatas = self
748 .metadata_cache
749 .read()
750 .metadata
751 .iter()
752 .filter(|metadata| metadata.default)
753 .cloned()
754 .collect::<Vec<_>>();
755 let env = self.env.clone();
756 let bodies = self.bodies;
757 self.executor.spawn(async move {
758 let txn = env.read_txn()?;
759
760 let mut default_prompts = Vec::new();
761 for metadata in default_metadatas {
762 if let Some(body) = bodies.get(&txn, &metadata.id)? {
763 if !body.is_empty() {
764 default_prompts.push((metadata, body));
765 }
766 }
767 }
768
769 default_prompts.sort_unstable_by_key(|(metadata, _)| metadata.saved_at);
770 Ok(default_prompts)
771 })
772 }
773
774 pub fn delete(&self, id: PromptId) -> Task<Result<()>> {
775 self.metadata_cache.write().remove(id);
776
777 let db_connection = self.env.clone();
778 let bodies = self.bodies;
779 let metadata = self.metadata;
780
781 self.executor.spawn(async move {
782 let mut txn = db_connection.write_txn()?;
783
784 metadata.delete(&mut txn, &id)?;
785 bodies.delete(&mut txn, &id)?;
786
787 txn.commit()?;
788 Ok(())
789 })
790 }
791
792 fn metadata(&self, id: PromptId) -> Option<PromptMetadata> {
793 self.metadata_cache.read().metadata_by_id.get(&id).cloned()
794 }
795
796 pub fn id_for_title(&self, title: &str) -> Option<PromptId> {
797 let metadata_cache = self.metadata_cache.read();
798 let metadata = metadata_cache
799 .metadata
800 .iter()
801 .find(|metadata| metadata.title.as_ref().map(|title| &***title) == Some(title))?;
802 Some(metadata.id)
803 }
804
805 pub fn search(&self, query: String) -> Task<Vec<PromptMetadata>> {
806 let cached_metadata = self.metadata_cache.read().metadata.clone();
807 let executor = self.executor.clone();
808 self.executor.spawn(async move {
809 if query.is_empty() {
810 cached_metadata
811 } else {
812 let candidates = cached_metadata
813 .iter()
814 .enumerate()
815 .filter_map(|(ix, metadata)| {
816 Some(StringMatchCandidate::new(
817 ix,
818 metadata.title.as_ref()?.to_string(),
819 ))
820 })
821 .collect::<Vec<_>>();
822 let matches = fuzzy::match_strings(
823 &candidates,
824 &query,
825 false,
826 100,
827 &AtomicBool::default(),
828 executor,
829 )
830 .await;
831 matches
832 .into_iter()
833 .map(|mat| cached_metadata[mat.candidate_id].clone())
834 .collect()
835 }
836 })
837 }
838
839 fn save(
840 &self,
841 id: PromptId,
842 title: Option<SharedString>,
843 default: bool,
844 body: Rope,
845 ) -> Task<Result<()>> {
846 let prompt_metadata = PromptMetadata {
847 id,
848 title,
849 default,
850 saved_at: Utc::now(),
851 };
852 self.metadata_cache.write().insert(prompt_metadata.clone());
853
854 let db_connection = self.env.clone();
855 let bodies = self.bodies;
856 let metadata = self.metadata;
857 let updates = self.updates.0.clone();
858
859 self.executor.spawn(async move {
860 let mut txn = db_connection.write_txn()?;
861
862 metadata.put(&mut txn, &id, &prompt_metadata)?;
863 bodies.put(&mut txn, &id, &body.to_string())?;
864
865 txn.commit()?;
866 updates.send(()).ok();
867
868 Ok(())
869 })
870 }
871
872 fn save_metadata(
873 &self,
874 id: PromptId,
875 title: Option<SharedString>,
876 default: bool,
877 ) -> Task<Result<()>> {
878 let prompt_metadata = PromptMetadata {
879 id,
880 title,
881 default,
882 saved_at: Utc::now(),
883 };
884 self.metadata_cache.write().insert(prompt_metadata.clone());
885
886 let db_connection = self.env.clone();
887 let metadata = self.metadata;
888 let updates = self.updates.0.clone();
889
890 self.executor.spawn(async move {
891 let mut txn = db_connection.write_txn()?;
892 metadata.put(&mut txn, &id, &prompt_metadata)?;
893 txn.commit()?;
894 updates.send(()).ok();
895
896 Ok(())
897 })
898 }
899
900 fn most_recently_saved(&self) -> Option<PromptId> {
901 self.metadata_cache
902 .read()
903 .metadata
904 .first()
905 .map(|metadata| metadata.id)
906 }
907}
908
909/// Wraps a shared future to a prompt store so it can be assigned as a context global.
910pub struct GlobalPromptStore(
911 Shared<BoxFuture<'static, Result<Arc<PromptStore>, Arc<anyhow::Error>>>>,
912);
913
914impl Global for GlobalPromptStore {}
915
916fn title_from_body(body: impl IntoIterator<Item = char>) -> Option<SharedString> {
917 let mut chars = body.into_iter().take_while(|c| *c != '\n').peekable();
918
919 let mut level = 0;
920 while let Some('#') = chars.peek() {
921 level += 1;
922 chars.next();
923 }
924
925 if level > 0 {
926 let title = chars.collect::<String>().trim().to_string();
927 if title.is_empty() {
928 None
929 } else {
930 Some(title.into())
931 }
932 } else {
933 None
934 }
935}