Improve editor open URL command to open the selected portion of URL (#21825)

tims created

Closes #21718

Just like in Vim, if a URL is selected, it opens exactly that portion of
the URL. Otherwise, if only the cursor is on a URL, it opens the entire
URL.

Zed currently does the latter. This PR also adds support for the former.


https://github.com/user-attachments/assets/8bdd2952-ceec-487c-b27a-5cea4258eb03

Release Notes:

- Updated the `editor: open url` to also handle the selected portion of
a URL.

Change summary

crates/editor/src/editor.rs      | 35 +++++++++++++++----
crates/editor/src/hover_links.rs | 59 ++++++++++++++++++++++++++++++++++
2 files changed, 86 insertions(+), 8 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -176,7 +176,7 @@ use workspace::{
 };
 use workspace::{Item as WorkspaceItem, OpenInTerminal, OpenTerminal, TabBarSettings, Toast};
 
-use crate::hover_links::find_url;
+use crate::hover_links::{find_url, find_url_from_range};
 use crate::signature_help::{SignatureHelpHiddenBy, SignatureHelpState};
 
 pub const FILE_HEADER_HEIGHT: u32 = 2;
@@ -9293,23 +9293,42 @@ impl Editor {
     }
 
     pub fn open_url(&mut self, _: &OpenUrl, cx: &mut ViewContext<Self>) {
-        let position = self.selections.newest_anchor().head();
-        let Some((buffer, buffer_position)) =
-            self.buffer.read(cx).text_anchor_for_position(position, cx)
+        let selection = self.selections.newest_anchor();
+        let head = selection.head();
+        let tail = selection.tail();
+
+        let Some((buffer, start_position)) =
+            self.buffer.read(cx).text_anchor_for_position(head, cx)
         else {
             return;
         };
 
-        cx.spawn(|editor, mut cx| async move {
-            if let Some((_, url)) = find_url(&buffer, buffer_position, cx.clone()) {
+        let end_position = if head != tail {
+            let Some((_, pos)) = self.buffer.read(cx).text_anchor_for_position(tail, cx) else {
+                return;
+            };
+            Some(pos)
+        } else {
+            None
+        };
+
+        let url_finder = cx.spawn(|editor, mut cx| async move {
+            let url = if let Some(end_pos) = end_position {
+                find_url_from_range(&buffer, start_position..end_pos, cx.clone())
+            } else {
+                find_url(&buffer, start_position, cx.clone()).map(|(_, url)| url)
+            };
+
+            if let Some(url) = url {
                 editor.update(&mut cx, |_, cx| {
                     cx.open_url(&url);
                 })
             } else {
                 Ok(())
             }
-        })
-        .detach();
+        });
+
+        url_finder.detach();
     }
 
     pub fn open_file(&mut self, _: &OpenFile, cx: &mut ViewContext<Self>) {

crates/editor/src/hover_links.rs 🔗

@@ -694,6 +694,65 @@ pub(crate) fn find_url(
     None
 }
 
+pub(crate) fn find_url_from_range(
+    buffer: &Model<language::Buffer>,
+    range: Range<text::Anchor>,
+    mut cx: AsyncWindowContext,
+) -> Option<String> {
+    const LIMIT: usize = 2048;
+
+    let Ok(snapshot) = buffer.update(&mut cx, |buffer, _| buffer.snapshot()) else {
+        return None;
+    };
+
+    let start_offset = range.start.to_offset(&snapshot);
+    let end_offset = range.end.to_offset(&snapshot);
+
+    let mut token_start = start_offset.min(end_offset);
+    let mut token_end = start_offset.max(end_offset);
+
+    let range_len = token_end - token_start;
+
+    if range_len >= LIMIT {
+        return None;
+    }
+
+    // Skip leading whitespace
+    for ch in snapshot.chars_at(token_start).take(range_len) {
+        if !ch.is_whitespace() {
+            break;
+        }
+        token_start += ch.len_utf8();
+    }
+
+    // Skip trailing whitespace
+    for ch in snapshot.reversed_chars_at(token_end).take(range_len) {
+        if !ch.is_whitespace() {
+            break;
+        }
+        token_end -= ch.len_utf8();
+    }
+
+    if token_start >= token_end {
+        return None;
+    }
+
+    let text = snapshot
+        .text_for_range(token_start..token_end)
+        .collect::<String>();
+
+    let mut finder = LinkFinder::new();
+    finder.kinds(&[LinkKind::Url]);
+
+    if let Some(link) = finder.links(&text).next() {
+        if link.start() == 0 && link.end() == text.len() {
+            return Some(link.as_str().to_string());
+        }
+    }
+
+    None
+}
+
 pub(crate) async fn find_file(
     buffer: &Model<language::Buffer>,
     project: Option<Model<Project>>,