1use anyhow::Result;
2use collections::{HashMap, HashSet};
3use editor::SelectionEffects;
4use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
5use gpui::{
6 App, Bounds, DEFAULT_ADDITIONAL_WINDOW_SIZE, Entity, EventEmitter, Focusable, PromptLevel,
7 Subscription, Task, TextStyle, Tiling, TitlebarOptions, WindowBounds, WindowHandle,
8 WindowOptions, actions, point, size, transparent_black,
9};
10use language::{Buffer, LanguageRegistry, language_settings::SoftWrap};
11use language_model::{ConfiguredModel, LanguageModelRegistry};
12use picker::{Picker, PickerDelegate};
13use platform_title_bar::PlatformTitleBar;
14use release_channel::ReleaseChannel;
15use rope::Rope;
16use settings::{ActionSequence, Settings};
17use std::sync::Arc;
18use std::sync::atomic::AtomicBool;
19use std::time::Duration;
20use theme_settings::ThemeSettings;
21use ui::{Divider, ListItem, ListItemSpacing, ListSubHeader, Tooltip, prelude::*};
22use ui_input::ErasedEditor;
23use util::{ResultExt, TryFutureExt};
24use workspace::{MultiWorkspace, Workspace, WorkspaceSettings, client_side_decorations};
25use zed_actions::assistant::InlineAssist;
26
27use prompt_store::*;
28
29pub fn init(cx: &mut App) {
30 prompt_store::init(cx);
31}
32
33actions!(
34 rules_library,
35 [
36 /// Creates a new rule in the rules library.
37 NewRule,
38 /// Deletes the selected rule.
39 DeleteRule,
40 /// Duplicates the selected rule.
41 DuplicateRule,
42 /// Toggles whether the selected rule is a default rule.
43 ToggleDefaultRule,
44 /// Restores a built-in rule to its default content.
45 RestoreDefaultContent
46 ]
47);
48
49pub trait InlineAssistDelegate {
50 fn assist(
51 &self,
52 prompt_editor: &Entity<Editor>,
53 initial_prompt: Option<String>,
54 window: &mut Window,
55 cx: &mut Context<RulesLibrary>,
56 );
57
58 /// Returns whether the Agent panel was focused.
59 fn focus_agent_panel(
60 &self,
61 workspace: &mut Workspace,
62 window: &mut Window,
63 cx: &mut Context<Workspace>,
64 ) -> bool;
65}
66
67/// This function opens a new rules library window if one doesn't exist already.
68/// If one exists, it brings it to the foreground.
69///
70/// Note that, when opening a new window, this waits for the PromptStore to be
71/// initialized. If it was initialized successfully, it returns a window handle
72/// to a rules library.
73pub fn open_rules_library(
74 language_registry: Arc<LanguageRegistry>,
75 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
76 prompt_to_select: Option<PromptId>,
77 cx: &mut App,
78) -> Task<Result<WindowHandle<RulesLibrary>>> {
79 let store = PromptStore::global(cx);
80 cx.spawn(async move |cx| {
81 // We query windows in spawn so that all windows have been returned to GPUI
82 let existing_window = cx.update(|cx| {
83 let existing_window = cx
84 .windows()
85 .into_iter()
86 .find_map(|window| window.downcast::<RulesLibrary>());
87 if let Some(existing_window) = existing_window {
88 existing_window
89 .update(cx, |rules_library, window, cx| {
90 if let Some(prompt_to_select) = prompt_to_select {
91 rules_library.load_rule(prompt_to_select, true, window, cx);
92 }
93 window.activate_window()
94 })
95 .ok();
96
97 Some(existing_window)
98 } else {
99 None
100 }
101 });
102
103 if let Some(existing_window) = existing_window {
104 return Ok(existing_window);
105 }
106
107 let store = store.await?;
108 cx.update(|cx| {
109 let app_id = ReleaseChannel::global(cx).app_id();
110 let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
111 let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
112 Ok(val) if val == "server" => gpui::WindowDecorations::Server,
113 Ok(val) if val == "client" => gpui::WindowDecorations::Client,
114 _ => match WorkspaceSettings::get_global(cx).window_decorations {
115 settings::WindowDecorations::Server => gpui::WindowDecorations::Server,
116 settings::WindowDecorations::Client => gpui::WindowDecorations::Client,
117 },
118 };
119 cx.open_window(
120 WindowOptions {
121 titlebar: Some(TitlebarOptions {
122 title: Some("Rules Library".into()),
123 appears_transparent: true,
124 traffic_light_position: Some(point(px(12.0), px(12.0))),
125 }),
126 app_id: Some(app_id.to_owned()),
127 window_bounds: Some(WindowBounds::Windowed(bounds)),
128 window_background: cx.theme().window_background_appearance(),
129 window_decorations: Some(window_decorations),
130 window_min_size: Some(DEFAULT_ADDITIONAL_WINDOW_SIZE),
131 kind: gpui::WindowKind::Floating,
132 ..Default::default()
133 },
134 |window, cx| {
135 cx.new(|cx| {
136 RulesLibrary::new(
137 store,
138 language_registry,
139 inline_assist_delegate,
140 prompt_to_select,
141 window,
142 cx,
143 )
144 })
145 },
146 )
147 })
148 })
149}
150
151pub struct RulesLibrary {
152 title_bar: Option<Entity<PlatformTitleBar>>,
153 store: Entity<PromptStore>,
154 language_registry: Arc<LanguageRegistry>,
155 rule_editors: HashMap<PromptId, RuleEditor>,
156 active_rule_id: Option<PromptId>,
157 picker: Entity<Picker<RulePickerDelegate>>,
158 pending_load: Task<()>,
159 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
160 _subscriptions: Vec<Subscription>,
161}
162
163struct RuleEditor {
164 title_editor: Entity<Editor>,
165 body_editor: Entity<Editor>,
166 next_title_and_body_to_save: Option<(String, Rope)>,
167 pending_save: Option<Task<Option<()>>>,
168 _subscriptions: Vec<Subscription>,
169}
170
171enum RulePickerEntry {
172 Header(SharedString),
173 Rule(PromptMetadata),
174 Separator,
175}
176
177struct RulePickerDelegate {
178 store: Entity<PromptStore>,
179 selected_index: usize,
180 filtered_entries: Vec<RulePickerEntry>,
181}
182
183enum RulePickerEvent {
184 Selected { prompt_id: PromptId },
185 Confirmed { prompt_id: PromptId },
186 Deleted { prompt_id: PromptId },
187 ToggledDefault { prompt_id: PromptId },
188}
189
190impl EventEmitter<RulePickerEvent> for Picker<RulePickerDelegate> {}
191
192impl PickerDelegate for RulePickerDelegate {
193 type ListItem = AnyElement;
194
195 fn match_count(&self) -> usize {
196 self.filtered_entries.len()
197 }
198
199 fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option<SharedString> {
200 Some("No rules found matching your search.".into())
201 }
202
203 fn selected_index(&self) -> usize {
204 self.selected_index
205 }
206
207 fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
208 self.selected_index = ix.min(self.filtered_entries.len().saturating_sub(1));
209
210 if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) {
211 cx.emit(RulePickerEvent::Selected { prompt_id: rule.id });
212 }
213
214 cx.notify();
215 }
216
217 fn can_select(&self, ix: usize, _: &mut Window, _: &mut Context<Picker<Self>>) -> bool {
218 match self.filtered_entries.get(ix) {
219 Some(RulePickerEntry::Rule(_)) => true,
220 Some(RulePickerEntry::Header(_)) | Some(RulePickerEntry::Separator) | None => false,
221 }
222 }
223
224 fn select_on_hover(&self) -> bool {
225 false
226 }
227
228 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
229 "Search…".into()
230 }
231
232 fn update_matches(
233 &mut self,
234 query: String,
235 window: &mut Window,
236 cx: &mut Context<Picker<Self>>,
237 ) -> Task<()> {
238 let cancellation_flag = Arc::new(AtomicBool::default());
239 let search = self.store.read(cx).search(query, cancellation_flag, cx);
240
241 let prev_prompt_id = self
242 .filtered_entries
243 .get(self.selected_index)
244 .and_then(|entry| {
245 if let RulePickerEntry::Rule(rule) = entry {
246 Some(rule.id)
247 } else {
248 None
249 }
250 });
251
252 cx.spawn_in(window, async move |this, cx| {
253 let (filtered_entries, selected_index) = cx
254 .background_spawn(async move {
255 let matches = search.await;
256
257 let (built_in_rules, user_rules): (Vec<_>, Vec<_>) =
258 matches.into_iter().partition(|rule| rule.id.is_built_in());
259 let (default_rules, other_rules): (Vec<_>, Vec<_>) =
260 user_rules.into_iter().partition(|rule| rule.default);
261
262 let mut filtered_entries = Vec::new();
263
264 if !built_in_rules.is_empty() {
265 filtered_entries.push(RulePickerEntry::Header("Built-in Rules".into()));
266
267 for rule in built_in_rules {
268 filtered_entries.push(RulePickerEntry::Rule(rule));
269 }
270
271 filtered_entries.push(RulePickerEntry::Separator);
272 }
273
274 if !default_rules.is_empty() {
275 filtered_entries.push(RulePickerEntry::Header("Default Rules".into()));
276
277 for rule in default_rules {
278 filtered_entries.push(RulePickerEntry::Rule(rule));
279 }
280
281 filtered_entries.push(RulePickerEntry::Separator);
282 }
283
284 for rule in other_rules {
285 filtered_entries.push(RulePickerEntry::Rule(rule));
286 }
287
288 let selected_index = prev_prompt_id
289 .and_then(|prev_prompt_id| {
290 filtered_entries.iter().position(|entry| {
291 if let RulePickerEntry::Rule(rule) = entry {
292 rule.id == prev_prompt_id
293 } else {
294 false
295 }
296 })
297 })
298 .unwrap_or_else(|| {
299 filtered_entries
300 .iter()
301 .position(|entry| matches!(entry, RulePickerEntry::Rule(_)))
302 .unwrap_or(0)
303 });
304
305 (filtered_entries, selected_index)
306 })
307 .await;
308
309 this.update_in(cx, |this, window, cx| {
310 this.delegate.filtered_entries = filtered_entries;
311 this.set_selected_index(
312 selected_index,
313 Some(picker::Direction::Down),
314 true,
315 window,
316 cx,
317 );
318 cx.notify();
319 })
320 .ok();
321 })
322 }
323
324 fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
325 if let Some(RulePickerEntry::Rule(rule)) = self.filtered_entries.get(self.selected_index) {
326 cx.emit(RulePickerEvent::Confirmed { prompt_id: rule.id });
327 }
328 }
329
330 fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
331
332 fn render_match(
333 &self,
334 ix: usize,
335 selected: bool,
336 _: &mut Window,
337 cx: &mut Context<Picker<Self>>,
338 ) -> Option<Self::ListItem> {
339 match self.filtered_entries.get(ix)? {
340 RulePickerEntry::Header(title) => {
341 let tooltip_text = if title.as_ref() == "Built-in Rules" {
342 "Built-in rules are those included out of the box with Zed."
343 } else {
344 "Default Rules are attached by default with every new thread."
345 };
346
347 Some(
348 ListSubHeader::new(title.clone())
349 .end_slot(
350 IconButton::new("info", IconName::Info)
351 .style(ButtonStyle::Transparent)
352 .icon_size(IconSize::Small)
353 .icon_color(Color::Muted)
354 .tooltip(Tooltip::text(tooltip_text))
355 .into_any_element(),
356 )
357 .inset(true)
358 .into_any_element(),
359 )
360 }
361 RulePickerEntry::Separator => Some(
362 h_flex()
363 .py_1()
364 .child(Divider::horizontal())
365 .into_any_element(),
366 ),
367 RulePickerEntry::Rule(rule) => {
368 let default = rule.default;
369 let prompt_id = rule.id;
370
371 Some(
372 ListItem::new(ix)
373 .inset(true)
374 .spacing(ListItemSpacing::Sparse)
375 .toggle_state(selected)
376 .child(
377 Label::new(rule.title.clone().unwrap_or("Untitled".into()))
378 .truncate()
379 .mr_10(),
380 )
381 .end_slot::<IconButton>((default && !prompt_id.is_built_in()).then(|| {
382 IconButton::new("toggle-default-rule", IconName::Paperclip)
383 .toggle_state(true)
384 .icon_color(Color::Accent)
385 .icon_size(IconSize::Small)
386 .tooltip(Tooltip::text("Remove from Default Rules"))
387 .on_click(cx.listener(move |_, _, _, cx| {
388 cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
389 }))
390 }))
391 .when(!prompt_id.is_built_in(), |this| {
392 this.end_slot_on_hover(
393 h_flex()
394 .child(
395 IconButton::new("delete-rule", IconName::Trash)
396 .icon_color(Color::Muted)
397 .icon_size(IconSize::Small)
398 .tooltip(Tooltip::text("Delete Rule"))
399 .on_click(cx.listener(move |_, _, _, cx| {
400 cx.emit(RulePickerEvent::Deleted { prompt_id })
401 })),
402 )
403 .child(
404 IconButton::new("toggle-default-rule", IconName::Plus)
405 .selected_icon(IconName::Dash)
406 .toggle_state(default)
407 .icon_size(IconSize::Small)
408 .icon_color(if default {
409 Color::Accent
410 } else {
411 Color::Muted
412 })
413 .map(|this| {
414 if default {
415 this.tooltip(Tooltip::text(
416 "Remove from Default Rules",
417 ))
418 } else {
419 this.tooltip(move |_window, cx| {
420 Tooltip::with_meta(
421 "Add to Default Rules",
422 None,
423 "Always included in every thread.",
424 cx,
425 )
426 })
427 }
428 })
429 .on_click(cx.listener(move |_, _, _, cx| {
430 cx.emit(RulePickerEvent::ToggledDefault {
431 prompt_id,
432 })
433 })),
434 ),
435 )
436 })
437 .into_any_element(),
438 )
439 }
440 }
441 }
442
443 fn render_editor(
444 &self,
445 editor: &Arc<dyn ErasedEditor>,
446 _: &mut Window,
447 cx: &mut Context<Picker<Self>>,
448 ) -> Div {
449 let editor = editor.as_any().downcast_ref::<Entity<Editor>>().unwrap();
450
451 h_flex()
452 .py_1()
453 .px_1p5()
454 .mx_1()
455 .gap_1p5()
456 .rounded_sm()
457 .bg(cx.theme().colors().editor_background)
458 .border_1()
459 .border_color(cx.theme().colors().border)
460 .child(Icon::new(IconName::MagnifyingGlass).color(Color::Muted))
461 .child(editor.clone())
462 }
463}
464
465impl RulesLibrary {
466 fn new(
467 store: Entity<PromptStore>,
468 language_registry: Arc<LanguageRegistry>,
469 inline_assist_delegate: Box<dyn InlineAssistDelegate>,
470 rule_to_select: Option<PromptId>,
471 window: &mut Window,
472 cx: &mut Context<Self>,
473 ) -> Self {
474 let (_selected_index, _matches) = if let Some(rule_to_select) = rule_to_select {
475 let matches = store.read(cx).all_prompt_metadata();
476 let selected_index = matches
477 .iter()
478 .enumerate()
479 .find(|(_, metadata)| metadata.id == rule_to_select)
480 .map_or(0, |(ix, _)| ix);
481 (selected_index, matches)
482 } else {
483 (0, vec![])
484 };
485
486 let picker_delegate = RulePickerDelegate {
487 store: store.clone(),
488 selected_index: 0,
489 filtered_entries: Vec::new(),
490 };
491
492 let picker = cx.new(|cx| {
493 let picker = Picker::list(picker_delegate, window, cx)
494 .modal(false)
495 .max_height(None);
496 picker.focus(window, cx);
497 picker
498 });
499
500 Self {
501 title_bar: if !cfg!(target_os = "macos") {
502 Some(cx.new(|cx| PlatformTitleBar::new("rules-library-title-bar", cx)))
503 } else {
504 None
505 },
506 store,
507 language_registry,
508 rule_editors: HashMap::default(),
509 active_rule_id: None,
510 pending_load: Task::ready(()),
511 inline_assist_delegate,
512 _subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)],
513 picker,
514 }
515 }
516
517 fn handle_picker_event(
518 &mut self,
519 _: &Entity<Picker<RulePickerDelegate>>,
520 event: &RulePickerEvent,
521 window: &mut Window,
522 cx: &mut Context<Self>,
523 ) {
524 match event {
525 RulePickerEvent::Selected { prompt_id } => {
526 self.load_rule(*prompt_id, false, window, cx);
527 }
528 RulePickerEvent::Confirmed { prompt_id } => {
529 self.load_rule(*prompt_id, true, window, cx);
530 }
531 RulePickerEvent::ToggledDefault { prompt_id } => {
532 self.toggle_default_for_rule(*prompt_id, window, cx);
533 }
534 RulePickerEvent::Deleted { prompt_id } => {
535 self.delete_rule(*prompt_id, window, cx);
536 }
537 }
538 }
539
540 pub fn new_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
541 // If we already have an untitled rule, use that instead
542 // of creating a new one.
543 if let Some(metadata) = self.store.read(cx).first()
544 && metadata.title.is_none()
545 {
546 self.load_rule(metadata.id, true, window, cx);
547 return;
548 }
549
550 let prompt_id = PromptId::new();
551 let save = self.store.update(cx, |store, cx| {
552 store.save(prompt_id, None, false, "".into(), cx)
553 });
554 self.picker
555 .update(cx, |picker, cx| picker.refresh(window, cx));
556 cx.spawn_in(window, async move |this, cx| {
557 save.await?;
558 this.update_in(cx, |this, window, cx| {
559 this.load_rule(prompt_id, true, window, cx)
560 })
561 })
562 .detach_and_log_err(cx);
563 }
564
565 pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
566 const SAVE_THROTTLE: Duration = Duration::from_millis(500);
567
568 if !prompt_id.can_edit() {
569 return;
570 }
571
572 let rule_metadata = self.store.read(cx).metadata(prompt_id).unwrap();
573 let rule_editor = self.rule_editors.get_mut(&prompt_id).unwrap();
574 let title = rule_editor.title_editor.read(cx).text(cx);
575 let body = rule_editor.body_editor.update(cx, |editor, cx| {
576 editor
577 .buffer()
578 .read(cx)
579 .as_singleton()
580 .unwrap()
581 .read(cx)
582 .as_rope()
583 .clone()
584 });
585
586 let store = self.store.clone();
587 let executor = cx.background_executor().clone();
588
589 rule_editor.next_title_and_body_to_save = Some((title, body));
590 if rule_editor.pending_save.is_none() {
591 rule_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| {
592 async move {
593 loop {
594 let title_and_body = this.update(cx, |this, _| {
595 this.rule_editors
596 .get_mut(&prompt_id)?
597 .next_title_and_body_to_save
598 .take()
599 })?;
600
601 if let Some((title, body)) = title_and_body {
602 let title = if title.trim().is_empty() {
603 None
604 } else {
605 Some(SharedString::from(title))
606 };
607 cx.update(|_window, cx| {
608 store.update(cx, |store, cx| {
609 store.save(prompt_id, title, rule_metadata.default, body, cx)
610 })
611 })?
612 .await
613 .log_err();
614 this.update_in(cx, |this, window, cx| {
615 this.picker
616 .update(cx, |picker, cx| picker.refresh(window, cx));
617 cx.notify();
618 })?;
619
620 executor.timer(SAVE_THROTTLE).await;
621 } else {
622 break;
623 }
624 }
625
626 this.update(cx, |this, _cx| {
627 if let Some(rule_editor) = this.rule_editors.get_mut(&prompt_id) {
628 rule_editor.pending_save = None;
629 }
630 })
631 }
632 .log_err()
633 .await
634 }));
635 }
636 }
637
638 pub fn delete_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
639 if let Some(active_rule_id) = self.active_rule_id {
640 self.delete_rule(active_rule_id, window, cx);
641 }
642 }
643
644 pub fn duplicate_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
645 if let Some(active_rule_id) = self.active_rule_id {
646 self.duplicate_rule(active_rule_id, window, cx);
647 }
648 }
649
650 pub fn toggle_default_for_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
651 if let Some(active_rule_id) = self.active_rule_id {
652 self.toggle_default_for_rule(active_rule_id, window, cx);
653 }
654 }
655
656 pub fn restore_default_content_for_active_rule(
657 &mut self,
658 window: &mut Window,
659 cx: &mut Context<Self>,
660 ) {
661 if let Some(active_rule_id) = self.active_rule_id {
662 self.restore_default_content(active_rule_id, window, cx);
663 }
664 }
665
666 pub fn restore_default_content(
667 &mut self,
668 prompt_id: PromptId,
669 window: &mut Window,
670 cx: &mut Context<Self>,
671 ) {
672 let Some(built_in) = prompt_id.as_built_in() else {
673 return;
674 };
675
676 if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
677 rule_editor.body_editor.update(cx, |editor, cx| {
678 editor.set_text(built_in.default_content(), window, cx);
679 });
680 }
681 }
682
683 pub fn toggle_default_for_rule(
684 &mut self,
685 prompt_id: PromptId,
686 window: &mut Window,
687 cx: &mut Context<Self>,
688 ) {
689 self.store.update(cx, move |store, cx| {
690 if let Some(rule_metadata) = store.metadata(prompt_id) {
691 store
692 .save_metadata(prompt_id, rule_metadata.title, !rule_metadata.default, cx)
693 .detach_and_log_err(cx);
694 }
695 });
696 self.picker
697 .update(cx, |picker, cx| picker.refresh(window, cx));
698 cx.notify();
699 }
700
701 pub fn load_rule(
702 &mut self,
703 prompt_id: PromptId,
704 focus: bool,
705 window: &mut Window,
706 cx: &mut Context<Self>,
707 ) {
708 if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
709 if focus {
710 rule_editor
711 .body_editor
712 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx));
713 }
714 self.set_active_rule(Some(prompt_id), window, cx);
715 } else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) {
716 let language_registry = self.language_registry.clone();
717 let rule = self.store.read(cx).load(prompt_id, cx);
718 self.pending_load = cx.spawn_in(window, async move |this, cx| {
719 let rule = rule.await;
720 let markdown = language_registry.language_for_name("Markdown").await;
721 this.update_in(cx, |this, window, cx| match rule {
722 Ok(rule) => {
723 let title_editor = cx.new(|cx| {
724 let mut editor = Editor::single_line(window, cx);
725 editor.set_placeholder_text("Untitled", window, cx);
726 editor.set_text(rule_metadata.title.unwrap_or_default(), window, cx);
727 if prompt_id.is_built_in() {
728 editor.set_read_only(true);
729 editor.set_show_edit_predictions(Some(false), window, cx);
730 }
731 editor
732 });
733 let body_editor = cx.new(|cx| {
734 let buffer = cx.new(|cx| {
735 let mut buffer = Buffer::local(rule, cx);
736 buffer.set_language(markdown.log_err(), cx);
737 buffer.set_language_registry(language_registry);
738 buffer
739 });
740
741 let mut editor = Editor::for_buffer(buffer, None, window, cx);
742 if !prompt_id.can_edit() {
743 editor.set_read_only(true);
744 editor.set_show_edit_predictions(Some(false), window, cx);
745 }
746 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
747 editor.set_show_gutter(false, cx);
748 editor.set_show_wrap_guides(false, cx);
749 editor.set_show_indent_guides(false, cx);
750 editor.set_use_modal_editing(true);
751 editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
752 if focus {
753 window.focus(&editor.focus_handle(cx), cx);
754 }
755 editor
756 });
757 let _subscriptions = vec![
758 cx.subscribe_in(
759 &title_editor,
760 window,
761 move |this, editor, event, window, cx| {
762 this.handle_rule_title_editor_event(
763 prompt_id, editor, event, window, cx,
764 )
765 },
766 ),
767 cx.subscribe_in(
768 &body_editor,
769 window,
770 move |this, editor, event, window, cx| {
771 this.handle_rule_body_editor_event(
772 prompt_id, editor, event, window, cx,
773 )
774 },
775 ),
776 ];
777 this.rule_editors.insert(
778 prompt_id,
779 RuleEditor {
780 title_editor,
781 body_editor,
782 next_title_and_body_to_save: None,
783 pending_save: None,
784 _subscriptions,
785 },
786 );
787 this.set_active_rule(Some(prompt_id), window, cx);
788 }
789 Err(error) => {
790 // TODO: we should show the error in the UI.
791 log::error!("error while loading rule: {:?}", error);
792 }
793 })
794 .ok();
795 });
796 }
797 }
798
799 fn set_active_rule(
800 &mut self,
801 prompt_id: Option<PromptId>,
802 window: &mut Window,
803 cx: &mut Context<Self>,
804 ) {
805 self.active_rule_id = prompt_id;
806 self.picker.update(cx, |picker, cx| {
807 if let Some(prompt_id) = prompt_id {
808 if picker
809 .delegate
810 .filtered_entries
811 .get(picker.delegate.selected_index())
812 .is_none_or(|old_selected_prompt| {
813 if let RulePickerEntry::Rule(rule) = old_selected_prompt {
814 rule.id != prompt_id
815 } else {
816 true
817 }
818 })
819 && let Some(ix) = picker.delegate.filtered_entries.iter().position(|mat| {
820 if let RulePickerEntry::Rule(rule) = mat {
821 rule.id == prompt_id
822 } else {
823 false
824 }
825 })
826 {
827 picker.set_selected_index(ix, None, true, window, cx);
828 }
829 } else {
830 picker.focus(window, cx);
831 }
832 });
833 cx.notify();
834 }
835
836 pub fn delete_rule(
837 &mut self,
838 prompt_id: PromptId,
839 window: &mut Window,
840 cx: &mut Context<Self>,
841 ) {
842 if let Some(metadata) = self.store.read(cx).metadata(prompt_id) {
843 let confirmation = window.prompt(
844 PromptLevel::Warning,
845 &format!(
846 "Are you sure you want to delete {}",
847 metadata.title.unwrap_or("Untitled".into())
848 ),
849 None,
850 &["Delete", "Cancel"],
851 cx,
852 );
853
854 cx.spawn_in(window, async move |this, cx| {
855 if confirmation.await.ok() == Some(0) {
856 this.update_in(cx, |this, window, cx| {
857 if this.active_rule_id == Some(prompt_id) {
858 this.set_active_rule(None, window, cx);
859 }
860 this.rule_editors.remove(&prompt_id);
861 this.store
862 .update(cx, |store, cx| store.delete(prompt_id, cx))
863 .detach_and_log_err(cx);
864 this.picker
865 .update(cx, |picker, cx| picker.refresh(window, cx));
866 cx.notify();
867 })?;
868 }
869 anyhow::Ok(())
870 })
871 .detach_and_log_err(cx);
872 }
873 }
874
875 pub fn duplicate_rule(
876 &mut self,
877 prompt_id: PromptId,
878 window: &mut Window,
879 cx: &mut Context<Self>,
880 ) {
881 if let Some(rule) = self.rule_editors.get(&prompt_id) {
882 const DUPLICATE_SUFFIX: &str = " copy";
883 let title_to_duplicate = rule.title_editor.read(cx).text(cx);
884 let existing_titles = self
885 .rule_editors
886 .iter()
887 .filter(|&(&id, _)| id != prompt_id)
888 .map(|(_, rule_editor)| rule_editor.title_editor.read(cx).text(cx))
889 .filter(|title| title.starts_with(&title_to_duplicate))
890 .collect::<HashSet<_>>();
891
892 let title = if existing_titles.is_empty() {
893 title_to_duplicate + DUPLICATE_SUFFIX
894 } else {
895 let mut i = 1;
896 loop {
897 let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
898 if !existing_titles.contains(&new_title) {
899 break new_title;
900 }
901 i += 1;
902 }
903 };
904
905 let new_id = PromptId::new();
906 let body = rule.body_editor.read(cx).text(cx);
907 let save = self.store.update(cx, |store, cx| {
908 store.save(new_id, Some(title.into()), false, body.into(), cx)
909 });
910 self.picker
911 .update(cx, |picker, cx| picker.refresh(window, cx));
912 cx.spawn_in(window, async move |this, cx| {
913 save.await?;
914 this.update_in(cx, |rules_library, window, cx| {
915 rules_library.load_rule(new_id, true, window, cx)
916 })
917 })
918 .detach_and_log_err(cx);
919 }
920 }
921
922 fn focus_active_rule(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
923 if let Some(active_rule) = self.active_rule_id {
924 self.rule_editors[&active_rule]
925 .body_editor
926 .update(cx, |editor, cx| window.focus(&editor.focus_handle(cx), cx));
927 cx.stop_propagation();
928 }
929 }
930
931 fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
932 self.picker
933 .update(cx, |picker, cx| picker.focus(window, cx));
934 }
935
936 pub fn inline_assist(
937 &mut self,
938 action: &InlineAssist,
939 window: &mut Window,
940 cx: &mut Context<Self>,
941 ) {
942 let Some(active_rule_id) = self.active_rule_id else {
943 cx.propagate();
944 return;
945 };
946
947 let rule_editor = &self.rule_editors[&active_rule_id].body_editor;
948 let Some(ConfiguredModel { provider, .. }) =
949 LanguageModelRegistry::read_global(cx).inline_assistant_model()
950 else {
951 return;
952 };
953
954 let initial_prompt = action.prompt.clone();
955 if provider.is_authenticated(cx) {
956 self.inline_assist_delegate
957 .assist(rule_editor, initial_prompt, window, cx);
958 } else {
959 for window in cx.windows() {
960 if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
961 let panel = multi_workspace
962 .update(cx, |multi_workspace, window, cx| {
963 window.activate_window();
964 multi_workspace.workspace().update(cx, |workspace, cx| {
965 self.inline_assist_delegate
966 .focus_agent_panel(workspace, window, cx)
967 })
968 })
969 .ok();
970 if panel == Some(true) {
971 return;
972 }
973 }
974 }
975 }
976 }
977
978 fn move_down_from_title(
979 &mut self,
980 _: &zed_actions::editor::MoveDown,
981 window: &mut Window,
982 cx: &mut Context<Self>,
983 ) {
984 if let Some(rule_id) = self.active_rule_id
985 && let Some(rule_editor) = self.rule_editors.get(&rule_id)
986 {
987 window.focus(&rule_editor.body_editor.focus_handle(cx), cx);
988 }
989 }
990
991 fn move_up_from_body(
992 &mut self,
993 _: &zed_actions::editor::MoveUp,
994 window: &mut Window,
995 cx: &mut Context<Self>,
996 ) {
997 if let Some(rule_id) = self.active_rule_id
998 && let Some(rule_editor) = self.rule_editors.get(&rule_id)
999 {
1000 window.focus(&rule_editor.title_editor.focus_handle(cx), cx);
1001 }
1002 }
1003
1004 fn handle_rule_title_editor_event(
1005 &mut self,
1006 prompt_id: PromptId,
1007 title_editor: &Entity<Editor>,
1008 event: &EditorEvent,
1009 window: &mut Window,
1010 cx: &mut Context<Self>,
1011 ) {
1012 match event {
1013 EditorEvent::BufferEdited => {
1014 self.save_rule(prompt_id, window, cx);
1015 }
1016 EditorEvent::Blurred => {
1017 title_editor.update(cx, |title_editor, cx| {
1018 title_editor.change_selections(
1019 SelectionEffects::no_scroll(),
1020 window,
1021 cx,
1022 |selections| {
1023 let cursor = selections.oldest_anchor().head();
1024 selections.select_anchor_ranges([cursor..cursor]);
1025 },
1026 );
1027 });
1028 }
1029 _ => {}
1030 }
1031 }
1032
1033 fn handle_rule_body_editor_event(
1034 &mut self,
1035 prompt_id: PromptId,
1036 body_editor: &Entity<Editor>,
1037 event: &EditorEvent,
1038 window: &mut Window,
1039 cx: &mut Context<Self>,
1040 ) {
1041 match event {
1042 EditorEvent::BufferEdited => {
1043 self.save_rule(prompt_id, window, cx);
1044 }
1045 EditorEvent::Blurred => {
1046 body_editor.update(cx, |body_editor, cx| {
1047 body_editor.change_selections(
1048 SelectionEffects::no_scroll(),
1049 window,
1050 cx,
1051 |selections| {
1052 let cursor = selections.oldest_anchor().head();
1053 selections.select_anchor_ranges([cursor..cursor]);
1054 },
1055 );
1056 });
1057 }
1058 _ => {}
1059 }
1060 }
1061
1062 fn render_rule_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
1063 v_flex()
1064 .id("rule-list")
1065 .capture_action(cx.listener(Self::focus_active_rule))
1066 .px_1p5()
1067 .h_full()
1068 .w_64()
1069 .overflow_x_hidden()
1070 .bg(cx.theme().colors().panel_background)
1071 .map(|this| {
1072 if cfg!(target_os = "macos") {
1073 this.child(
1074 h_flex()
1075 .p(DynamicSpacing::Base04.rems(cx))
1076 .h_9()
1077 .w_full()
1078 .flex_none()
1079 .justify_end()
1080 .child(
1081 IconButton::new("new-rule", IconName::Plus)
1082 .tooltip(move |_window, cx| {
1083 Tooltip::for_action("New Rule", &NewRule, cx)
1084 })
1085 .on_click(|_, window, cx| {
1086 window.dispatch_action(Box::new(NewRule), cx);
1087 }),
1088 ),
1089 )
1090 } else {
1091 this.child(
1092 h_flex().p_1().w_full().child(
1093 Button::new("new-rule", "New Rule")
1094 .full_width()
1095 .style(ButtonStyle::Outlined)
1096 .start_icon(
1097 Icon::new(IconName::Plus)
1098 .size(IconSize::Small)
1099 .color(Color::Muted),
1100 )
1101 .on_click(|_, window, cx| {
1102 window.dispatch_action(Box::new(NewRule), cx);
1103 }),
1104 ),
1105 )
1106 }
1107 })
1108 .child(div().flex_grow().child(self.picker.clone()))
1109 }
1110
1111 fn render_active_rule_editor(
1112 &self,
1113 editor: &Entity<Editor>,
1114 read_only: bool,
1115 cx: &mut Context<Self>,
1116 ) -> impl IntoElement {
1117 let settings = ThemeSettings::get_global(cx);
1118 let text_color = if read_only {
1119 cx.theme().colors().text_muted
1120 } else {
1121 cx.theme().colors().text
1122 };
1123
1124 div()
1125 .w_full()
1126 .pl_1()
1127 .border_1()
1128 .border_color(transparent_black())
1129 .rounded_sm()
1130 .when(!read_only, |this| {
1131 this.group_hover("active-editor-header", |this| {
1132 this.border_color(cx.theme().colors().border_variant)
1133 })
1134 })
1135 .on_action(cx.listener(Self::move_down_from_title))
1136 .child(EditorElement::new(
1137 &editor,
1138 EditorStyle {
1139 background: cx.theme().system().transparent,
1140 local_player: cx.theme().players().local(),
1141 text: TextStyle {
1142 color: text_color,
1143 font_family: settings.ui_font.family.clone(),
1144 font_features: settings.ui_font.features.clone(),
1145 font_size: HeadlineSize::Medium.rems().into(),
1146 font_weight: settings.ui_font.weight,
1147 line_height: relative(settings.buffer_line_height.value()),
1148 ..Default::default()
1149 },
1150 scrollbar_width: Pixels::ZERO,
1151 syntax: cx.theme().syntax().clone(),
1152 status: cx.theme().status().clone(),
1153 inlay_hints_style: editor::make_inlay_hints_style(cx),
1154 edit_prediction_styles: editor::make_suggestion_styles(cx),
1155 ..EditorStyle::default()
1156 },
1157 ))
1158 }
1159
1160 fn render_duplicate_rule_button(&self) -> impl IntoElement {
1161 IconButton::new("duplicate-rule", IconName::BookCopy)
1162 .tooltip(move |_window, cx| Tooltip::for_action("Duplicate Rule", &DuplicateRule, cx))
1163 .on_click(|_, window, cx| {
1164 window.dispatch_action(Box::new(DuplicateRule), cx);
1165 })
1166 }
1167
1168 fn render_built_in_rule_controls(&self) -> impl IntoElement {
1169 h_flex()
1170 .gap_1()
1171 .child(self.render_duplicate_rule_button())
1172 .child(
1173 IconButton::new("restore-default", IconName::RotateCcw)
1174 .tooltip(move |_window, cx| {
1175 Tooltip::for_action(
1176 "Restore to Default Content",
1177 &RestoreDefaultContent,
1178 cx,
1179 )
1180 })
1181 .on_click(|_, window, cx| {
1182 window.dispatch_action(Box::new(RestoreDefaultContent), cx);
1183 }),
1184 )
1185 }
1186
1187 fn render_regular_rule_controls(&self, default: bool) -> impl IntoElement {
1188 h_flex()
1189 .gap_1()
1190 .child(
1191 IconButton::new("toggle-default-rule", IconName::Paperclip)
1192 .toggle_state(default)
1193 .when(default, |this| this.icon_color(Color::Accent))
1194 .map(|this| {
1195 if default {
1196 this.tooltip(Tooltip::text("Remove from Default Rules"))
1197 } else {
1198 this.tooltip(move |_window, cx| {
1199 Tooltip::with_meta(
1200 "Add to Default Rules",
1201 None,
1202 "Always included in every thread.",
1203 cx,
1204 )
1205 })
1206 }
1207 })
1208 .on_click(|_, window, cx| {
1209 window.dispatch_action(Box::new(ToggleDefaultRule), cx);
1210 }),
1211 )
1212 .child(self.render_duplicate_rule_button())
1213 .child(
1214 IconButton::new("delete-rule", IconName::Trash)
1215 .tooltip(move |_window, cx| Tooltip::for_action("Delete Rule", &DeleteRule, cx))
1216 .on_click(|_, window, cx| {
1217 window.dispatch_action(Box::new(DeleteRule), cx);
1218 }),
1219 )
1220 }
1221
1222 fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
1223 div()
1224 .id("rule-editor")
1225 .h_full()
1226 .flex_grow()
1227 .border_l_1()
1228 .border_color(cx.theme().colors().border)
1229 .bg(cx.theme().colors().editor_background)
1230 .children(self.active_rule_id.and_then(|prompt_id| {
1231 let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
1232 let rule_editor = &self.rule_editors[&prompt_id];
1233 let focus_handle = rule_editor.body_editor.focus_handle(cx);
1234 let built_in = prompt_id.is_built_in();
1235
1236 Some(
1237 v_flex()
1238 .id("rule-editor-inner")
1239 .size_full()
1240 .relative()
1241 .overflow_hidden()
1242 .on_click(cx.listener(move |_, _, window, cx| {
1243 window.focus(&focus_handle, cx);
1244 }))
1245 .child(
1246 h_flex()
1247 .group("active-editor-header")
1248 .h_12()
1249 .px_2()
1250 .gap_2()
1251 .justify_between()
1252 .child(self.render_active_rule_editor(
1253 &rule_editor.title_editor,
1254 built_in,
1255 cx,
1256 ))
1257 .child(h_flex().h_full().flex_shrink_0().map(|this| {
1258 if built_in {
1259 this.child(self.render_built_in_rule_controls())
1260 } else {
1261 this.child(
1262 self.render_regular_rule_controls(
1263 rule_metadata.default,
1264 ),
1265 )
1266 }
1267 })),
1268 )
1269 .child(
1270 div()
1271 .on_action(cx.listener(Self::focus_picker))
1272 .on_action(cx.listener(Self::inline_assist))
1273 .on_action(cx.listener(Self::move_up_from_body))
1274 .h_full()
1275 .flex_grow()
1276 .child(
1277 h_flex()
1278 .py_2()
1279 .pl_2p5()
1280 .h_full()
1281 .flex_1()
1282 .child(rule_editor.body_editor.clone()),
1283 ),
1284 ),
1285 )
1286 }))
1287 }
1288}
1289
1290impl Render for RulesLibrary {
1291 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1292 let ui_font = theme_settings::setup_ui_font(window, cx);
1293 let theme = cx.theme().clone();
1294
1295 client_side_decorations(
1296 v_flex()
1297 .id("rules-library")
1298 .key_context("RulesLibrary")
1299 .on_action(
1300 |action_sequence: &ActionSequence, window: &mut Window, cx: &mut App| {
1301 for action in &action_sequence.0 {
1302 window.dispatch_action(action.boxed_clone(), cx);
1303 }
1304 },
1305 )
1306 .on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
1307 .on_action(
1308 cx.listener(|this, &DeleteRule, window, cx| {
1309 this.delete_active_rule(window, cx)
1310 }),
1311 )
1312 .on_action(cx.listener(|this, &DuplicateRule, window, cx| {
1313 this.duplicate_active_rule(window, cx)
1314 }))
1315 .on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
1316 this.toggle_default_for_active_rule(window, cx)
1317 }))
1318 .on_action(cx.listener(|this, &RestoreDefaultContent, window, cx| {
1319 this.restore_default_content_for_active_rule(window, cx)
1320 }))
1321 .size_full()
1322 .overflow_hidden()
1323 .font(ui_font)
1324 .text_color(theme.colors().text)
1325 .children(self.title_bar.clone())
1326 .bg(theme.colors().background)
1327 .child(
1328 h_flex()
1329 .flex_1()
1330 .when(!cfg!(target_os = "macos"), |this| {
1331 this.border_t_1().border_color(cx.theme().colors().border)
1332 })
1333 .child(self.render_rule_list(cx))
1334 .child(self.render_active_rule(cx)),
1335 ),
1336 window,
1337 cx,
1338 Tiling::default(),
1339 )
1340 }
1341}