prompt_library.rs

  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}