Fix link opening (#44910)

Conrad Irwin and Zed Zippy created

- **Fix editor::OpenUrl on zed links**
- **Fix cmd-clicking links too**

Closes #44293
Closes #43833

Release Notes:

- The `editor::OpenUrl` action now works for links to https://zed.dev
- Clicking on a link to a Zed channel or channel-note within the editor
no-longer redirects you via the web.

---------

Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>

Change summary

crates/client/src/client.rs         | 58 +++++++++++++++++++++++++-----
crates/editor/src/editor.rs         |  9 ++++
crates/zed/src/zed/open_listener.rs | 41 ++++++--------------
3 files changed, 68 insertions(+), 40 deletions(-)

Detailed changes

crates/client/src/client.rs 🔗

@@ -1730,23 +1730,59 @@ impl ProtoClient for Client {
 /// prefix for the zed:// url scheme
 pub const ZED_URL_SCHEME: &str = "zed";
 
+/// A parsed Zed link that can be handled internally by the application.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ZedLink {
+    /// Join a channel: `zed.dev/channel/channel-name-123` or `zed://channel/channel-name-123`
+    Channel { channel_id: u64 },
+    /// Open channel notes: `zed.dev/channel/channel-name-123/notes` or with heading `notes#heading`
+    ChannelNotes {
+        channel_id: u64,
+        heading: Option<String>,
+    },
+}
+
 /// Parses the given link into a Zed link.
 ///
-/// Returns a [`Some`] containing the unprefixed link if the link is a Zed link.
-/// Returns [`None`] otherwise.
-pub fn parse_zed_link<'a>(link: &'a str, cx: &App) -> Option<&'a str> {
+/// Returns a [`Some`] containing the parsed link if the link is a recognized Zed link
+/// that should be handled internally by the application.
+/// Returns [`None`] for links that should be opened in the browser.
+pub fn parse_zed_link(link: &str, cx: &App) -> Option<ZedLink> {
     let server_url = &ClientSettings::get_global(cx).server_url;
-    if let Some(stripped) = link
+    let path = link
         .strip_prefix(server_url)
         .and_then(|result| result.strip_prefix('/'))
-    {
-        return Some(stripped);
+        .or_else(|| {
+            link.strip_prefix(ZED_URL_SCHEME)
+                .and_then(|result| result.strip_prefix("://"))
+        })?;
+
+    let mut parts = path.split('/');
+
+    if parts.next() != Some("channel") {
+        return None;
     }
-    if let Some(stripped) = link
-        .strip_prefix(ZED_URL_SCHEME)
-        .and_then(|result| result.strip_prefix("://"))
-    {
-        return Some(stripped);
+
+    let slug = parts.next()?;
+    let id_str = slug.split('-').next_back()?;
+    let channel_id = id_str.parse::<u64>().ok()?;
+
+    let Some(next) = parts.next() else {
+        return Some(ZedLink::Channel { channel_id });
+    };
+
+    if let Some(heading) = next.strip_prefix("notes#") {
+        return Some(ZedLink::ChannelNotes {
+            channel_id,
+            heading: Some(heading.to_string()),
+        });
+    }
+
+    if next == "notes" {
+        return Some(ZedLink::ChannelNotes {
+            channel_id,
+            heading: None,
+        });
     }
 
     None

crates/editor/src/editor.rs 🔗

@@ -17467,7 +17467,14 @@ impl Editor {
                 // If there is one url or file, open it directly
                 match first_url_or_file {
                     Some(Either::Left(url)) => {
-                        cx.update(|_, cx| cx.open_url(&url))?;
+                        cx.update(|window, cx| {
+                            if parse_zed_link(&url, cx).is_some() {
+                                window
+                                    .dispatch_action(Box::new(zed_actions::OpenZedUrl { url }), cx);
+                            } else {
+                                cx.open_url(&url);
+                            }
+                        })?;
                         Ok(Navigated::Yes)
                     }
                     Some(Either::Right(path)) => {

crates/zed/src/zed/open_listener.rs 🔗

@@ -3,7 +3,7 @@ use crate::restorable_workspace_locations;
 use anyhow::{Context as _, Result, anyhow};
 use cli::{CliRequest, CliResponse, ipc::IpcSender};
 use cli::{IpcHandshake, ipc};
-use client::parse_zed_link;
+use client::{ZedLink, parse_zed_link};
 use collections::HashMap;
 use db::kvp::KEY_VALUE_STORE;
 use editor::Editor;
@@ -112,8 +112,18 @@ impl OpenRequest {
                 });
             } else if url.starts_with("ssh://") {
                 this.parse_ssh_file_path(&url, cx)?
-            } else if let Some(request_path) = parse_zed_link(&url, cx) {
-                this.parse_request_path(request_path).log_err();
+            } else if let Some(zed_link) = parse_zed_link(&url, cx) {
+                match zed_link {
+                    ZedLink::Channel { channel_id } => {
+                        this.join_channel = Some(channel_id);
+                    }
+                    ZedLink::ChannelNotes {
+                        channel_id,
+                        heading,
+                    } => {
+                        this.open_channel_notes.push((channel_id, heading));
+                    }
+                }
             } else {
                 log::error!("unhandled url: {}", url);
             }
@@ -157,31 +167,6 @@ impl OpenRequest {
         self.parse_file_path(url.path());
         Ok(())
     }
-
-    fn parse_request_path(&mut self, request_path: &str) -> Result<()> {
-        let mut parts = request_path.split('/');
-        if parts.next() == Some("channel")
-            && let Some(slug) = parts.next()
-            && let Some(id_str) = slug.split('-').next_back()
-            && let Ok(channel_id) = id_str.parse::<u64>()
-        {
-            let Some(next) = parts.next() else {
-                self.join_channel = Some(channel_id);
-                return Ok(());
-            };
-
-            if let Some(heading) = next.strip_prefix("notes#") {
-                self.open_channel_notes
-                    .push((channel_id, Some(heading.to_string())));
-                return Ok(());
-            }
-            if next == "notes" {
-                self.open_channel_notes.push((channel_id, None));
-                return Ok(());
-            }
-        }
-        anyhow::bail!("invalid zed url: {request_path}")
-    }
 }
 
 #[derive(Clone)]