Prompt Library Refinements (#13470)

Nate Butler , Antonio Scandurra , and Richard created

TODO:

- [x] Moving the cursor out of the title editor should unselect any
selected text

Release Notes:

- N/A

---------

Co-authored-by: Antonio Scandurra <me@as-cii.com>
Co-authored-by: Richard <richard@zed.dev>

Change summary

assets/icons/book.svg                            |   1 
assets/icons/book_copy.svg                       |   1 
assets/icons/book_plus.svg                       |   1 
crates/assistant/src/prompt_library.rs           | 469 +++++++++++------
crates/editor/src/editor.rs                      |  60 +
crates/editor/src/element.rs                     | 101 +++
crates/editor/src/scroll.rs                      |   2 
crates/editor/src/scroll/actions.rs              |   2 
crates/ui/src/clickable.rs                       |   4 
crates/ui/src/components/button/button.rs        |   5 
crates/ui/src/components/button/button_like.rs   |   9 
crates/ui/src/components/button/icon_button.rs   |   5 
crates/ui/src/components/button/toggle_button.rs |   5 
crates/ui/src/components/disclosure.rs           |   9 
crates/ui/src/components/icon.rs                 |   6 
15 files changed, 454 insertions(+), 226 deletions(-)

Detailed changes

assets/icons/book.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/></svg>

assets/icons/book_copy.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-copy"><path d="M2 16V4a2 2 0 0 1 2-2h11"/><path d="M5 14H4a2 2 0 1 0 0 4h1"/><path d="M22 18H11a2 2 0 1 0 0 4h11V6H11a2 2 0 0 0-2 2v12"/></svg>

assets/icons/book_plus.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-plus"><path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1 0-5H20"/><path d="M9 10h6"/><path d="M12 7v6"/></svg>

crates/assistant/src/prompt_library.rs 🔗

@@ -6,16 +6,16 @@ use anyhow::{anyhow, Result};
 use assistant_slash_command::SlashCommandRegistry;
 use chrono::{DateTime, Utc};
 use collections::HashMap;
-use editor::{actions::Tab, CurrentLineHighlight, Editor, EditorEvent};
+use editor::{actions::Tab, CurrentLineHighlight, Editor, EditorElement, EditorEvent, EditorStyle};
 use futures::{
     future::{self, BoxFuture, Shared},
     FutureExt,
 };
 use fuzzy::StringMatchCandidate;
 use gpui::{
-    actions, percentage, point, size, Animation, AnimationExt, AppContext, BackgroundExecutor,
-    Bounds, EventEmitter, Global, PromptLevel, ReadGlobal, Subscription, Task, TitlebarOptions,
-    Transformation, UpdateGlobal, View, WindowBounds, WindowHandle, WindowOptions,
+    actions, point, size, transparent_black, AppContext, BackgroundExecutor, Bounds, EventEmitter,
+    Global, HighlightStyle, PromptLevel, ReadGlobal, Subscription, Task, TextStyle,
+    TitlebarOptions, UpdateGlobal, View, WindowBounds, WindowHandle, WindowOptions,
 };
 use heed::{types::SerdeBincode, Database, RoTxn};
 use language::{language_settings::SoftWrap, Buffer, LanguageRegistry};
@@ -109,12 +109,13 @@ pub struct PromptLibrary {
 }
 
 struct PromptEditor {
-    editor: View<Editor>,
+    title_editor: View<Editor>,
+    body_editor: View<Editor>,
     token_count: Option<usize>,
     pending_token_count: Task<Option<()>>,
-    next_body_to_save: Option<Rope>,
+    next_title_and_body_to_save: Option<(String, Rope)>,
     pending_save: Option<Task<Option<()>>>,
-    _subscription: Subscription,
+    _subscriptions: Vec<Subscription>,
 }
 
 struct PromptPickerDelegate {
@@ -345,7 +346,8 @@ impl PromptLibrary {
 
         let prompt_metadata = self.store.metadata(prompt_id).unwrap();
         let prompt_editor = self.prompt_editors.get_mut(&prompt_id).unwrap();
-        let body = prompt_editor.editor.update(cx, |editor, cx| {
+        let title = prompt_editor.title_editor.read(cx).text(cx);
+        let body = prompt_editor.body_editor.update(cx, |editor, cx| {
             editor
                 .buffer()
                 .read(cx)
@@ -359,20 +361,24 @@ impl PromptLibrary {
         let store = self.store.clone();
         let executor = cx.background_executor().clone();
 
-        prompt_editor.next_body_to_save = Some(body);
+        prompt_editor.next_title_and_body_to_save = Some((title, body));
         if prompt_editor.pending_save.is_none() {
             prompt_editor.pending_save = Some(cx.spawn(|this, mut cx| {
                 async move {
                     loop {
-                        let next_body_to_save = this.update(&mut cx, |this, _| {
+                        let title_and_body = this.update(&mut cx, |this, _| {
                             this.prompt_editors
                                 .get_mut(&prompt_id)?
-                                .next_body_to_save
+                                .next_title_and_body_to_save
                                 .take()
                         })?;
 
-                        if let Some(body) = next_body_to_save {
-                            let title = title_from_body(body.chars_at(0));
+                        if let Some((title, body)) = title_and_body {
+                            let title = if title.trim().is_empty() {
+                                None
+                            } else {
+                                Some(SharedString::from(title))
+                            };
                             store
                                 .save(prompt_id, title, prompt_metadata.default, body)
                                 .await
@@ -425,11 +431,11 @@ impl PromptLibrary {
         if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
             if focus {
                 prompt_editor
-                    .editor
+                    .body_editor
                     .update(cx, |editor, cx| editor.focus(cx));
             }
             self.set_active_prompt(Some(prompt_id), cx);
-        } else {
+        } else if let Some(prompt_metadata) = self.store.metadata(prompt_id) {
             let language_registry = self.language_registry.clone();
             let commands = SlashCommandRegistry::global(cx);
             let prompt = self.store.load(prompt_id);
@@ -438,13 +444,20 @@ impl PromptLibrary {
                 let markdown = language_registry.language_for_name("Markdown").await;
                 this.update(&mut cx, |this, cx| match prompt {
                     Ok(prompt) => {
-                        let buffer = cx.new_model(|cx| {
-                            let mut buffer = Buffer::local(prompt, cx);
-                            buffer.set_language(markdown.log_err(), cx);
-                            buffer.set_language_registry(language_registry);
-                            buffer
+                        let title_editor = cx.new_view(|cx| {
+                            let mut editor = Editor::auto_width(cx);
+                            editor.set_placeholder_text("Untitled", cx);
+                            editor.set_text(prompt_metadata.title.unwrap_or_default(), cx);
+                            editor
                         });
-                        let editor = cx.new_view(|cx| {
+                        let body_editor = cx.new_view(|cx| {
+                            let buffer = cx.new_model(|cx| {
+                                let mut buffer = Buffer::local(prompt, cx);
+                                buffer.set_language(markdown.log_err(), cx);
+                                buffer.set_language_registry(language_registry);
+                                buffer
+                            });
+
                             let mut editor = Editor::for_buffer(buffer, None, cx);
                             editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
                             editor.set_show_gutter(false, cx);
@@ -460,19 +473,24 @@ impl PromptLibrary {
                             }
                             editor
                         });
-                        let _subscription =
-                            cx.subscribe(&editor, move |this, _editor, event, cx| {
-                                this.handle_prompt_editor_event(prompt_id, event, cx)
-                            });
+                        let _subscriptions = vec![
+                            cx.subscribe(&title_editor, move |this, editor, event, cx| {
+                                this.handle_prompt_title_editor_event(prompt_id, editor, event, cx)
+                            }),
+                            cx.subscribe(&body_editor, move |this, editor, event, cx| {
+                                this.handle_prompt_body_editor_event(prompt_id, editor, event, cx)
+                            }),
+                        ];
                         this.prompt_editors.insert(
                             prompt_id,
                             PromptEditor {
-                                editor,
-                                next_body_to_save: None,
+                                title_editor,
+                                body_editor,
+                                next_title_and_body_to_save: None,
                                 pending_save: None,
                                 token_count: None,
                                 pending_token_count: Task::ready(None),
-                                _subscription,
+                                _subscriptions,
                             },
                         );
                         this.set_active_prompt(Some(prompt_id), cx);
@@ -549,7 +567,7 @@ impl PromptLibrary {
     fn focus_active_prompt(&mut self, _: &Tab, cx: &mut ViewContext<Self>) {
         if let Some(active_prompt) = self.active_prompt_id {
             self.prompt_editors[&active_prompt]
-                .editor
+                .body_editor
                 .update(cx, |editor, cx| editor.focus(cx));
             cx.stop_propagation();
         }
@@ -565,7 +583,7 @@ impl PromptLibrary {
             return;
         };
 
-        let prompt_editor = &self.prompt_editors[&active_prompt_id].editor;
+        let prompt_editor = &self.prompt_editors[&active_prompt_id].body_editor;
         let provider = CompletionProvider::global(cx);
         if provider.is_authenticated() {
             InlineAssistant::update_global(cx, |assistant, cx| {
@@ -589,50 +607,73 @@ impl PromptLibrary {
         }
     }
 
-    fn handle_prompt_editor_event(
+    fn move_down_from_title(&mut self, _: &editor::actions::MoveDown, cx: &mut ViewContext<Self>) {
+        if let Some(prompt_id) = self.active_prompt_id {
+            if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
+                cx.focus_view(&prompt_editor.body_editor);
+            }
+        }
+    }
+
+    fn move_up_from_body(&mut self, _: &editor::actions::MoveUp, cx: &mut ViewContext<Self>) {
+        if let Some(prompt_id) = self.active_prompt_id {
+            if let Some(prompt_editor) = self.prompt_editors.get(&prompt_id) {
+                cx.focus_view(&prompt_editor.title_editor);
+            }
+        }
+    }
+
+    fn handle_prompt_title_editor_event(
         &mut self,
         prompt_id: PromptId,
+        title_editor: View<Editor>,
         event: &EditorEvent,
         cx: &mut ViewContext<Self>,
     ) {
-        if let EditorEvent::BufferEdited = event {
-            let prompt_editor = self.prompt_editors.get(&prompt_id).unwrap();
-            let buffer = prompt_editor
-                .editor
-                .read(cx)
-                .buffer()
-                .read(cx)
-                .as_singleton()
-                .unwrap();
-
-            buffer.update(cx, |buffer, cx| {
-                let mut chars = buffer.chars_at(0);
-                match chars.next() {
-                    Some('#') => {
-                        if chars.next() != Some(' ') {
-                            drop(chars);
-                            buffer.edit([(1..1, " ")], None, cx);
-                        }
-                    }
-                    Some(' ') => {
-                        drop(chars);
-                        buffer.edit([(0..0, "#")], None, cx);
-                    }
-                    _ => {
-                        drop(chars);
-                        buffer.edit([(0..0, "# ")], None, cx);
-                    }
-                }
-            });
+        match event {
+            EditorEvent::BufferEdited => {
+                self.save_prompt(prompt_id, cx);
+                self.count_tokens(prompt_id, cx);
+            }
+            EditorEvent::Blurred => {
+                title_editor.update(cx, |title_editor, cx| {
+                    title_editor.change_selections(None, cx, |selections| {
+                        let cursor = selections.oldest_anchor().head();
+                        selections.select_anchor_ranges([cursor..cursor]);
+                    });
+                });
+            }
+            _ => {}
+        }
+    }
 
-            self.save_prompt(prompt_id, cx);
-            self.count_tokens(prompt_id, cx);
+    fn handle_prompt_body_editor_event(
+        &mut self,
+        prompt_id: PromptId,
+        body_editor: View<Editor>,
+        event: &EditorEvent,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match event {
+            EditorEvent::BufferEdited => {
+                self.save_prompt(prompt_id, cx);
+                self.count_tokens(prompt_id, cx);
+            }
+            EditorEvent::Blurred => {
+                body_editor.update(cx, |body_editor, cx| {
+                    body_editor.change_selections(None, cx, |selections| {
+                        let cursor = selections.oldest_anchor().head();
+                        selections.select_anchor_ranges([cursor..cursor]);
+                    });
+                });
+            }
+            _ => {}
         }
     }
 
     fn count_tokens(&mut self, prompt_id: PromptId, cx: &mut ViewContext<Self>) {
         if let Some(prompt) = self.prompt_editors.get_mut(&prompt_id) {
-            let editor = &prompt.editor.read(cx);
+            let editor = &prompt.body_editor.read(cx);
             let buffer = &editor.buffer().read(cx).as_singleton().unwrap().read(cx);
             let body = buffer.as_rope().clone();
             prompt.pending_token_count = cx.spawn(|this, mut cx| {
@@ -708,122 +749,209 @@ impl PromptLibrary {
             .flex_none()
             .min_w_64()
             .children(self.active_prompt_id.and_then(|prompt_id| {
-                let buffer_font = ThemeSettings::get_global(cx).buffer_font.family.clone();
                 let prompt_metadata = self.store.metadata(prompt_id)?;
                 let prompt_editor = &self.prompt_editors[&prompt_id];
-                let focus_handle = prompt_editor.editor.focus_handle(cx);
+                let focus_handle = prompt_editor.body_editor.focus_handle(cx);
                 let current_model = CompletionProvider::global(cx).model();
-                let token_count = prompt_editor.token_count.map(|count| count.to_string());
+                let settings = ThemeSettings::get_global(cx);
 
                 Some(
-                    h_flex()
+                    v_flex()
                         .id("prompt-editor-inner")
                         .size_full()
-                        .items_start()
+                        .relative()
+                        .overflow_hidden()
+                        .pl(Spacing::XXLarge.rems(cx))
+                        .pt(Spacing::Large.rems(cx))
                         .on_click(cx.listener(move |_, _, cx| {
                             cx.focus(&focus_handle);
                         }))
                         .child(
-                            div()
-                                .on_action(cx.listener(Self::focus_picker))
-                                .on_action(cx.listener(Self::inline_assist))
-                                .flex_grow()
-                                .h_full()
-                                .pt(Spacing::XXLarge.rems(cx))
-                                .pl(Spacing::XXLarge.rems(cx))
-                                .child(prompt_editor.editor.clone()),
-                        )
-                        .child(
-                            v_flex()
-                                .w_12()
-                                .py(Spacing::Large.rems(cx))
-                                .justify_start()
-                                .items_end()
-                                .gap_1()
-                                .child(h_flex().h_8().font_family(buffer_font).when_some_else(
-                                    token_count,
-                                    |tokens_ready, token_count| {
-                                        tokens_ready.pr_3().justify_end().child(
-                                            // This isn't actually a button, it just let's us easily add
-                                            // a tooltip to the token count.
-                                            Button::new("token_count", token_count.clone())
-                                                .style(ButtonStyle::Transparent)
-                                                .color(Color::Muted)
-                                                .tooltip(move |cx| {
-                                                    Tooltip::with_meta(
-                                                        format!("{} tokens", token_count,),
-                                                        None,
-                                                        format!(
-                                                            "Model: {}",
-                                                            current_model.display_name()
-                                                        ),
-                                                        cx,
-                                                    )
-                                                }),
-                                        )
-                                    },
-                                    |tokens_loading| {
-                                        tokens_loading.w_12().justify_center().child(
-                                            Icon::new(IconName::ArrowCircle)
-                                                .size(IconSize::Small)
-                                                .color(Color::Muted)
-                                                .with_animation(
-                                                    "arrow-circle",
-                                                    Animation::new(Duration::from_secs(4)).repeat(),
-                                                    |icon, delta| {
-                                                        icon.transform(Transformation::rotate(
-                                                            percentage(delta),
-                                                        ))
-                                                    },
-                                                ),
-                                        )
-                                    },
-                                ))
+                            h_flex()
+                                .group("active-editor-header")
+                                .pr(Spacing::XXLarge.rems(cx))
+                                .pt(Spacing::XSmall.rems(cx))
+                                .pb(Spacing::Large.rems(cx))
+                                .justify_between()
                                 .child(
-                                    h_flex().justify_center().w_12().h_8().child(
-                                        IconButton::new("toggle-default-prompt", IconName::Sparkle)
-                                            .style(ButtonStyle::Transparent)
-                                            .selected(prompt_metadata.default)
-                                            .selected_icon(IconName::SparkleFilled)
-                                            .icon_color(if prompt_metadata.default {
-                                                Color::Accent
-                                            } else {
-                                                Color::Muted
-                                            })
-                                            .shape(IconButtonShape::Square)
-                                            .tooltip(move |cx| {
-                                                Tooltip::text(
-                                                    if prompt_metadata.default {
-                                                        "Remove from Default Prompt"
-                                                    } else {
-                                                        "Add to Default Prompt"
-                                                    },
-                                                    cx,
+                                    h_flex().gap_1().child(
+                                        div()
+                                            .max_w_80()
+                                            .on_action(cx.listener(Self::move_down_from_title))
+                                            .border_1()
+                                            .border_color(transparent_black())
+                                            .rounded_md()
+                                            .group_hover("active-editor-header", |this| {
+                                                this.border_color(
+                                                    cx.theme().colors().border_variant,
                                                 )
                                             })
-                                            .on_click(|_, cx| {
-                                                cx.dispatch_action(Box::new(ToggleDefaultPrompt));
-                                            }),
+                                            .child(EditorElement::new(
+                                                &prompt_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
+                                                            .size()
+                                                            .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: HighlightStyle {
+                                                        color: Some(cx.theme().status().hint),
+                                                        ..HighlightStyle::default()
+                                                    },
+                                                    suggestions_style: HighlightStyle {
+                                                        color: Some(cx.theme().status().predictive),
+                                                        ..HighlightStyle::default()
+                                                    },
+                                                },
+                                            )),
                                     ),
                                 )
                                 .child(
-                                    h_flex().justify_center().w_12().h_8().child(
-                                        IconButton::new("delete-prompt", IconName::Trash)
-                                            .size(ButtonSize::Large)
-                                            .style(ButtonStyle::Transparent)
-                                            .shape(IconButtonShape::Square)
-                                            .tooltip(move |cx| {
-                                                Tooltip::for_action(
-                                                    "Delete Prompt",
-                                                    &DeletePrompt,
-                                                    cx,
+                                    h_flex()
+                                        .h_full()
+                                        .child(
+                                            h_flex()
+                                                .h_full()
+                                                .gap(Spacing::XXLarge.rems(cx))
+                                                .child(div()),
+                                        )
+                                        .child(
+                                            h_flex()
+                                                .h_full()
+                                                .gap(Spacing::XXLarge.rems(cx))
+                                                .child(
+                                                    IconButton::new(
+                                                        "delete-prompt",
+                                                        IconName::Trash,
+                                                    )
+                                                    .size(ButtonSize::Large)
+                                                    .style(ButtonStyle::Transparent)
+                                                    .shape(IconButtonShape::Square)
+                                                    .size(ButtonSize::Large)
+                                                    .tooltip(move |cx| {
+                                                        Tooltip::for_action(
+                                                            "Delete Prompt",
+                                                            &DeletePrompt,
+                                                            cx,
+                                                        )
+                                                    })
+                                                    .on_click(|_, cx| {
+                                                        cx.dispatch_action(Box::new(DeletePrompt));
+                                                    }),
                                                 )
-                                            })
-                                            .on_click(|_, cx| {
-                                                cx.dispatch_action(Box::new(DeletePrompt));
-                                            }),
-                                    ),
+                                                // .child(
+                                                //     IconButton::new(
+                                                //         "duplicate-prompt",
+                                                //         IconName::BookCopy,
+                                                //     )
+                                                //     .size(ButtonSize::Large)
+                                                //     .style(ButtonStyle::Transparent)
+                                                //     .shape(IconButtonShape::Square)
+                                                //     .size(ButtonSize::Large)
+                                                //     .tooltip(move |cx| {
+                                                //         Tooltip::for_action(
+                                                //             "Duplicate Prompt",
+                                                //             &gpui::NoAction,
+                                                //             cx,
+                                                //         )
+                                                //     })
+                                                //     .disabled(true),
+                                                // )
+                                                .child(
+                                                    IconButton::new(
+                                                        "toggle-default-prompt",
+                                                        IconName::Sparkle,
+                                                    )
+                                                    .style(ButtonStyle::Transparent)
+                                                    .selected(prompt_metadata.default)
+                                                    .selected_icon(IconName::SparkleFilled)
+                                                    .icon_color(if prompt_metadata.default {
+                                                        Color::Accent
+                                                    } else {
+                                                        Color::Muted
+                                                    })
+                                                    .shape(IconButtonShape::Square)
+                                                    .size(ButtonSize::Large)
+                                                    .tooltip(move |cx| {
+                                                        Tooltip::text(
+                                                            if prompt_metadata.default {
+                                                                "Remove from Default Prompt"
+                                                            } else {
+                                                                "Add to Default Prompt"
+                                                            },
+                                                            cx,
+                                                        )
+                                                    })
+                                                    .on_click(|_, cx| {
+                                                        cx.dispatch_action(Box::new(
+                                                            ToggleDefaultPrompt,
+                                                        ));
+                                                    }),
+                                                ),
+                                        ),
                                 ),
+                        )
+                        .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(prompt_editor.body_editor.clone())
+                                .children(prompt_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();
+
+                                    h_flex()
+                                        .id("token_count")
+                                        .absolute()
+                                        .bottom_1()
+                                        .right_4()
+                                        .flex_initial()
+                                        .px_2()
+                                        .py_1()
+                                        .tooltip(move |cx| {
+                                            let token_count = token_count.clone();
+
+                                            Tooltip::with_meta(
+                                                format!("{} tokens", token_count.clone()),
+                                                None,
+                                                format!("Model: {}", current_model.display_name()),
+                                                cx,
+                                            )
+                                        })
+                                        .child(
+                                            Label::new(format!(
+                                                "{} tokens",
+                                                label_token_count.clone()
+                                            ))
+                                            .color(Color::Muted),
+                                        )
+                                })),
                         ),
                 )
             }))
@@ -1115,24 +1243,3 @@ pub struct GlobalPromptStore(
 );
 
 impl Global for GlobalPromptStore {}
-
-fn title_from_body(body: impl IntoIterator<Item = char>) -> Option<SharedString> {
-    let mut chars = body.into_iter().take_while(|c| *c != '\n').peekable();
-
-    let mut level = 0;
-    while let Some('#') = chars.peek() {
-        level += 1;
-        chars.next();
-    }
-
-    if level > 0 {
-        let title = chars.collect::<String>().trim().to_string();
-        if title.is_empty() {
-            None
-        } else {
-            Some(title.into())
-        }
-    } else {
-        None
-    }
-}

crates/editor/src/editor.rs 🔗

@@ -335,7 +335,7 @@ pub enum SelectMode {
 
 #[derive(Copy, Clone, PartialEq, Eq, Debug)]
 pub enum EditorMode {
-    SingleLine,
+    SingleLine { auto_width: bool },
     AutoHeight { max_lines: usize },
     Full,
 }
@@ -1580,7 +1580,13 @@ impl Editor {
     pub fn single_line(cx: &mut ViewContext<Self>) -> Self {
         let buffer = cx.new_model(|cx| Buffer::local("", cx));
         let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
-        Self::new(EditorMode::SingleLine, buffer, None, false, cx)
+        Self::new(
+            EditorMode::SingleLine { auto_width: false },
+            buffer,
+            None,
+            false,
+            cx,
+        )
     }
 
     pub fn multi_line(cx: &mut ViewContext<Self>) -> Self {
@@ -1589,6 +1595,18 @@ impl Editor {
         Self::new(EditorMode::Full, buffer, None, false, cx)
     }
 
+    pub fn auto_width(cx: &mut ViewContext<Self>) -> Self {
+        let buffer = cx.new_model(|cx| Buffer::local("", cx));
+        let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
+        Self::new(
+            EditorMode::SingleLine { auto_width: true },
+            buffer,
+            None,
+            false,
+            cx,
+        )
+    }
+
     pub fn auto_height(max_lines: usize, cx: &mut ViewContext<Self>) -> Self {
         let buffer = cx.new_model(|cx| Buffer::local("", cx));
         let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
@@ -1701,8 +1719,8 @@ impl Editor {
 
         let blink_manager = cx.new_model(|cx| BlinkManager::new(CURSOR_BLINK_INTERVAL, cx));
 
-        let soft_wrap_mode_override =
-            (mode == EditorMode::SingleLine).then(|| language_settings::SoftWrap::PreferLine);
+        let soft_wrap_mode_override = matches!(mode, EditorMode::SingleLine { .. })
+            .then(|| language_settings::SoftWrap::PreferLine);
 
         let mut project_subscriptions = Vec::new();
         if mode == EditorMode::Full {
@@ -1749,7 +1767,7 @@ impl Editor {
             .detach();
         cx.on_blur(&focus_handle, Self::handle_blur).detach();
 
-        let show_indent_guides = if mode == EditorMode::SingleLine {
+        let show_indent_guides = if matches!(mode, EditorMode::SingleLine { .. }) {
             Some(false)
         } else {
             None
@@ -1905,7 +1923,7 @@ impl Editor {
         let mut key_context = KeyContext::new_with_defaults();
         key_context.add("Editor");
         let mode = match self.mode {
-            EditorMode::SingleLine => "single_line",
+            EditorMode::SingleLine { .. } => "single_line",
             EditorMode::AutoHeight { .. } => "auto_height",
             EditorMode::Full => "full",
         };
@@ -6660,7 +6678,7 @@ impl Editor {
             return;
         }
 
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -6697,7 +6715,7 @@ impl Editor {
             return;
         }
 
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -6728,7 +6746,7 @@ impl Editor {
             return;
         }
 
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -6791,7 +6809,7 @@ impl Editor {
             return;
         }
 
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -6839,7 +6857,7 @@ impl Editor {
     pub fn move_down(&mut self, _: &MoveDown, cx: &mut ViewContext<Self>) {
         self.take_rename(true, cx);
 
-        if self.mode == EditorMode::SingleLine {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -6900,7 +6918,7 @@ impl Editor {
             return;
         }
 
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -7248,7 +7266,7 @@ impl Editor {
         _: &MoveToStartOfParagraph,
         cx: &mut ViewContext<Self>,
     ) {
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -7268,7 +7286,7 @@ impl Editor {
         _: &MoveToEndOfParagraph,
         cx: &mut ViewContext<Self>,
     ) {
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -7288,7 +7306,7 @@ impl Editor {
         _: &SelectToStartOfParagraph,
         cx: &mut ViewContext<Self>,
     ) {
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -7308,7 +7326,7 @@ impl Editor {
         _: &SelectToEndOfParagraph,
         cx: &mut ViewContext<Self>,
     ) {
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -7324,7 +7342,7 @@ impl Editor {
     }
 
     pub fn move_to_beginning(&mut self, _: &MoveToBeginning, cx: &mut ViewContext<Self>) {
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -7344,7 +7362,7 @@ impl Editor {
     }
 
     pub fn move_to_end(&mut self, _: &MoveToEnd, cx: &mut ViewContext<Self>) {
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }
@@ -8203,7 +8221,7 @@ impl Editor {
             let advance_downwards = action.advance_downwards
                 && selections_on_single_row
                 && !selections_selecting
-                && this.mode != EditorMode::SingleLine;
+                && !matches!(this.mode, EditorMode::SingleLine { .. });
 
             if advance_downwards {
                 let snapshot = this.buffer.read(cx).snapshot(cx);
@@ -12079,7 +12097,7 @@ impl Render for Editor {
         let settings = ThemeSettings::get_global(cx);
 
         let text_style = match self.mode {
-            EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle {
+            EditorMode::SingleLine { .. } | EditorMode::AutoHeight { .. } => TextStyle {
                 color: cx.theme().colors().editor_foreground,
                 font_family: settings.ui_font.family.clone(),
                 font_features: settings.ui_font.features.clone(),
@@ -12108,7 +12126,7 @@ impl Render for Editor {
         };
 
         let background = match self.mode {
-            EditorMode::SingleLine => cx.theme().system().transparent,
+            EditorMode::SingleLine { .. } => cx.theme().system().transparent,
             EditorMode::AutoHeight { max_lines: _ } => cx.theme().system().transparent,
             EditorMode::Full => cx.theme().colors().editor_background,
         };

crates/editor/src/element.rs 🔗

@@ -1831,10 +1831,10 @@ impl EditorElement {
     }
 
     fn layout_lines(
-        &self,
         rows: Range<DisplayRow>,
         line_number_layouts: &[Option<ShapedLine>],
         snapshot: &EditorSnapshot,
+        style: &EditorStyle,
         cx: &mut WindowContext,
     ) -> Vec<LineWithInvisibles> {
         if rows.start >= rows.end {
@@ -1843,7 +1843,7 @@ impl EditorElement {
 
         // Show the placeholder when the editor is empty
         if snapshot.is_empty() {
-            let font_size = self.style.text.font_size.to_pixels(cx.rem_size());
+            let font_size = style.text.font_size.to_pixels(cx.rem_size());
             let placeholder_color = cx.theme().colors().text_placeholder;
             let placeholder_text = snapshot.placeholder_text();
 
@@ -1858,7 +1858,7 @@ impl EditorElement {
                 .filter_map(move |line| {
                     let run = TextRun {
                         len: line.len(),
-                        font: self.style.text.font(),
+                        font: style.text.font(),
                         color: placeholder_color,
                         background_color: None,
                         underline: Default::default(),
@@ -1877,10 +1877,10 @@ impl EditorElement {
                 })
                 .collect()
         } else {
-            let chunks = snapshot.highlighted_chunks(rows.clone(), true, &self.style);
+            let chunks = snapshot.highlighted_chunks(rows.clone(), true, style);
             LineWithInvisibles::from_chunks(
                 chunks,
-                &self.style.text,
+                &style.text,
                 MAX_LINE_LEN,
                 rows.len(),
                 line_number_layouts,
@@ -4475,7 +4475,7 @@ impl EditorElement {
             // We currently use single-line and auto-height editors in UI contexts,
             // so we don't want to scale everything with the buffer font size, as it
             // ends up looking off.
-            EditorMode::SingleLine | EditorMode::AutoHeight { .. } => None,
+            EditorMode::SingleLine { .. } | EditorMode::AutoHeight { .. } => None,
         }
     }
 }
@@ -4499,12 +4499,43 @@ impl Element for EditorElement {
                 editor.set_style(self.style.clone(), cx);
 
                 let layout_id = match editor.mode {
-                    EditorMode::SingleLine => {
+                    EditorMode::SingleLine { auto_width } => {
                         let rem_size = cx.rem_size();
-                        let mut style = Style::default();
-                        style.size.width = relative(1.).into();
-                        style.size.height = self.style.text.line_height_in_pixels(rem_size).into();
-                        cx.request_layout(style, None)
+
+                        let height = self.style.text.line_height_in_pixels(rem_size);
+                        if auto_width {
+                            let editor_handle = cx.view().clone();
+                            let style = self.style.clone();
+                            cx.request_measured_layout(Style::default(), move |_, _, cx| {
+                                let editor_snapshot =
+                                    editor_handle.update(cx, |editor, cx| editor.snapshot(cx));
+                                let line = Self::layout_lines(
+                                    DisplayRow(0)..DisplayRow(1),
+                                    &[],
+                                    &editor_snapshot,
+                                    &style,
+                                    cx,
+                                )
+                                .pop()
+                                .unwrap();
+
+                                let font_id = cx.text_system().resolve_font(&style.text.font());
+                                let font_size = style.text.font_size.to_pixels(cx.rem_size());
+                                let em_width = cx
+                                    .text_system()
+                                    .typographic_bounds(font_id, font_size, 'm')
+                                    .unwrap()
+                                    .size
+                                    .width;
+
+                                size(line.width + em_width, height)
+                            })
+                        } else {
+                            let mut style = Style::default();
+                            style.size.height = height.into();
+                            style.size.width = relative(1.).into();
+                            cx.request_layout(style, None)
+                        }
                     }
                     EditorMode::AutoHeight { max_lines } => {
                         let editor_handle = cx.view().clone();
@@ -4763,8 +4794,13 @@ impl Element for EditorElement {
                     );
 
                     let mut max_visible_line_width = Pixels::ZERO;
-                    let mut line_layouts =
-                        self.layout_lines(start_row..end_row, &line_numbers, &snapshot, cx);
+                    let mut line_layouts = Self::layout_lines(
+                        start_row..end_row,
+                        &line_numbers,
+                        &snapshot,
+                        &self.style,
+                        cx,
+                    );
                     for line_with_invisibles in &line_layouts {
                         if line_with_invisibles.width > max_visible_line_width {
                             max_visible_line_width = line_with_invisibles.width;
@@ -4792,16 +4828,43 @@ impl Element for EditorElement {
                         )
                     });
 
-                    let scroll_pixel_position = point(
-                        scroll_position.x * em_width,
-                        scroll_position.y * line_height,
-                    );
-
                     let start_buffer_row =
                         MultiBufferRow(start_anchor.to_point(&snapshot.buffer_snapshot).row);
                     let end_buffer_row =
                         MultiBufferRow(end_anchor.to_point(&snapshot.buffer_snapshot).row);
 
+                    let scroll_max = point(
+                        ((scroll_width - text_hitbox.size.width) / em_width).max(0.0),
+                        max_row.as_f32(),
+                    );
+
+                    self.editor.update(cx, |editor, cx| {
+                        let clamped = editor.scroll_manager.clamp_scroll_left(scroll_max.x);
+
+                        let autoscrolled = if autoscroll_horizontally {
+                            editor.autoscroll_horizontally(
+                                start_row,
+                                text_hitbox.size.width,
+                                scroll_width,
+                                em_width,
+                                &line_layouts,
+                                cx,
+                            )
+                        } else {
+                            false
+                        };
+
+                        if clamped || autoscrolled {
+                            snapshot = editor.snapshot(cx);
+                            scroll_position = snapshot.scroll_position();
+                        }
+                    });
+
+                    let scroll_pixel_position = point(
+                        scroll_position.x * em_width,
+                        scroll_position.y * line_height,
+                    );
+
                     let indent_guides = self.layout_indent_guides(
                         content_origin,
                         text_hitbox.origin,
@@ -6065,7 +6128,7 @@ mod tests {
         });
 
         for editor_mode_without_invisibles in [
-            EditorMode::SingleLine,
+            EditorMode::SingleLine { auto_width: false },
             EditorMode::AutoHeight { max_lines: 100 },
         ] {
             let invisibles = collect_invisibles_from_new_editor(

crates/editor/src/scroll.rs 🔗

@@ -455,7 +455,7 @@ impl Editor {
     }
 
     pub fn scroll_screen(&mut self, amount: &ScrollAmount, cx: &mut ViewContext<Self>) {
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }

crates/editor/src/scroll/actions.rs 🔗

@@ -15,7 +15,7 @@ impl Editor {
             return;
         }
 
-        if matches!(self.mode, EditorMode::SingleLine) {
+        if matches!(self.mode, EditorMode::SingleLine { .. }) {
             cx.propagate();
             return;
         }

crates/ui/src/clickable.rs 🔗

@@ -1,7 +1,9 @@
-use gpui::{ClickEvent, WindowContext};
+use gpui::{ClickEvent, CursorStyle, WindowContext};
 
 /// A trait for elements that can be clicked. Enables the use of the `on_click` method.
 pub trait Clickable {
     /// Sets the click handler that will fire whenever the element is clicked.
     fn on_click(self, handler: impl Fn(&ClickEvent, &mut WindowContext) + 'static) -> Self;
+    /// Sets the cursor style when hovering over the element.
+    fn cursor_style(self, cursor_style: CursorStyle) -> Self;
 }

crates/ui/src/components/button/button.rs 🔗

@@ -249,6 +249,11 @@ impl Clickable for Button {
         self.base = self.base.on_click(handler);
         self
     }
+
+    fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self {
+        self.base = self.base.cursor_style(cursor_style);
+        self
+    }
 }
 
 impl FixedWidth for Button {

crates/ui/src/components/button/button_like.rs 🔗

@@ -1,4 +1,4 @@
-use gpui::{relative, DefiniteLength, MouseButton};
+use gpui::{relative, CursorStyle, DefiniteLength, MouseButton};
 use gpui::{transparent_black, AnyElement, AnyView, ClickEvent, Hsla, Rems};
 use smallvec::SmallVec;
 
@@ -344,6 +344,7 @@ pub struct ButtonLike {
     size: ButtonSize,
     rounding: Option<ButtonLikeRounding>,
     tooltip: Option<Box<dyn Fn(&mut WindowContext) -> AnyView>>,
+    cursor_style: CursorStyle,
     on_click: Option<Box<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
     children: SmallVec<[AnyElement; 2]>,
 }
@@ -363,6 +364,7 @@ impl ButtonLike {
             rounding: Some(ButtonLikeRounding::All),
             tooltip: None,
             children: SmallVec::new(),
+            cursor_style: CursorStyle::PointingHand,
             on_click: None,
             layer: None,
         }
@@ -405,6 +407,11 @@ impl Clickable for ButtonLike {
         self.on_click = Some(Box::new(handler));
         self
     }
+
+    fn cursor_style(mut self, cursor_style: CursorStyle) -> Self {
+        self.cursor_style = cursor_style;
+        self
+    }
 }
 
 impl FixedWidth for ButtonLike {

crates/ui/src/components/button/icon_button.rs 🔗

@@ -86,6 +86,11 @@ impl Clickable for IconButton {
         self.base = self.base.on_click(handler);
         self
     }
+
+    fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self {
+        self.base = self.base.cursor_style(cursor_style);
+        self
+    }
 }
 
 impl FixedWidth for IconButton {

crates/ui/src/components/button/toggle_button.rs 🔗

@@ -82,6 +82,11 @@ impl Clickable for ToggleButton {
         self.base = self.base.on_click(handler);
         self
     }
+
+    fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self {
+        self.base = self.base.cursor_style(cursor_style);
+        self
+    }
 }
 
 impl ButtonCommon for ToggleButton {

crates/ui/src/components/disclosure.rs 🔗

@@ -1,6 +1,6 @@
 use std::sync::Arc;
 
-use gpui::ClickEvent;
+use gpui::{ClickEvent, CursorStyle};
 
 use crate::{prelude::*, Color, IconButton, IconButtonShape, IconName, IconSize};
 
@@ -10,6 +10,7 @@ pub struct Disclosure {
     is_open: bool,
     selected: bool,
     on_toggle: Option<Arc<dyn Fn(&ClickEvent, &mut WindowContext) + 'static>>,
+    cursor_style: CursorStyle,
 }
 
 impl Disclosure {
@@ -19,6 +20,7 @@ impl Disclosure {
             is_open,
             selected: false,
             on_toggle: None,
+            cursor_style: CursorStyle::PointingHand,
         }
     }
 
@@ -43,6 +45,11 @@ impl Clickable for Disclosure {
         self.on_toggle = Some(Arc::new(handler));
         self
     }
+
+    fn cursor_style(mut self, cursor_style: gpui::CursorStyle) -> Self {
+        self.cursor_style = cursor_style;
+        self
+    }
 }
 
 impl RenderOnce for Disclosure {

crates/ui/src/components/icon.rs 🔗

@@ -97,6 +97,9 @@ pub enum IconName {
     BellOff,
     BellRing,
     Bolt,
+    Book,
+    BookCopy,
+    BookPlus,
     CaseSensitive,
     Check,
     ChevronDown,
@@ -231,6 +234,9 @@ impl IconName {
             IconName::BellOff => "icons/bell_off.svg",
             IconName::BellRing => "icons/bell_ring.svg",
             IconName::Bolt => "icons/bolt.svg",
+            IconName::Book => "icons/book.svg",
+            IconName::BookCopy => "icons/book_copy.svg",
+            IconName::BookPlus => "icons/book_plus.svg",
             IconName::CaseSensitive => "icons/case_insensitive.svg",
             IconName::Check => "icons/check.svg",
             IconName::ChevronDown => "icons/chevron_down.svg",