rules_library.rs

   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}