1use collections::HashMap;
2use editor::{Editor, EditorEvent};
3use fs::Fs;
4use gpui::{prelude::FluentBuilder, *};
5use language::{language_settings, Buffer, LanguageRegistry};
6use picker::{Picker, PickerDelegate};
7use std::sync::Arc;
8use ui::{prelude::*, IconButtonShape, Indicator, ListItem, ListItemSpacing, Tooltip};
9use util::{ResultExt, TryFutureExt};
10use workspace::ModalView;
11
12use crate::prompts::{PromptId, PromptLibrary, SortOrder, StaticPrompt, PROMPT_DEFAULT_TITLE};
13
14actions!(prompt_manager, [NewPrompt, SavePrompt]);
15
16pub struct PromptManager {
17 focus_handle: FocusHandle,
18 prompt_library: Arc<PromptLibrary>,
19 language_registry: Arc<LanguageRegistry>,
20 #[allow(dead_code)]
21 fs: Arc<dyn Fs>,
22 picker: View<Picker<PromptManagerDelegate>>,
23 prompt_editors: HashMap<PromptId, View<Editor>>,
24 active_prompt_id: Option<PromptId>,
25 last_new_prompt_id: Option<PromptId>,
26 _subscriptions: Vec<Subscription>,
27}
28
29impl PromptManager {
30 pub fn new(
31 prompt_library: Arc<PromptLibrary>,
32 language_registry: Arc<LanguageRegistry>,
33 fs: Arc<dyn Fs>,
34 cx: &mut ViewContext<Self>,
35 ) -> Self {
36 let prompt_manager = cx.view().downgrade();
37 let picker = cx.new_view(|cx| {
38 Picker::uniform_list(
39 PromptManagerDelegate {
40 prompt_manager,
41 matching_prompts: vec![],
42 matching_prompt_ids: vec![],
43 prompt_library: prompt_library.clone(),
44 selected_index: 0,
45 _subscriptions: vec![],
46 },
47 cx,
48 )
49 .max_height(rems(35.75))
50 .modal(false)
51 });
52
53 let focus_handle = picker.focus_handle(cx);
54
55 let subscriptions = vec![
56 // cx.on_focus_in(&focus_handle, Self::focus_in),
57 // cx.on_focus_out(&focus_handle, Self::focus_out),
58 ];
59
60 let mut manager = Self {
61 focus_handle,
62 prompt_library,
63 language_registry,
64 fs,
65 picker,
66 prompt_editors: HashMap::default(),
67 active_prompt_id: None,
68 last_new_prompt_id: None,
69 _subscriptions: subscriptions,
70 };
71
72 manager.active_prompt_id = manager.prompt_library.first_prompt_id();
73
74 manager
75 }
76
77 fn dispatch_context(&self, cx: &ViewContext<Self>) -> KeyContext {
78 let mut dispatch_context = KeyContext::new_with_defaults();
79 dispatch_context.add("PromptManager");
80
81 let identifier = match self.active_editor() {
82 Some(active_editor) if active_editor.focus_handle(cx).is_focused(cx) => "editing",
83 _ => "not_editing",
84 };
85
86 dispatch_context.add(identifier);
87 dispatch_context
88 }
89
90 pub fn new_prompt(&mut self, _: &NewPrompt, cx: &mut ViewContext<Self>) {
91 // TODO: Why doesn't this prevent making a new prompt if you
92 // move the picker selection/maybe unfocus the editor?
93
94 // Prevent making a new prompt if the last new prompt is still empty
95 //
96 // Instead, we'll focus the last new prompt
97 if let Some(last_new_prompt_id) = self.last_new_prompt_id() {
98 if let Some(last_new_prompt) = self.prompt_library.prompt_by_id(last_new_prompt_id) {
99 let normalized_body = last_new_prompt
100 .body()
101 .trim()
102 .replace(['\r', '\n'], "")
103 .to_string();
104
105 if last_new_prompt.title() == PROMPT_DEFAULT_TITLE && normalized_body.is_empty() {
106 self.set_editor_for_prompt(last_new_prompt_id, cx);
107 self.focus_active_editor(cx);
108 }
109 }
110 }
111
112 let prompt = self.prompt_library.new_prompt();
113 self.set_last_new_prompt_id(Some(prompt.id().to_owned()));
114
115 self.prompt_library.add_prompt(prompt.clone());
116
117 let id = *prompt.id();
118 self.picker.update(cx, |picker, _cx| {
119 let prompts = self
120 .prompt_library
121 .sorted_prompts(SortOrder::Alphabetical)
122 .clone()
123 .into_iter();
124
125 picker.delegate.prompt_library = self.prompt_library.clone();
126 picker.delegate.matching_prompts = prompts.clone().map(|(_, p)| Arc::new(p)).collect();
127 picker.delegate.matching_prompt_ids = prompts.map(|(id, _)| id).collect();
128 picker.delegate.selected_index = picker
129 .delegate
130 .matching_prompts
131 .iter()
132 .position(|p| p.id() == &id)
133 .unwrap_or(0);
134 });
135
136 self.active_prompt_id = Some(id);
137
138 cx.notify();
139 }
140
141 pub fn save_prompt(
142 &mut self,
143 fs: Arc<dyn Fs>,
144 prompt_id: PromptId,
145 new_content: String,
146 cx: &mut ViewContext<Self>,
147 ) -> Result<()> {
148 let library = self.prompt_library.clone();
149 if library.prompt_by_id(prompt_id).is_some() {
150 cx.spawn(|_, _| async move {
151 library
152 .save_prompt(prompt_id, Some(new_content), fs)
153 .log_err()
154 .await;
155 })
156 .detach();
157 cx.notify();
158 }
159
160 Ok(())
161 }
162
163 pub fn set_active_prompt(&mut self, prompt_id: Option<PromptId>, cx: &mut ViewContext<Self>) {
164 self.active_prompt_id = prompt_id;
165 cx.notify();
166 }
167
168 pub fn last_new_prompt_id(&self) -> Option<PromptId> {
169 self.last_new_prompt_id
170 }
171
172 pub fn set_last_new_prompt_id(&mut self, id: Option<PromptId>) {
173 self.last_new_prompt_id = id;
174 }
175
176 pub fn focus_active_editor(&self, cx: &mut ViewContext<Self>) {
177 if let Some(active_prompt_id) = self.active_prompt_id {
178 if let Some(editor) = self.prompt_editors.get(&active_prompt_id) {
179 let focus_handle = editor.focus_handle(cx);
180
181 cx.focus(&focus_handle)
182 }
183 }
184 }
185
186 pub fn active_editor(&self) -> Option<&View<Editor>> {
187 self.active_prompt_id
188 .and_then(|active_prompt_id| self.prompt_editors.get(&active_prompt_id))
189 }
190
191 fn set_editor_for_prompt(
192 &mut self,
193 prompt_id: PromptId,
194 cx: &mut ViewContext<Self>,
195 ) -> impl IntoElement {
196 let prompt_library = self.prompt_library.clone();
197
198 let editor_for_prompt = self.prompt_editors.entry(prompt_id).or_insert_with(|| {
199 cx.new_view(|cx| {
200 let text = if let Some(prompt) = prompt_library.prompt_by_id(prompt_id) {
201 prompt.content().to_owned()
202 } else {
203 "".to_string()
204 };
205
206 let buffer = cx.new_model(|cx| {
207 let mut buffer = Buffer::local(text, cx);
208 let markdown = self.language_registry.language_for_name("Markdown");
209 cx.spawn(|buffer, mut cx| async move {
210 if let Some(markdown) = markdown.await.log_err() {
211 _ = buffer.update(&mut cx, |buffer, cx| {
212 buffer.set_language(Some(markdown), cx);
213 });
214 }
215 })
216 .detach();
217 buffer.set_language_registry(self.language_registry.clone());
218 buffer
219 });
220 let mut editor = Editor::for_buffer(buffer, None, cx);
221 editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx);
222 editor.set_show_gutter(false, cx);
223 editor
224 })
225 });
226
227 editor_for_prompt.clone()
228 }
229
230 fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext<Self>) {
231 cx.emit(DismissEvent);
232 }
233
234 fn render_prompt_list(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
235 let picker = self.picker.clone();
236
237 v_flex()
238 .id("prompt-list")
239 .bg(cx.theme().colors().surface_background)
240 .h_full()
241 .w_1_3()
242 .overflow_hidden()
243 .child(
244 h_flex()
245 .bg(cx.theme().colors().background)
246 .p(Spacing::Small.rems(cx))
247 .border_b_1()
248 .border_color(cx.theme().colors().border)
249 .h(rems(1.75))
250 .w_full()
251 .flex_none()
252 .justify_between()
253 .child(Label::new("Prompt Library").size(LabelSize::Small))
254 .child(
255 IconButton::new("new-prompt", IconName::Plus)
256 .shape(IconButtonShape::Square)
257 .tooltip(move |cx| Tooltip::text("New Prompt", cx))
258 .on_click(|_, cx| {
259 cx.dispatch_action(NewPrompt.boxed_clone());
260 }),
261 ),
262 )
263 .child(
264 v_flex()
265 .h(rems(38.25))
266 .flex_grow()
267 .justify_start()
268 .child(picker),
269 )
270 }
271}
272
273impl Render for PromptManager {
274 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
275 let active_prompt_id = self.active_prompt_id;
276 let active_prompt = if let Some(active_prompt_id) = active_prompt_id {
277 self.prompt_library.clone().prompt_by_id(active_prompt_id)
278 } else {
279 None
280 };
281 let active_editor = self.active_editor().map(|editor| editor.clone());
282 let updated_content = if let Some(editor) = active_editor {
283 Some(editor.read(cx).text(cx))
284 } else {
285 None
286 };
287 let can_save = active_prompt_id.is_some() && updated_content.is_some();
288 let fs = self.fs.clone();
289
290 h_flex()
291 .id("prompt-manager")
292 .key_context(self.dispatch_context(cx))
293 .track_focus(&self.focus_handle)
294 .on_action(cx.listener(Self::dismiss))
295 .on_action(cx.listener(Self::new_prompt))
296 .elevation_3(cx)
297 .size_full()
298 .flex_none()
299 .w(rems(64.))
300 .h(rems(40.))
301 .overflow_hidden()
302 .child(self.render_prompt_list(cx))
303 .child(
304 div().w_2_3().h_full().child(
305 v_flex()
306 .id("prompt-editor")
307 .border_l_1()
308 .border_color(cx.theme().colors().border)
309 .bg(cx.theme().colors().editor_background)
310 .size_full()
311 .flex_none()
312 .min_w_64()
313 .h_full()
314 .overflow_hidden()
315 .child(
316 h_flex()
317 .bg(cx.theme().colors().background)
318 .p(Spacing::Small.rems(cx))
319 .border_b_1()
320 .border_color(cx.theme().colors().border)
321 .h_7()
322 .w_full()
323 .justify_between()
324 .child(
325 h_flex()
326 .gap(Spacing::XXLarge.rems(cx))
327 .child(if can_save {
328 IconButton::new("save", IconName::Save)
329 .shape(IconButtonShape::Square)
330 .tooltip(move |cx| Tooltip::text("Save Prompt", cx))
331 .on_click(cx.listener(move |this, _event, cx| {
332 if let Some(prompt_id) = active_prompt_id {
333 this.save_prompt(
334 fs.clone(),
335 prompt_id,
336 updated_content.clone().unwrap_or(
337 "TODO: make unreachable"
338 .to_string(),
339 ),
340 cx,
341 )
342 .log_err();
343 }
344 }))
345 } else {
346 IconButton::new("save", IconName::Save)
347 .shape(IconButtonShape::Square)
348 .disabled(true)
349 })
350 .when_some(active_prompt, |this, active_prompt| {
351 let path = active_prompt.path();
352
353 this.child(
354 IconButton::new("reveal", IconName::Reveal)
355 .shape(IconButtonShape::Square)
356 .disabled(path.is_none())
357 .tooltip(move |cx| {
358 Tooltip::text("Reveal in Finder", cx)
359 })
360 .on_click(cx.listener(move |_, _event, cx| {
361 if let Some(path) = path.clone() {
362 cx.reveal_path(&path);
363 }
364 })),
365 )
366 }),
367 )
368 .child(
369 IconButton::new("dismiss", IconName::Close)
370 .shape(IconButtonShape::Square)
371 .tooltip(move |cx| Tooltip::text("Close", cx))
372 .on_click(|_, cx| {
373 cx.dispatch_action(menu::Cancel.boxed_clone());
374 }),
375 ),
376 )
377 .when_some(active_prompt_id, |this, active_prompt_id| {
378 this.child(
379 h_flex()
380 .flex_1()
381 .w_full()
382 .py(Spacing::Large.rems(cx))
383 .px(Spacing::XLarge.rems(cx))
384 .child(self.set_editor_for_prompt(active_prompt_id, cx)),
385 )
386 }),
387 ),
388 )
389 }
390}
391
392impl EventEmitter<DismissEvent> for PromptManager {}
393impl EventEmitter<EditorEvent> for PromptManager {}
394
395impl ModalView for PromptManager {}
396
397impl FocusableView for PromptManager {
398 fn focus_handle(&self, _cx: &AppContext) -> gpui::FocusHandle {
399 self.focus_handle.clone()
400 }
401}
402
403pub struct PromptManagerDelegate {
404 prompt_manager: WeakView<PromptManager>,
405 matching_prompts: Vec<Arc<StaticPrompt>>,
406 matching_prompt_ids: Vec<PromptId>,
407 prompt_library: Arc<PromptLibrary>,
408 selected_index: usize,
409 _subscriptions: Vec<Subscription>,
410}
411
412impl PickerDelegate for PromptManagerDelegate {
413 type ListItem = ListItem;
414
415 fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
416 "Find a prompt…".into()
417 }
418
419 fn match_count(&self) -> usize {
420 self.matching_prompt_ids.len()
421 }
422
423 fn selected_index(&self) -> usize {
424 self.selected_index
425 }
426
427 fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext<Picker<Self>>) {
428 self.selected_index = ix;
429 }
430
431 fn selected_index_changed(
432 &self,
433 ix: usize,
434 _cx: &mut ViewContext<Picker<Self>>,
435 ) -> Option<Box<dyn Fn(&mut WindowContext) + 'static>> {
436 let prompt_id = self.matching_prompt_ids.get(ix).copied()?;
437 let prompt_manager = self.prompt_manager.upgrade()?;
438
439 Some(Box::new(move |cx| {
440 prompt_manager.update(cx, |manager, cx| {
441 manager.set_active_prompt(Some(prompt_id), cx);
442 })
443 }))
444 }
445
446 fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
447 let prompt_library = self.prompt_library.clone();
448 cx.spawn(|picker, mut cx| async move {
449 async {
450 let prompts = prompt_library.sorted_prompts(SortOrder::Alphabetical);
451 let matching_prompts = prompts
452 .into_iter()
453 .filter(|(_, prompt)| {
454 prompt
455 .content()
456 .to_lowercase()
457 .contains(&query.to_lowercase())
458 })
459 .collect::<Vec<_>>();
460 picker.update(&mut cx, |picker, cx| {
461 picker.delegate.matching_prompt_ids =
462 matching_prompts.iter().map(|(id, _)| *id).collect();
463 picker.delegate.matching_prompts = matching_prompts
464 .into_iter()
465 .map(|(_, prompt)| Arc::new(prompt))
466 .collect();
467 cx.notify();
468 })?;
469 anyhow::Ok(())
470 }
471 .log_err()
472 .await;
473 })
474 }
475
476 fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<Self>>) {
477 let prompt_manager = self.prompt_manager.upgrade().unwrap();
478 prompt_manager.update(cx, move |manager, cx| manager.focus_active_editor(cx));
479 }
480
481 fn should_dismiss(&self) -> bool {
482 false
483 }
484
485 fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
486 self.prompt_manager
487 .update(cx, |_, cx| {
488 cx.emit(DismissEvent);
489 })
490 .ok();
491 }
492
493 fn render_match(
494 &self,
495 ix: usize,
496 selected: bool,
497 _cx: &mut ViewContext<Picker<Self>>,
498 ) -> Option<Self::ListItem> {
499 let prompt = self.matching_prompts.get(ix)?;
500
501 let is_diry = self.prompt_library.is_dirty(prompt.id());
502
503 Some(
504 ListItem::new(ix)
505 .inset(true)
506 .spacing(ListItemSpacing::Sparse)
507 .selected(selected)
508 .child(Label::new(prompt.title()))
509 .end_slot(div().when(is_diry, |this| this.child(Indicator::dot()))),
510 )
511 }
512}