Merge branch 'main' into add-telemetry-tests

Joseph T. Lyons created

Change summary

Cargo.lock                                        |   1 
assets/settings/default.json                      |   3 
crates/collab_ui/src/chat_panel.rs                |  14 -
crates/collab_ui/src/chat_panel/message_editor.rs |   5 
crates/editor/src/editor.rs                       |  13 +
crates/gpui/src/elements/div.rs                   |   6 
crates/gpui/src/elements/text.rs                  | 128 ++++++++++++++++
crates/language/src/language_settings.rs          |   8 +
crates/rich_text/Cargo.toml                       |   1 
crates/rich_text/src/rich_text.rs                 |  15 +
crates/ui/src/components/tooltip.rs               |  91 +++++++++---
11 files changed, 242 insertions(+), 43 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6145,6 +6145,7 @@ dependencies = [
  "smol",
  "sum_tree",
  "theme",
+ "ui",
  "util",
 ]
 

assets/settings/default.json 🔗

@@ -72,6 +72,9 @@
   // Whether to use additional LSP queries to format (and amend) the code after
   // every "trigger" symbol input, defined by LSP server capabilities.
   "use_on_type_format": true,
+  // Whether to automatically type closing characters for you. For example,
+  // when you type (, Zed will automatically add a closing ) at the correct position.
+  "use_autoclose": true,
   // Controls whether copilot provides suggestion immediately
   // or waits for a `copilot::Toggle`
   "show_copilot_suggestions": true,

crates/collab_ui/src/chat_panel.rs 🔗

@@ -349,15 +349,13 @@ impl ChatPanel {
             .when(!is_continuation_from_previous, |this| {
                 this.pt_3().child(
                     h_flex()
-                        .child(
-                            div().absolute().child(
-                                Avatar::new(message.sender.avatar_uri.clone())
-                                    .size(cx.rem_size() * 1.5),
-                            ),
-                        )
+                        .text_ui_sm()
+                        .child(div().absolute().child(
+                            Avatar::new(message.sender.avatar_uri.clone()).size(cx.rem_size()),
+                        ))
                         .child(
                             div()
-                                .pl(cx.rem_size() * 1.5 + px(6.0))
+                                .pl(cx.rem_size() + px(6.0))
                                 .pr(px(8.0))
                                 .font_weight(FontWeight::BOLD)
                                 .child(Label::new(message.sender.github_login.clone())),
@@ -597,7 +595,7 @@ impl Render for ChatPanel {
                             el.child(
                                 div()
                                     .rounded_md()
-                                    .h_7()
+                                    .h_6()
                                     .w_full()
                                     .bg(cx.theme().colors().editor_background),
                             )

crates/collab_ui/src/chat_panel/message_editor.rs 🔗

@@ -18,7 +18,7 @@ use project::search::SearchQuery;
 use settings::Settings;
 use std::{sync::Arc, time::Duration};
 use theme::ThemeSettings;
-use ui::prelude::*;
+use ui::{prelude::*, UiTextSize};
 
 const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
 
@@ -83,6 +83,7 @@ impl MessageEditor {
         let this = cx.view().downgrade();
         editor.update(cx, |editor, cx| {
             editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+            editor.set_use_autoclose(false);
             editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
         });
 
@@ -325,7 +326,7 @@ impl Render for MessageEditor {
             },
             font_family: settings.ui_font.family.clone(),
             font_features: settings.ui_font.features,
-            font_size: rems(0.875).into(),
+            font_size: UiTextSize::Small.rems().into(),
             font_weight: FontWeight::NORMAL,
             font_style: FontStyle::Normal,
             line_height: relative(1.3).into(),

crates/editor/src/editor.rs 🔗

@@ -409,6 +409,7 @@ pub struct Editor {
     style: Option<EditorStyle>,
     editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
     show_copilot_suggestions: bool,
+    use_autoclose: bool,
 }
 
 pub struct EditorSnapshot {
@@ -1411,6 +1412,7 @@ impl Editor {
             keymap_context_layers: Default::default(),
             input_enabled: true,
             read_only: false,
+            use_autoclose: true,
             leader_peer_id: None,
             remote_id: None,
             hover_state: Default::default(),
@@ -1692,6 +1694,10 @@ impl Editor {
         self.read_only = read_only;
     }
 
+    pub fn set_use_autoclose(&mut self, autoclose: bool) {
+        self.use_autoclose = autoclose;
+    }
+
     pub fn set_show_copilot_suggestions(&mut self, show_copilot_suggestions: bool) {
         self.show_copilot_suggestions = show_copilot_suggestions;
     }
@@ -2290,7 +2296,12 @@ impl Editor {
                                         ),
                                         &bracket_pair.start[..prefix_len],
                                     ));
-                            if following_text_allows_autoclose && preceding_text_matches_prefix {
+                            let autoclose = self.use_autoclose
+                                && snapshot.settings_at(selection.start, cx).use_autoclose;
+                            if autoclose
+                                && following_text_allows_autoclose
+                                && preceding_text_matches_prefix
+                            {
                                 let anchor = snapshot.anchor_before(selection.end);
                                 new_selections.push((selection.map(|_| anchor), text.len()));
                                 new_autoclose_regions.push((

crates/gpui/src/elements/div.rs 🔗

@@ -24,7 +24,7 @@ use taffy::style::Overflow;
 use util::ResultExt;
 
 const DRAG_THRESHOLD: f64 = 2.;
-const TOOLTIP_DELAY: Duration = Duration::from_millis(500);
+pub(crate) const TOOLTIP_DELAY: Duration = Duration::from_millis(500);
 
 pub struct GroupStyle {
     pub group: SharedString,
@@ -1718,8 +1718,8 @@ pub struct InteractiveElementState {
 }
 
 pub struct ActiveTooltip {
-    tooltip: Option<AnyTooltip>,
-    _task: Option<Task<()>>,
+    pub(crate) tooltip: Option<AnyTooltip>,
+    pub(crate) _task: Option<Task<()>>,
 }
 
 /// Whether or not the element or a group that contains it is clicked by the mouse.

crates/gpui/src/elements/text.rs 🔗

@@ -1,12 +1,18 @@
 use crate::{
-    Bounds, DispatchPhase, Element, ElementId, HighlightStyle, IntoElement, LayoutId,
-    MouseDownEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextRun, TextStyle,
-    WhiteSpace, WindowContext, WrappedLine,
+    ActiveTooltip, AnyTooltip, AnyView, Bounds, DispatchPhase, Element, ElementId, HighlightStyle,
+    IntoElement, LayoutId, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Point,
+    SharedString, Size, TextRun, TextStyle, WhiteSpace, WindowContext, WrappedLine, TOOLTIP_DELAY,
 };
 use anyhow::anyhow;
 use parking_lot::{Mutex, MutexGuard};
 use smallvec::SmallVec;
-use std::{cell::Cell, mem, ops::Range, rc::Rc, sync::Arc};
+use std::{
+    cell::{Cell, RefCell},
+    mem,
+    ops::Range,
+    rc::Rc,
+    sync::Arc,
+};
 use util::ResultExt;
 
 impl Element for &'static str {
@@ -289,6 +295,8 @@ pub struct InteractiveText {
     text: StyledText,
     click_listener:
         Option<Box<dyn Fn(&[Range<usize>], InteractiveTextClickEvent, &mut WindowContext<'_>)>>,
+    hover_listener: Option<Box<dyn Fn(Option<usize>, MouseMoveEvent, &mut WindowContext<'_>)>>,
+    tooltip_builder: Option<Rc<dyn Fn(usize, &mut WindowContext<'_>) -> Option<AnyView>>>,
     clickable_ranges: Vec<Range<usize>>,
 }
 
@@ -300,18 +308,25 @@ struct InteractiveTextClickEvent {
 pub struct InteractiveTextState {
     text_state: TextState,
     mouse_down_index: Rc<Cell<Option<usize>>>,
+    hovered_index: Rc<Cell<Option<usize>>>,
+    active_tooltip: Rc<RefCell<Option<ActiveTooltip>>>,
 }
 
+/// InteractiveTest is a wrapper around StyledText that adds mouse interactions.
 impl InteractiveText {
     pub fn new(id: impl Into<ElementId>, text: StyledText) -> Self {
         Self {
             element_id: id.into(),
             text,
             click_listener: None,
+            hover_listener: None,
+            tooltip_builder: None,
             clickable_ranges: Vec::new(),
         }
     }
 
+    /// on_click is called when the user clicks on one of the given ranges, passing the index of
+    /// the clicked range.
     pub fn on_click(
         mut self,
         ranges: Vec<Range<usize>>,
@@ -328,6 +343,25 @@ impl InteractiveText {
         self.clickable_ranges = ranges;
         self
     }
+
+    /// on_hover is called when the mouse moves over a character within the text, passing the
+    /// index of the hovered character, or None if the mouse leaves the text.
+    pub fn on_hover(
+        mut self,
+        listener: impl Fn(Option<usize>, MouseMoveEvent, &mut WindowContext<'_>) + 'static,
+    ) -> Self {
+        self.hover_listener = Some(Box::new(listener));
+        self
+    }
+
+    /// tooltip lets you specify a tooltip for a given character index in the string.
+    pub fn tooltip(
+        mut self,
+        builder: impl Fn(usize, &mut WindowContext<'_>) -> Option<AnyView> + 'static,
+    ) -> Self {
+        self.tooltip_builder = Some(Rc::new(builder));
+        self
+    }
 }
 
 impl Element for InteractiveText {
@@ -339,13 +373,18 @@ impl Element for InteractiveText {
         cx: &mut WindowContext,
     ) -> (LayoutId, Self::State) {
         if let Some(InteractiveTextState {
-            mouse_down_index, ..
+            mouse_down_index,
+            hovered_index,
+            active_tooltip,
+            ..
         }) = state
         {
             let (layout_id, text_state) = self.text.request_layout(None, cx);
             let element_state = InteractiveTextState {
                 text_state,
                 mouse_down_index,
+                hovered_index,
+                active_tooltip,
             };
             (layout_id, element_state)
         } else {
@@ -353,6 +392,8 @@ impl Element for InteractiveText {
             let element_state = InteractiveTextState {
                 text_state,
                 mouse_down_index: Rc::default(),
+                hovered_index: Rc::default(),
+                active_tooltip: Rc::default(),
             };
             (layout_id, element_state)
         }
@@ -408,6 +449,83 @@ impl Element for InteractiveText {
                 });
             }
         }
+        if let Some(hover_listener) = self.hover_listener.take() {
+            let text_state = state.text_state.clone();
+            let hovered_index = state.hovered_index.clone();
+            cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
+                if phase == DispatchPhase::Bubble {
+                    let current = hovered_index.get();
+                    let updated = text_state.index_for_position(bounds, event.position);
+                    if current != updated {
+                        hovered_index.set(updated);
+                        hover_listener(updated, event.clone(), cx);
+                        cx.refresh();
+                    }
+                }
+            });
+        }
+        if let Some(tooltip_builder) = self.tooltip_builder.clone() {
+            let active_tooltip = state.active_tooltip.clone();
+            let pending_mouse_down = state.mouse_down_index.clone();
+            let text_state = state.text_state.clone();
+
+            cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| {
+                let position = text_state.index_for_position(bounds, event.position);
+                let is_hovered = position.is_some() && pending_mouse_down.get().is_none();
+                if !is_hovered {
+                    active_tooltip.take();
+                    return;
+                }
+                let position = position.unwrap();
+
+                if phase != DispatchPhase::Bubble {
+                    return;
+                }
+
+                if active_tooltip.borrow().is_none() {
+                    let task = cx.spawn({
+                        let active_tooltip = active_tooltip.clone();
+                        let tooltip_builder = tooltip_builder.clone();
+
+                        move |mut cx| async move {
+                            cx.background_executor().timer(TOOLTIP_DELAY).await;
+                            cx.update(|_, cx| {
+                                let new_tooltip =
+                                    tooltip_builder(position, cx).map(|tooltip| ActiveTooltip {
+                                        tooltip: Some(AnyTooltip {
+                                            view: tooltip,
+                                            cursor_offset: cx.mouse_position(),
+                                        }),
+                                        _task: None,
+                                    });
+                                *active_tooltip.borrow_mut() = new_tooltip;
+                                cx.refresh();
+                            })
+                            .ok();
+                        }
+                    });
+                    *active_tooltip.borrow_mut() = Some(ActiveTooltip {
+                        tooltip: None,
+                        _task: Some(task),
+                    });
+                }
+            });
+
+            let active_tooltip = state.active_tooltip.clone();
+            cx.on_mouse_event(move |_: &MouseDownEvent, _, _| {
+                active_tooltip.take();
+            });
+
+            if let Some(tooltip) = state
+                .active_tooltip
+                .clone()
+                .borrow()
+                .as_ref()
+                .and_then(|at| at.tooltip.clone())
+            {
+                cx.set_tooltip(tooltip);
+            }
+        }
 
         self.text.paint(bounds, &mut state.text_state, cx)
     }

crates/language/src/language_settings.rs 🔗

@@ -91,6 +91,8 @@ pub struct LanguageSettings {
     pub extend_comment_on_newline: bool,
     /// Inlay hint related settings.
     pub inlay_hints: InlayHintSettings,
+    /// Whether to automatically close brackets.
+    pub use_autoclose: bool,
 }
 
 /// The settings for [GitHub Copilot](https://github.com/features/copilot).
@@ -208,6 +210,11 @@ pub struct LanguageSettingsContent {
     /// Inlay hint related settings.
     #[serde(default)]
     pub inlay_hints: Option<InlayHintSettings>,
+    /// Whether to automatically type closing characters for you. For example,
+    /// when you type (, Zed will automatically add a closing ) at the correct position.
+    ///
+    /// Default: true
+    pub use_autoclose: Option<bool>,
 }
 
 /// The contents of the GitHub Copilot settings.
@@ -540,6 +547,7 @@ fn merge_settings(settings: &mut LanguageSettings, src: &LanguageSettingsContent
     merge(&mut settings.tab_size, src.tab_size);
     merge(&mut settings.hard_tabs, src.hard_tabs);
     merge(&mut settings.soft_wrap, src.soft_wrap);
+    merge(&mut settings.use_autoclose, src.use_autoclose);
     merge(&mut settings.show_wrap_guides, src.show_wrap_guides);
     merge(&mut settings.wrap_guides, src.wrap_guides.clone());
 

crates/rich_text/Cargo.toml 🔗

@@ -21,6 +21,7 @@ sum_tree = { path = "../sum_tree" }
 theme = { path = "../theme" }
 language = { path = "../language" }
 util = { path = "../util" }
+ui = { path = "../ui" }
 anyhow.workspace = true
 futures.workspace = true
 lazy_static.workspace = true

crates/rich_text/src/rich_text.rs 🔗

@@ -6,6 +6,7 @@ use gpui::{
 use language::{HighlightId, Language, LanguageRegistry};
 use std::{ops::Range, sync::Arc};
 use theme::ActiveTheme;
+use ui::LinkPreview;
 use util::RangeExt;
 
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -84,6 +85,18 @@ impl RichText {
             let link_urls = self.link_urls.clone();
             move |ix, cx| cx.open_url(&link_urls[ix])
         })
+        .tooltip({
+            let link_ranges = self.link_ranges.clone();
+            let link_urls = self.link_urls.clone();
+            move |idx, cx| {
+                for (ix, range) in link_ranges.iter().enumerate() {
+                    if range.contains(&idx) {
+                        return Some(LinkPreview::new(&link_urls[ix], cx));
+                    }
+                }
+                None
+            }
+        })
         .into_any_element()
     }
 }
@@ -237,7 +250,7 @@ pub fn render_markdown_mut(
                 _ => {}
             },
             Event::HardBreak => text.push('\n'),
-            Event::SoftBreak => text.push(' '),
+            Event::SoftBreak => text.push('\n'),
             _ => {}
         }
     }

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

@@ -69,29 +69,74 @@ impl Tooltip {
 
 impl Render for Tooltip {
     fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
-        let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
-        overlay().child(
-            // padding to avoid mouse cursor
-            div().pl_2().pt_2p5().child(
-                v_flex()
-                    .elevation_2(cx)
-                    .font(ui_font)
-                    .text_ui()
-                    .text_color(cx.theme().colors().text)
-                    .py_1()
-                    .px_2()
-                    .child(
-                        h_flex()
-                            .gap_4()
-                            .child(self.title.clone())
-                            .when_some(self.key_binding.clone(), |this, key_binding| {
-                                this.justify_between().child(key_binding)
-                            }),
-                    )
-                    .when_some(self.meta.clone(), |this, meta| {
-                        this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted))
+        tooltip_container(cx, |el, _| {
+            el.child(
+                h_flex()
+                    .gap_4()
+                    .child(self.title.clone())
+                    .when_some(self.key_binding.clone(), |this, key_binding| {
+                        this.justify_between().child(key_binding)
                     }),
-            ),
-        )
+            )
+            .when_some(self.meta.clone(), |this, meta| {
+                this.child(Label::new(meta).size(LabelSize::Small).color(Color::Muted))
+            })
+        })
+    }
+}
+
+fn tooltip_container<V>(
+    cx: &mut ViewContext<V>,
+    f: impl FnOnce(Div, &mut ViewContext<V>) -> Div,
+) -> impl IntoElement {
+    let ui_font = ThemeSettings::get_global(cx).ui_font.family.clone();
+    overlay().child(
+        // padding to avoid mouse cursor
+        div().pl_2().pt_2p5().child(
+            v_flex()
+                .elevation_2(cx)
+                .font(ui_font)
+                .text_ui()
+                .text_color(cx.theme().colors().text)
+                .py_1()
+                .px_2()
+                .map(|el| f(el, cx)),
+        ),
+    )
+}
+
+pub struct LinkPreview {
+    link: SharedString,
+}
+
+impl LinkPreview {
+    pub fn new(url: &str, cx: &mut WindowContext) -> AnyView {
+        let mut wrapped_url = String::new();
+        for (i, ch) in url.chars().enumerate() {
+            if i == 500 {
+                wrapped_url.push('…');
+                break;
+            }
+            if i % 100 == 0 && i != 0 {
+                wrapped_url.push('\n');
+            }
+            wrapped_url.push(ch);
+        }
+        cx.new_view(|_cx| LinkPreview {
+            link: wrapped_url.into(),
+        })
+        .into()
+    }
+}
+
+impl Render for LinkPreview {
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        tooltip_container(cx, |el, _| {
+            el.child(
+                Label::new(self.link.clone())
+                    .size(LabelSize::XSmall)
+                    .color(Color::Muted),
+            )
+        })
     }
 }