1348 lines
56 KiB
Rust
1348 lines
56 KiB
Rust
use anyhow::Result;
|
|
use collections::{HashMap, HashSet};
|
|
use editor::{CompletionProvider, SelectionEffects};
|
|
use editor::{CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle, actions::Tab};
|
|
use gpui::{
|
|
Action, App, Bounds, Entity, EventEmitter, Focusable, PromptLevel, Subscription, Task,
|
|
TextStyle, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions, actions, point, size,
|
|
transparent_black,
|
|
};
|
|
use language::{Buffer, LanguageRegistry, language_settings::SoftWrap};
|
|
use language_model::{
|
|
ConfiguredModel, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, Role,
|
|
};
|
|
use picker::{Picker, PickerDelegate};
|
|
use release_channel::ReleaseChannel;
|
|
use rope::Rope;
|
|
use settings::Settings;
|
|
use std::rc::Rc;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::AtomicBool;
|
|
use std::time::Duration;
|
|
use theme::ThemeSettings;
|
|
use title_bar::platform_title_bar::PlatformTitleBar;
|
|
use ui::{
|
|
Context, IconButtonShape, KeyBinding, ListItem, ListItemSpacing, ParentElement, Render,
|
|
SharedString, Styled, Tooltip, Window, div, prelude::*,
|
|
};
|
|
use util::{ResultExt, TryFutureExt};
|
|
use workspace::{Workspace, client_side_decorations};
|
|
use zed_actions::assistant::InlineAssist;
|
|
|
|
use prompt_store::*;
|
|
|
|
pub fn init(cx: &mut App) {
|
|
prompt_store::init(cx);
|
|
}
|
|
|
|
actions!(
|
|
rules_library,
|
|
[
|
|
/// Creates a new rule in the rules library.
|
|
NewRule,
|
|
/// Deletes the selected rule.
|
|
DeleteRule,
|
|
/// Duplicates the selected rule.
|
|
DuplicateRule,
|
|
/// Toggles whether the selected rule is a default rule.
|
|
ToggleDefaultRule
|
|
]
|
|
);
|
|
|
|
const BUILT_IN_TOOLTIP_TEXT: &'static str = concat!(
|
|
"This rule supports special functionality.\n",
|
|
"It's read-only, but you can remove it from your default rules."
|
|
);
|
|
|
|
pub trait InlineAssistDelegate {
|
|
fn assist(
|
|
&self,
|
|
prompt_editor: &Entity<Editor>,
|
|
initial_prompt: Option<String>,
|
|
window: &mut Window,
|
|
cx: &mut Context<RulesLibrary>,
|
|
);
|
|
|
|
/// Returns whether the Agent panel was focused.
|
|
fn focus_agent_panel(
|
|
&self,
|
|
workspace: &mut Workspace,
|
|
window: &mut Window,
|
|
cx: &mut Context<Workspace>,
|
|
) -> bool;
|
|
}
|
|
|
|
/// This function opens a new rules library window if one doesn't exist already.
|
|
/// If one exists, it brings it to the foreground.
|
|
///
|
|
/// Note that, when opening a new window, this waits for the PromptStore to be
|
|
/// initialized. If it was initialized successfully, it returns a window handle
|
|
/// to a rules library.
|
|
pub fn open_rules_library(
|
|
language_registry: Arc<LanguageRegistry>,
|
|
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
|
|
make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
|
|
prompt_to_select: Option<PromptId>,
|
|
cx: &mut App,
|
|
) -> Task<Result<WindowHandle<RulesLibrary>>> {
|
|
let store = PromptStore::global(cx);
|
|
cx.spawn(async move |cx| {
|
|
// We query windows in spawn so that all windows have been returned to GPUI
|
|
let existing_window = cx
|
|
.update(|cx| {
|
|
let existing_window = cx
|
|
.windows()
|
|
.into_iter()
|
|
.find_map(|window| window.downcast::<RulesLibrary>());
|
|
if let Some(existing_window) = existing_window {
|
|
existing_window
|
|
.update(cx, |rules_library, window, cx| {
|
|
if let Some(prompt_to_select) = prompt_to_select {
|
|
rules_library.load_rule(prompt_to_select, true, window, cx);
|
|
}
|
|
window.activate_window()
|
|
})
|
|
.ok();
|
|
|
|
Some(existing_window)
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
.ok()
|
|
.flatten();
|
|
|
|
if let Some(existing_window) = existing_window {
|
|
return Ok(existing_window);
|
|
}
|
|
|
|
let store = store.await?;
|
|
cx.update(|cx| {
|
|
let app_id = ReleaseChannel::global(cx).app_id();
|
|
let bounds = Bounds::centered(None, size(px(1024.0), px(768.0)), cx);
|
|
let window_decorations = match std::env::var("ZED_WINDOW_DECORATIONS") {
|
|
Ok(val) if val == "server" => gpui::WindowDecorations::Server,
|
|
Ok(val) if val == "client" => gpui::WindowDecorations::Client,
|
|
_ => gpui::WindowDecorations::Client,
|
|
};
|
|
cx.open_window(
|
|
WindowOptions {
|
|
titlebar: Some(TitlebarOptions {
|
|
title: Some("Rules Library".into()),
|
|
appears_transparent: true,
|
|
traffic_light_position: Some(point(px(9.0), px(9.0))),
|
|
}),
|
|
app_id: Some(app_id.to_owned()),
|
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
|
window_background: cx.theme().window_background_appearance(),
|
|
window_decorations: Some(window_decorations),
|
|
..Default::default()
|
|
},
|
|
|window, cx| {
|
|
cx.new(|cx| {
|
|
RulesLibrary::new(
|
|
store,
|
|
language_registry,
|
|
inline_assist_delegate,
|
|
make_completion_provider,
|
|
prompt_to_select,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
},
|
|
)
|
|
})?
|
|
})
|
|
}
|
|
|
|
pub struct RulesLibrary {
|
|
title_bar: Option<Entity<PlatformTitleBar>>,
|
|
store: Entity<PromptStore>,
|
|
language_registry: Arc<LanguageRegistry>,
|
|
rule_editors: HashMap<PromptId, RuleEditor>,
|
|
active_rule_id: Option<PromptId>,
|
|
picker: Entity<Picker<RulePickerDelegate>>,
|
|
pending_load: Task<()>,
|
|
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
|
|
make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
|
|
_subscriptions: Vec<Subscription>,
|
|
}
|
|
|
|
struct RuleEditor {
|
|
title_editor: Entity<Editor>,
|
|
body_editor: Entity<Editor>,
|
|
token_count: Option<u64>,
|
|
pending_token_count: Task<Option<()>>,
|
|
next_title_and_body_to_save: Option<(String, Rope)>,
|
|
pending_save: Option<Task<Option<()>>>,
|
|
_subscriptions: Vec<Subscription>,
|
|
}
|
|
|
|
struct RulePickerDelegate {
|
|
store: Entity<PromptStore>,
|
|
selected_index: usize,
|
|
matches: Vec<PromptMetadata>,
|
|
}
|
|
|
|
enum RulePickerEvent {
|
|
Selected { prompt_id: PromptId },
|
|
Confirmed { prompt_id: PromptId },
|
|
Deleted { prompt_id: PromptId },
|
|
ToggledDefault { prompt_id: PromptId },
|
|
}
|
|
|
|
impl EventEmitter<RulePickerEvent> for Picker<RulePickerDelegate> {}
|
|
|
|
impl PickerDelegate for RulePickerDelegate {
|
|
type ListItem = ListItem;
|
|
|
|
fn match_count(&self) -> usize {
|
|
self.matches.len()
|
|
}
|
|
|
|
fn no_matches_text(&self, _window: &mut Window, cx: &mut App) -> Option<SharedString> {
|
|
let text = if self.store.read(cx).prompt_count() == 0 {
|
|
"No rules.".into()
|
|
} else {
|
|
"No rules found matching your search.".into()
|
|
};
|
|
Some(text)
|
|
}
|
|
|
|
fn selected_index(&self) -> usize {
|
|
self.selected_index
|
|
}
|
|
|
|
fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
|
self.selected_index = ix;
|
|
if let Some(prompt) = self.matches.get(self.selected_index) {
|
|
cx.emit(RulePickerEvent::Selected {
|
|
prompt_id: prompt.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
|
|
"Search...".into()
|
|
}
|
|
|
|
fn update_matches(
|
|
&mut self,
|
|
query: String,
|
|
window: &mut Window,
|
|
cx: &mut Context<Picker<Self>>,
|
|
) -> Task<()> {
|
|
let cancellation_flag = Arc::new(AtomicBool::default());
|
|
let search = self.store.read(cx).search(query, cancellation_flag, cx);
|
|
let prev_prompt_id = self.matches.get(self.selected_index).map(|mat| mat.id);
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
let (matches, selected_index) = cx
|
|
.background_spawn(async move {
|
|
let matches = search.await;
|
|
|
|
let selected_index = prev_prompt_id
|
|
.and_then(|prev_prompt_id| {
|
|
matches.iter().position(|entry| entry.id == prev_prompt_id)
|
|
})
|
|
.unwrap_or(0);
|
|
(matches, selected_index)
|
|
})
|
|
.await;
|
|
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.delegate.matches = matches;
|
|
this.delegate.set_selected_index(selected_index, window, cx);
|
|
cx.notify();
|
|
})
|
|
.ok();
|
|
})
|
|
}
|
|
|
|
fn confirm(&mut self, _secondary: bool, _: &mut Window, cx: &mut Context<Picker<Self>>) {
|
|
if let Some(prompt) = self.matches.get(self.selected_index) {
|
|
cx.emit(RulePickerEvent::Confirmed {
|
|
prompt_id: prompt.id,
|
|
});
|
|
}
|
|
}
|
|
|
|
fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context<Picker<Self>>) {}
|
|
|
|
fn render_match(
|
|
&self,
|
|
ix: usize,
|
|
selected: bool,
|
|
_: &mut Window,
|
|
cx: &mut Context<Picker<Self>>,
|
|
) -> Option<Self::ListItem> {
|
|
let rule = self.matches.get(ix)?;
|
|
let default = rule.default;
|
|
let prompt_id = rule.id;
|
|
|
|
let element = ListItem::new(ix)
|
|
.inset(true)
|
|
.spacing(ListItemSpacing::Sparse)
|
|
.toggle_state(selected)
|
|
.child(
|
|
h_flex()
|
|
.h_5()
|
|
.line_height(relative(1.))
|
|
.child(Label::new(rule.title.clone().unwrap_or("Untitled".into()))),
|
|
)
|
|
.end_slot::<IconButton>(default.then(|| {
|
|
IconButton::new("toggle-default-rule", IconName::StarFilled)
|
|
.toggle_state(true)
|
|
.icon_color(Color::Accent)
|
|
.icon_size(IconSize::Small)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(Tooltip::text("Remove from Default Rules"))
|
|
.on_click(cx.listener(move |_, _, _, cx| {
|
|
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
|
|
}))
|
|
}))
|
|
.end_hover_slot(
|
|
h_flex()
|
|
.gap_1()
|
|
.child(if prompt_id.is_built_in() {
|
|
div()
|
|
.id("built-in-rule")
|
|
.child(Icon::new(IconName::FileLock).color(Color::Muted))
|
|
.tooltip(move |window, cx| {
|
|
Tooltip::with_meta(
|
|
"Built-in rule",
|
|
None,
|
|
BUILT_IN_TOOLTIP_TEXT,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.into_any()
|
|
} else {
|
|
IconButton::new("delete-rule", IconName::Trash)
|
|
.icon_color(Color::Muted)
|
|
.icon_size(IconSize::Small)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(Tooltip::text("Delete Rule"))
|
|
.on_click(cx.listener(move |_, _, _, cx| {
|
|
cx.emit(RulePickerEvent::Deleted { prompt_id })
|
|
}))
|
|
.into_any_element()
|
|
})
|
|
.child(
|
|
IconButton::new("toggle-default-rule", IconName::Star)
|
|
.toggle_state(default)
|
|
.selected_icon(IconName::StarFilled)
|
|
.icon_color(if default { Color::Accent } else { Color::Muted })
|
|
.icon_size(IconSize::Small)
|
|
.shape(IconButtonShape::Square)
|
|
.map(|this| {
|
|
if default {
|
|
this.tooltip(Tooltip::text("Remove from Default Rules"))
|
|
} else {
|
|
this.tooltip(move |window, cx| {
|
|
Tooltip::with_meta(
|
|
"Add to Default Rules",
|
|
None,
|
|
"Always included in every thread.",
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
}
|
|
})
|
|
.on_click(cx.listener(move |_, _, _, cx| {
|
|
cx.emit(RulePickerEvent::ToggledDefault { prompt_id })
|
|
})),
|
|
),
|
|
);
|
|
Some(element)
|
|
}
|
|
|
|
fn render_editor(
|
|
&self,
|
|
editor: &Entity<Editor>,
|
|
_: &mut Window,
|
|
cx: &mut Context<Picker<Self>>,
|
|
) -> Div {
|
|
h_flex()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.rounded_sm()
|
|
.overflow_hidden()
|
|
.flex_none()
|
|
.py_1()
|
|
.px_2()
|
|
.mx_1()
|
|
.child(editor.clone())
|
|
}
|
|
}
|
|
|
|
impl RulesLibrary {
|
|
fn new(
|
|
store: Entity<PromptStore>,
|
|
language_registry: Arc<LanguageRegistry>,
|
|
inline_assist_delegate: Box<dyn InlineAssistDelegate>,
|
|
make_completion_provider: Rc<dyn Fn() -> Rc<dyn CompletionProvider>>,
|
|
rule_to_select: Option<PromptId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let (selected_index, matches) = if let Some(rule_to_select) = rule_to_select {
|
|
let matches = store.read(cx).all_prompt_metadata();
|
|
let selected_index = matches
|
|
.iter()
|
|
.enumerate()
|
|
.find(|(_, metadata)| metadata.id == rule_to_select)
|
|
.map_or(0, |(ix, _)| ix);
|
|
(selected_index, matches)
|
|
} else {
|
|
(0, vec![])
|
|
};
|
|
|
|
let delegate = RulePickerDelegate {
|
|
store: store.clone(),
|
|
selected_index,
|
|
matches,
|
|
};
|
|
|
|
let picker = cx.new(|cx| {
|
|
let picker = Picker::uniform_list(delegate, window, cx)
|
|
.modal(false)
|
|
.max_height(None);
|
|
picker.focus(window, cx);
|
|
picker
|
|
});
|
|
Self {
|
|
title_bar: if !cfg!(target_os = "macos") {
|
|
Some(cx.new(|_| PlatformTitleBar::new("rules-library-title-bar")))
|
|
} else {
|
|
None
|
|
},
|
|
store: store.clone(),
|
|
language_registry,
|
|
rule_editors: HashMap::default(),
|
|
active_rule_id: None,
|
|
pending_load: Task::ready(()),
|
|
inline_assist_delegate,
|
|
make_completion_provider,
|
|
_subscriptions: vec![cx.subscribe_in(&picker, window, Self::handle_picker_event)],
|
|
picker,
|
|
}
|
|
}
|
|
|
|
fn handle_picker_event(
|
|
&mut self,
|
|
_: &Entity<Picker<RulePickerDelegate>>,
|
|
event: &RulePickerEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match event {
|
|
RulePickerEvent::Selected { prompt_id } => {
|
|
self.load_rule(*prompt_id, false, window, cx);
|
|
}
|
|
RulePickerEvent::Confirmed { prompt_id } => {
|
|
self.load_rule(*prompt_id, true, window, cx);
|
|
}
|
|
RulePickerEvent::ToggledDefault { prompt_id } => {
|
|
self.toggle_default_for_rule(*prompt_id, window, cx);
|
|
}
|
|
RulePickerEvent::Deleted { prompt_id } => {
|
|
self.delete_rule(*prompt_id, window, cx);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn new_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
// If we already have an untitled rule, use that instead
|
|
// of creating a new one.
|
|
if let Some(metadata) = self.store.read(cx).first() {
|
|
if metadata.title.is_none() {
|
|
self.load_rule(metadata.id, true, window, cx);
|
|
return;
|
|
}
|
|
}
|
|
|
|
let prompt_id = PromptId::new();
|
|
let save = self.store.update(cx, |store, cx| {
|
|
store.save(prompt_id, None, false, "".into(), cx)
|
|
});
|
|
self.picker
|
|
.update(cx, |picker, cx| picker.refresh(window, cx));
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
save.await?;
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.load_rule(prompt_id, true, window, cx)
|
|
})
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
|
|
pub fn save_rule(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
|
|
const SAVE_THROTTLE: Duration = Duration::from_millis(500);
|
|
|
|
if prompt_id.is_built_in() {
|
|
return;
|
|
}
|
|
|
|
let rule_metadata = self.store.read(cx).metadata(prompt_id).unwrap();
|
|
let rule_editor = self.rule_editors.get_mut(&prompt_id).unwrap();
|
|
let title = rule_editor.title_editor.read(cx).text(cx);
|
|
let body = rule_editor.body_editor.update(cx, |editor, cx| {
|
|
editor
|
|
.buffer()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.unwrap()
|
|
.read(cx)
|
|
.as_rope()
|
|
.clone()
|
|
});
|
|
|
|
let store = self.store.clone();
|
|
let executor = cx.background_executor().clone();
|
|
|
|
rule_editor.next_title_and_body_to_save = Some((title, body));
|
|
if rule_editor.pending_save.is_none() {
|
|
rule_editor.pending_save = Some(cx.spawn_in(window, async move |this, cx| {
|
|
async move {
|
|
loop {
|
|
let title_and_body = this.update(cx, |this, _| {
|
|
this.rule_editors
|
|
.get_mut(&prompt_id)?
|
|
.next_title_and_body_to_save
|
|
.take()
|
|
})?;
|
|
|
|
if let Some((title, body)) = title_and_body {
|
|
let title = if title.trim().is_empty() {
|
|
None
|
|
} else {
|
|
Some(SharedString::from(title))
|
|
};
|
|
cx.update(|_window, cx| {
|
|
store.update(cx, |store, cx| {
|
|
store.save(prompt_id, title, rule_metadata.default, body, cx)
|
|
})
|
|
})?
|
|
.await
|
|
.log_err();
|
|
this.update_in(cx, |this, window, cx| {
|
|
this.picker
|
|
.update(cx, |picker, cx| picker.refresh(window, cx));
|
|
cx.notify();
|
|
})?;
|
|
|
|
executor.timer(SAVE_THROTTLE).await;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.update(cx, |this, _cx| {
|
|
if let Some(rule_editor) = this.rule_editors.get_mut(&prompt_id) {
|
|
rule_editor.pending_save = None;
|
|
}
|
|
})
|
|
}
|
|
.log_err()
|
|
.await
|
|
}));
|
|
}
|
|
}
|
|
|
|
pub fn delete_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(active_rule_id) = self.active_rule_id {
|
|
self.delete_rule(active_rule_id, window, cx);
|
|
}
|
|
}
|
|
|
|
pub fn duplicate_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(active_rule_id) = self.active_rule_id {
|
|
self.duplicate_rule(active_rule_id, window, cx);
|
|
}
|
|
}
|
|
|
|
pub fn toggle_default_for_active_rule(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(active_rule_id) = self.active_rule_id {
|
|
self.toggle_default_for_rule(active_rule_id, window, cx);
|
|
}
|
|
}
|
|
|
|
pub fn toggle_default_for_rule(
|
|
&mut self,
|
|
prompt_id: PromptId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.store.update(cx, move |store, cx| {
|
|
if let Some(rule_metadata) = store.metadata(prompt_id) {
|
|
store
|
|
.save_metadata(prompt_id, rule_metadata.title, !rule_metadata.default, cx)
|
|
.detach_and_log_err(cx);
|
|
}
|
|
});
|
|
self.picker
|
|
.update(cx, |picker, cx| picker.refresh(window, cx));
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn load_rule(
|
|
&mut self,
|
|
prompt_id: PromptId,
|
|
focus: bool,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(rule_editor) = self.rule_editors.get(&prompt_id) {
|
|
if focus {
|
|
rule_editor
|
|
.body_editor
|
|
.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
|
|
}
|
|
self.set_active_rule(Some(prompt_id), window, cx);
|
|
} else if let Some(rule_metadata) = self.store.read(cx).metadata(prompt_id) {
|
|
let language_registry = self.language_registry.clone();
|
|
let rule = self.store.read(cx).load(prompt_id, cx);
|
|
let make_completion_provider = self.make_completion_provider.clone();
|
|
self.pending_load = cx.spawn_in(window, async move |this, cx| {
|
|
let rule = rule.await;
|
|
let markdown = language_registry.language_for_name("Markdown").await;
|
|
this.update_in(cx, |this, window, cx| match rule {
|
|
Ok(rule) => {
|
|
let title_editor = cx.new(|cx| {
|
|
let mut editor = Editor::single_line(window, cx);
|
|
editor.set_placeholder_text("Untitled", cx);
|
|
editor.set_text(rule_metadata.title.unwrap_or_default(), window, cx);
|
|
if prompt_id.is_built_in() {
|
|
editor.set_read_only(true);
|
|
editor.set_show_edit_predictions(Some(false), window, cx);
|
|
}
|
|
editor
|
|
});
|
|
let body_editor = cx.new(|cx| {
|
|
let buffer = cx.new(|cx| {
|
|
let mut buffer = Buffer::local(rule, cx);
|
|
buffer.set_language(markdown.log_err(), cx);
|
|
buffer.set_language_registry(language_registry);
|
|
buffer
|
|
});
|
|
|
|
let mut editor = Editor::for_buffer(buffer, None, window, cx);
|
|
if prompt_id.is_built_in() {
|
|
editor.set_read_only(true);
|
|
editor.set_show_edit_predictions(Some(false), window, cx);
|
|
}
|
|
editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
|
|
editor.set_show_gutter(false, cx);
|
|
editor.set_show_wrap_guides(false, cx);
|
|
editor.set_show_indent_guides(false, cx);
|
|
editor.set_use_modal_editing(false);
|
|
editor.set_current_line_highlight(Some(CurrentLineHighlight::None));
|
|
editor.set_completion_provider(Some(make_completion_provider()));
|
|
if focus {
|
|
window.focus(&editor.focus_handle(cx));
|
|
}
|
|
editor
|
|
});
|
|
let _subscriptions = vec![
|
|
cx.subscribe_in(
|
|
&title_editor,
|
|
window,
|
|
move |this, editor, event, window, cx| {
|
|
this.handle_rule_title_editor_event(
|
|
prompt_id, editor, event, window, cx,
|
|
)
|
|
},
|
|
),
|
|
cx.subscribe_in(
|
|
&body_editor,
|
|
window,
|
|
move |this, editor, event, window, cx| {
|
|
this.handle_rule_body_editor_event(
|
|
prompt_id, editor, event, window, cx,
|
|
)
|
|
},
|
|
),
|
|
];
|
|
this.rule_editors.insert(
|
|
prompt_id,
|
|
RuleEditor {
|
|
title_editor,
|
|
body_editor,
|
|
next_title_and_body_to_save: None,
|
|
pending_save: None,
|
|
token_count: None,
|
|
pending_token_count: Task::ready(None),
|
|
_subscriptions,
|
|
},
|
|
);
|
|
this.set_active_rule(Some(prompt_id), window, cx);
|
|
this.count_tokens(prompt_id, window, cx);
|
|
}
|
|
Err(error) => {
|
|
// TODO: we should show the error in the UI.
|
|
log::error!("error while loading rule: {:?}", error);
|
|
}
|
|
})
|
|
.ok();
|
|
});
|
|
}
|
|
}
|
|
|
|
fn set_active_rule(
|
|
&mut self,
|
|
prompt_id: Option<PromptId>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.active_rule_id = prompt_id;
|
|
self.picker.update(cx, |picker, cx| {
|
|
if let Some(prompt_id) = prompt_id {
|
|
if picker
|
|
.delegate
|
|
.matches
|
|
.get(picker.delegate.selected_index())
|
|
.map_or(true, |old_selected_prompt| {
|
|
old_selected_prompt.id != prompt_id
|
|
})
|
|
{
|
|
if let Some(ix) = picker
|
|
.delegate
|
|
.matches
|
|
.iter()
|
|
.position(|mat| mat.id == prompt_id)
|
|
{
|
|
picker.set_selected_index(ix, None, true, window, cx);
|
|
}
|
|
}
|
|
} else {
|
|
picker.focus(window, cx);
|
|
}
|
|
});
|
|
cx.notify();
|
|
}
|
|
|
|
pub fn delete_rule(
|
|
&mut self,
|
|
prompt_id: PromptId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(metadata) = self.store.read(cx).metadata(prompt_id) {
|
|
let confirmation = window.prompt(
|
|
PromptLevel::Warning,
|
|
&format!(
|
|
"Are you sure you want to delete {}",
|
|
metadata.title.unwrap_or("Untitled".into())
|
|
),
|
|
None,
|
|
&["Delete", "Cancel"],
|
|
cx,
|
|
);
|
|
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
if confirmation.await.ok() == Some(0) {
|
|
this.update_in(cx, |this, window, cx| {
|
|
if this.active_rule_id == Some(prompt_id) {
|
|
this.set_active_rule(None, window, cx);
|
|
}
|
|
this.rule_editors.remove(&prompt_id);
|
|
this.store
|
|
.update(cx, |store, cx| store.delete(prompt_id, cx))
|
|
.detach_and_log_err(cx);
|
|
this.picker
|
|
.update(cx, |picker, cx| picker.refresh(window, cx));
|
|
cx.notify();
|
|
})?;
|
|
}
|
|
anyhow::Ok(())
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
}
|
|
|
|
pub fn duplicate_rule(
|
|
&mut self,
|
|
prompt_id: PromptId,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(rule) = self.rule_editors.get(&prompt_id) {
|
|
const DUPLICATE_SUFFIX: &str = " copy";
|
|
let title_to_duplicate = rule.title_editor.read(cx).text(cx);
|
|
let existing_titles = self
|
|
.rule_editors
|
|
.iter()
|
|
.filter(|&(&id, _)| id != prompt_id)
|
|
.map(|(_, rule_editor)| rule_editor.title_editor.read(cx).text(cx))
|
|
.filter(|title| title.starts_with(&title_to_duplicate))
|
|
.collect::<HashSet<_>>();
|
|
|
|
let title = if existing_titles.is_empty() {
|
|
title_to_duplicate + DUPLICATE_SUFFIX
|
|
} else {
|
|
let mut i = 1;
|
|
loop {
|
|
let new_title = format!("{title_to_duplicate}{DUPLICATE_SUFFIX} {i}");
|
|
if !existing_titles.contains(&new_title) {
|
|
break new_title;
|
|
}
|
|
i += 1;
|
|
}
|
|
};
|
|
|
|
let new_id = PromptId::new();
|
|
let body = rule.body_editor.read(cx).text(cx);
|
|
let save = self.store.update(cx, |store, cx| {
|
|
store.save(new_id, Some(title.into()), false, body.into(), cx)
|
|
});
|
|
self.picker
|
|
.update(cx, |picker, cx| picker.refresh(window, cx));
|
|
cx.spawn_in(window, async move |this, cx| {
|
|
save.await?;
|
|
this.update_in(cx, |rules_library, window, cx| {
|
|
rules_library.load_rule(new_id, true, window, cx)
|
|
})
|
|
})
|
|
.detach_and_log_err(cx);
|
|
}
|
|
}
|
|
|
|
fn focus_active_rule(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
|
|
if let Some(active_rule) = self.active_rule_id {
|
|
self.rule_editors[&active_rule]
|
|
.body_editor
|
|
.update(cx, |editor, cx| window.focus(&editor.focus_handle(cx)));
|
|
cx.stop_propagation();
|
|
}
|
|
}
|
|
|
|
fn focus_picker(&mut self, _: &menu::Cancel, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.picker
|
|
.update(cx, |picker, cx| picker.focus(window, cx));
|
|
}
|
|
|
|
pub fn inline_assist(
|
|
&mut self,
|
|
action: &InlineAssist,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(active_rule_id) = self.active_rule_id else {
|
|
cx.propagate();
|
|
return;
|
|
};
|
|
|
|
let rule_editor = &self.rule_editors[&active_rule_id].body_editor;
|
|
let Some(ConfiguredModel { provider, .. }) =
|
|
LanguageModelRegistry::read_global(cx).inline_assistant_model()
|
|
else {
|
|
return;
|
|
};
|
|
|
|
let initial_prompt = action.prompt.clone();
|
|
if provider.is_authenticated(cx) {
|
|
self.inline_assist_delegate
|
|
.assist(rule_editor, initial_prompt, window, cx);
|
|
} else {
|
|
for window in cx.windows() {
|
|
if let Some(workspace) = window.downcast::<Workspace>() {
|
|
let panel = workspace
|
|
.update(cx, |workspace, window, cx| {
|
|
window.activate_window();
|
|
self.inline_assist_delegate
|
|
.focus_agent_panel(workspace, window, cx)
|
|
})
|
|
.ok();
|
|
if panel == Some(true) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn move_down_from_title(
|
|
&mut self,
|
|
_: &editor::actions::MoveDown,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(rule_id) = self.active_rule_id {
|
|
if let Some(rule_editor) = self.rule_editors.get(&rule_id) {
|
|
window.focus(&rule_editor.body_editor.focus_handle(cx));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn move_up_from_body(
|
|
&mut self,
|
|
_: &editor::actions::MoveUp,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
if let Some(rule_id) = self.active_rule_id {
|
|
if let Some(rule_editor) = self.rule_editors.get(&rule_id) {
|
|
window.focus(&rule_editor.title_editor.focus_handle(cx));
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_rule_title_editor_event(
|
|
&mut self,
|
|
prompt_id: PromptId,
|
|
title_editor: &Entity<Editor>,
|
|
event: &EditorEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match event {
|
|
EditorEvent::BufferEdited => {
|
|
self.save_rule(prompt_id, window, cx);
|
|
self.count_tokens(prompt_id, window, cx);
|
|
}
|
|
EditorEvent::Blurred => {
|
|
title_editor.update(cx, |title_editor, cx| {
|
|
title_editor.change_selections(
|
|
SelectionEffects::no_scroll(),
|
|
window,
|
|
cx,
|
|
|selections| {
|
|
let cursor = selections.oldest_anchor().head();
|
|
selections.select_anchor_ranges([cursor..cursor]);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn handle_rule_body_editor_event(
|
|
&mut self,
|
|
prompt_id: PromptId,
|
|
body_editor: &Entity<Editor>,
|
|
event: &EditorEvent,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
match event {
|
|
EditorEvent::BufferEdited => {
|
|
self.save_rule(prompt_id, window, cx);
|
|
self.count_tokens(prompt_id, window, cx);
|
|
}
|
|
EditorEvent::Blurred => {
|
|
body_editor.update(cx, |body_editor, cx| {
|
|
body_editor.change_selections(
|
|
SelectionEffects::no_scroll(),
|
|
window,
|
|
cx,
|
|
|selections| {
|
|
let cursor = selections.oldest_anchor().head();
|
|
selections.select_anchor_ranges([cursor..cursor]);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
fn count_tokens(&mut self, prompt_id: PromptId, window: &mut Window, cx: &mut Context<Self>) {
|
|
let Some(ConfiguredModel { model, .. }) =
|
|
LanguageModelRegistry::read_global(cx).default_model()
|
|
else {
|
|
return;
|
|
};
|
|
if let Some(rule) = self.rule_editors.get_mut(&prompt_id) {
|
|
let editor = &rule.body_editor.read(cx);
|
|
let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
|
|
let body = buffer.as_rope().clone();
|
|
rule.pending_token_count = cx.spawn_in(window, async move |this, cx| {
|
|
async move {
|
|
const DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1);
|
|
|
|
cx.background_executor().timer(DEBOUNCE_TIMEOUT).await;
|
|
let token_count = cx
|
|
.update(|_, cx| {
|
|
model.count_tokens(
|
|
LanguageModelRequest {
|
|
thread_id: None,
|
|
prompt_id: None,
|
|
intent: None,
|
|
mode: None,
|
|
messages: vec![LanguageModelRequestMessage {
|
|
role: Role::System,
|
|
content: vec![body.to_string().into()],
|
|
cache: false,
|
|
}],
|
|
tools: Vec::new(),
|
|
tool_choice: None,
|
|
stop: Vec::new(),
|
|
temperature: None,
|
|
thinking_allowed: true,
|
|
},
|
|
cx,
|
|
)
|
|
})?
|
|
.await?;
|
|
|
|
this.update(cx, |this, cx| {
|
|
let rule_editor = this.rule_editors.get_mut(&prompt_id).unwrap();
|
|
rule_editor.token_count = Some(token_count);
|
|
cx.notify();
|
|
})
|
|
}
|
|
.log_err()
|
|
.await
|
|
});
|
|
}
|
|
}
|
|
|
|
fn render_rule_list(&mut self, cx: &mut Context<Self>) -> impl IntoElement {
|
|
v_flex()
|
|
.id("rule-list")
|
|
.capture_action(cx.listener(Self::focus_active_rule))
|
|
.bg(cx.theme().colors().panel_background)
|
|
.h_full()
|
|
.px_1()
|
|
.w_1_3()
|
|
.overflow_x_hidden()
|
|
.child(
|
|
h_flex()
|
|
.p(DynamicSpacing::Base04.rems(cx))
|
|
.h_9()
|
|
.w_full()
|
|
.flex_none()
|
|
.justify_end()
|
|
.child(
|
|
IconButton::new("new-rule", IconName::Plus)
|
|
.style(ButtonStyle::Transparent)
|
|
.shape(IconButtonShape::Square)
|
|
.tooltip(move |window, cx| {
|
|
Tooltip::for_action("New Rule", &NewRule, window, cx)
|
|
})
|
|
.on_click(|_, window, cx| {
|
|
window.dispatch_action(Box::new(NewRule), cx);
|
|
}),
|
|
),
|
|
)
|
|
.child(div().flex_grow().child(self.picker.clone()))
|
|
}
|
|
|
|
fn render_active_rule(&mut self, cx: &mut Context<RulesLibrary>) -> gpui::Stateful<Div> {
|
|
div()
|
|
.w_2_3()
|
|
.h_full()
|
|
.id("rule-editor")
|
|
.border_l_1()
|
|
.border_color(cx.theme().colors().border)
|
|
.bg(cx.theme().colors().editor_background)
|
|
.flex_none()
|
|
.min_w_64()
|
|
.children(self.active_rule_id.and_then(|prompt_id| {
|
|
let rule_metadata = self.store.read(cx).metadata(prompt_id)?;
|
|
let rule_editor = &self.rule_editors[&prompt_id];
|
|
let focus_handle = rule_editor.body_editor.focus_handle(cx);
|
|
let model = LanguageModelRegistry::read_global(cx)
|
|
.default_model()
|
|
.map(|default| default.model);
|
|
let settings = ThemeSettings::get_global(cx);
|
|
|
|
Some(
|
|
v_flex()
|
|
.id("rule-editor-inner")
|
|
.size_full()
|
|
.relative()
|
|
.overflow_hidden()
|
|
.on_click(cx.listener(move |_, _, window, _| {
|
|
window.focus(&focus_handle);
|
|
}))
|
|
.child(
|
|
h_flex()
|
|
.group("active-editor-header")
|
|
.pt_2()
|
|
.px_2p5()
|
|
.gap_2()
|
|
.justify_between()
|
|
.child(
|
|
div()
|
|
.w_full()
|
|
.on_action(cx.listener(Self::move_down_from_title))
|
|
.border_1()
|
|
.border_color(transparent_black())
|
|
.rounded_sm()
|
|
.group_hover("active-editor-header", |this| {
|
|
this.border_color(cx.theme().colors().border_variant)
|
|
})
|
|
.child(EditorElement::new(
|
|
&rule_editor.title_editor,
|
|
EditorStyle {
|
|
background: cx.theme().system().transparent,
|
|
local_player: cx.theme().players().local(),
|
|
text: TextStyle {
|
|
color: cx.theme().colors().editor_foreground,
|
|
font_family: settings.ui_font.family.clone(),
|
|
font_features: settings
|
|
.ui_font
|
|
.features
|
|
.clone(),
|
|
font_size: HeadlineSize::Large.rems().into(),
|
|
font_weight: settings.ui_font.weight,
|
|
line_height: relative(
|
|
settings.buffer_line_height.value(),
|
|
),
|
|
..Default::default()
|
|
},
|
|
scrollbar_width: Pixels::ZERO,
|
|
syntax: cx.theme().syntax().clone(),
|
|
status: cx.theme().status().clone(),
|
|
inlay_hints_style: editor::make_inlay_hints_style(
|
|
cx,
|
|
),
|
|
edit_prediction_styles:
|
|
editor::make_suggestion_styles(cx),
|
|
..EditorStyle::default()
|
|
},
|
|
)),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.h_full()
|
|
.flex_shrink_0()
|
|
.gap(DynamicSpacing::Base04.rems(cx))
|
|
.children(rule_editor.token_count.map(|token_count| {
|
|
let token_count: SharedString =
|
|
token_count.to_string().into();
|
|
let label_token_count: SharedString =
|
|
token_count.to_string().into();
|
|
|
|
div()
|
|
.id("token_count")
|
|
.mr_1()
|
|
.flex_shrink_0()
|
|
.tooltip(move |window, cx| {
|
|
Tooltip::with_meta(
|
|
"Token Estimation",
|
|
None,
|
|
format!(
|
|
"Model: {}",
|
|
model
|
|
.as_ref()
|
|
.map(|model| model.name().0)
|
|
.unwrap_or_default()
|
|
),
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.child(
|
|
Label::new(format!(
|
|
"{} tokens",
|
|
label_token_count.clone()
|
|
))
|
|
.color(Color::Muted),
|
|
)
|
|
}))
|
|
.child(if prompt_id.is_built_in() {
|
|
div()
|
|
.id("built-in-rule")
|
|
.child(
|
|
Icon::new(IconName::FileLock)
|
|
.color(Color::Muted),
|
|
)
|
|
.tooltip(move |window, cx| {
|
|
Tooltip::with_meta(
|
|
"Built-in rule",
|
|
None,
|
|
BUILT_IN_TOOLTIP_TEXT,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.into_any()
|
|
} else {
|
|
IconButton::new("delete-rule", IconName::Trash)
|
|
.icon_size(IconSize::Small)
|
|
.tooltip(move |window, cx| {
|
|
Tooltip::for_action(
|
|
"Delete Rule",
|
|
&DeleteRule,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(|_, window, cx| {
|
|
window
|
|
.dispatch_action(Box::new(DeleteRule), cx);
|
|
})
|
|
.into_any_element()
|
|
})
|
|
.child(
|
|
IconButton::new("duplicate-rule", IconName::BookCopy)
|
|
.icon_size(IconSize::Small)
|
|
.tooltip(move |window, cx| {
|
|
Tooltip::for_action(
|
|
"Duplicate Rule",
|
|
&DuplicateRule,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.on_click(|_, window, cx| {
|
|
window.dispatch_action(
|
|
Box::new(DuplicateRule),
|
|
cx,
|
|
);
|
|
}),
|
|
)
|
|
.child(
|
|
IconButton::new("toggle-default-rule", IconName::Star)
|
|
.icon_size(IconSize::Small)
|
|
.toggle_state(rule_metadata.default)
|
|
.selected_icon(IconName::StarFilled)
|
|
.icon_color(if rule_metadata.default {
|
|
Color::Accent
|
|
} else {
|
|
Color::Muted
|
|
})
|
|
.map(|this| {
|
|
if rule_metadata.default {
|
|
this.tooltip(Tooltip::text(
|
|
"Remove from Default Rules",
|
|
))
|
|
} else {
|
|
this.tooltip(move |window, cx| {
|
|
Tooltip::with_meta(
|
|
"Add to Default Rules",
|
|
None,
|
|
"Always included in every thread.",
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
}
|
|
})
|
|
.on_click(|_, window, cx| {
|
|
window.dispatch_action(
|
|
Box::new(ToggleDefaultRule),
|
|
cx,
|
|
);
|
|
}),
|
|
),
|
|
),
|
|
)
|
|
.child(
|
|
div()
|
|
.on_action(cx.listener(Self::focus_picker))
|
|
.on_action(cx.listener(Self::inline_assist))
|
|
.on_action(cx.listener(Self::move_up_from_body))
|
|
.flex_grow()
|
|
.h_full()
|
|
.child(
|
|
h_flex()
|
|
.py_2()
|
|
.pl_2p5()
|
|
.h_full()
|
|
.flex_1()
|
|
.child(rule_editor.body_editor.clone()),
|
|
),
|
|
),
|
|
)
|
|
}))
|
|
}
|
|
}
|
|
|
|
impl Render for RulesLibrary {
|
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
|
let ui_font = theme::setup_ui_font(window, cx);
|
|
let theme = cx.theme().clone();
|
|
|
|
client_side_decorations(
|
|
v_flex()
|
|
.id("rules-library")
|
|
.key_context("PromptLibrary")
|
|
.on_action(cx.listener(|this, &NewRule, window, cx| this.new_rule(window, cx)))
|
|
.on_action(
|
|
cx.listener(|this, &DeleteRule, window, cx| {
|
|
this.delete_active_rule(window, cx)
|
|
}),
|
|
)
|
|
.on_action(cx.listener(|this, &DuplicateRule, window, cx| {
|
|
this.duplicate_active_rule(window, cx)
|
|
}))
|
|
.on_action(cx.listener(|this, &ToggleDefaultRule, window, cx| {
|
|
this.toggle_default_for_active_rule(window, cx)
|
|
}))
|
|
.size_full()
|
|
.overflow_hidden()
|
|
.font(ui_font)
|
|
.text_color(theme.colors().text)
|
|
.children(self.title_bar.clone())
|
|
.child(
|
|
h_flex()
|
|
.flex_1()
|
|
.child(self.render_rule_list(cx))
|
|
.map(|el| {
|
|
if self.store.read(cx).prompt_count() == 0 {
|
|
el.child(
|
|
v_flex()
|
|
.w_2_3()
|
|
.h_full()
|
|
.items_center()
|
|
.justify_center()
|
|
.gap_4()
|
|
.bg(cx.theme().colors().editor_background)
|
|
.child(
|
|
h_flex()
|
|
.gap_2()
|
|
.child(
|
|
Icon::new(IconName::Book)
|
|
.size(IconSize::Medium)
|
|
.color(Color::Muted),
|
|
)
|
|
.child(
|
|
Label::new("No rules yet")
|
|
.size(LabelSize::Large)
|
|
.color(Color::Muted),
|
|
),
|
|
)
|
|
.child(
|
|
h_flex()
|
|
.child(h_flex())
|
|
.child(
|
|
v_flex()
|
|
.gap_1()
|
|
.child(Label::new(
|
|
"Create your first rule:",
|
|
))
|
|
.child(
|
|
Button::new("create-rule", "New Rule")
|
|
.full_width()
|
|
.key_binding(
|
|
KeyBinding::for_action(
|
|
&NewRule, window, cx,
|
|
),
|
|
)
|
|
.on_click(|_, window, cx| {
|
|
window.dispatch_action(
|
|
NewRule.boxed_clone(),
|
|
cx,
|
|
)
|
|
}),
|
|
),
|
|
)
|
|
.child(h_flex()),
|
|
),
|
|
)
|
|
} else {
|
|
el.child(self.render_active_rule(cx))
|
|
}
|
|
}),
|
|
),
|
|
window,
|
|
cx,
|
|
)
|
|
}
|
|
}
|