Open URIs from the CLI, support for the `zed://` URI scheme on Linux (#14104)

Cappy Ishihara and Conrad Irwin created

Allows Zed to open custom `zed://` links (redirects from
https://zed.dev/channels) on Linux used XDG MIME types.

This PR also allows the CLI to be able to open Zed (`zed://`) URIs
directly instead of executing the main executable in
`/usr/libexec/zed-editor`.


Release Notes:

- Linux: Allow `zed.dev/channel` (`zed://`) URIs to open on Linux
- CLI: Ability to open URIs from the command line

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/cli/src/cli.rs               |  1 
crates/cli/src/main.rs              | 66 +++++++++++++++++-------------
crates/zed/resources/zed.desktop.in |  4 
crates/zed/src/zed/open_listener.rs | 24 ++++++++++
4 files changed, 64 insertions(+), 31 deletions(-)

Detailed changes

crates/cli/src/cli.rs 🔗

@@ -11,6 +11,7 @@ pub struct IpcHandshake {
 pub enum CliRequest {
     Open {
         paths: Vec<String>,
+        urls: Vec<String>,
         wait: bool,
         open_new_workspace: Option<bool>,
         dev_server_token: Option<String>,

crates/cli/src/main.rs 🔗

@@ -5,6 +5,7 @@ use clap::Parser;
 use cli::{ipc::IpcOneShotServer, CliRequest, CliResponse, IpcHandshake};
 use parking_lot::Mutex;
 use std::{
+    convert::Infallible,
     env, fs, io,
     path::{Path, PathBuf},
     process::ExitStatus,
@@ -37,8 +38,7 @@ struct Args {
     ///
     /// Use `path:line:row` syntax to open a file at a specific location.
     /// Non-existing paths and directories will ignore `:line:row` suffix.
-    #[arg(value_parser = parse_path_with_position)]
-    paths_with_position: Vec<PathLikeWithPosition<PathBuf>>,
+    paths_with_position: Vec<String>,
     /// Print Zed's version and the app path.
     #[arg(short, long)]
     version: bool,
@@ -53,12 +53,30 @@ struct Args {
     dev_server_token: Option<String>,
 }
 
-fn parse_path_with_position(
-    argument_str: &str,
-) -> Result<PathLikeWithPosition<PathBuf>, std::convert::Infallible> {
-    PathLikeWithPosition::parse_str(argument_str, |_, path_str| {
+fn parse_path_with_position(argument_str: &str) -> Result<String, std::io::Error> {
+    let path_like = PathLikeWithPosition::parse_str::<Infallible>(argument_str, |_, path_str| {
         Ok(Path::new(path_str).to_path_buf())
     })
+    .unwrap();
+    let curdir = env::current_dir()?;
+
+    let canonicalized = path_like.map_path_like(|path| match fs::canonicalize(&path) {
+        Ok(path) => Ok(path),
+        Err(e) => {
+            if let Some(mut parent) = path.parent() {
+                if parent == Path::new("") {
+                    parent = &curdir
+                }
+                match fs::canonicalize(parent) {
+                    Ok(parent) => Ok(parent.join(path.file_name().unwrap())),
+                    Err(_) => Err(e),
+                }
+            } else {
+                Err(e)
+            }
+        }
+    })?;
+    Ok(canonicalized.to_string(|path| path.display().to_string()))
 }
 
 fn main() -> Result<()> {
@@ -91,28 +109,6 @@ fn main() -> Result<()> {
         return Ok(());
     }
 
-    let curdir = env::current_dir()?;
-    let mut paths = vec![];
-    for path in args.paths_with_position {
-        let canonicalized = path.map_path_like(|path| match fs::canonicalize(&path) {
-            Ok(path) => Ok(path),
-            Err(e) => {
-                if let Some(mut parent) = path.parent() {
-                    if parent == Path::new("") {
-                        parent = &curdir;
-                    }
-                    match fs::canonicalize(parent) {
-                        Ok(parent) => Ok(parent.join(path.file_name().unwrap())),
-                        Err(_) => Err(e),
-                    }
-                } else {
-                    Err(e)
-                }
-            }
-        })?;
-        paths.push(canonicalized.to_string(|path| path.display().to_string()))
-    }
-
     let (server, server_name) =
         IpcOneShotServer::<IpcHandshake>::new().context("Handshake before Zed spawn")?;
     let url = format!("zed-cli://{server_name}");
@@ -126,6 +122,19 @@ fn main() -> Result<()> {
     };
 
     let exit_status = Arc::new(Mutex::new(None));
+    let mut paths = vec![];
+    let mut urls = vec![];
+    for path in args.paths_with_position.iter() {
+        if path.starts_with("zed://")
+            || path.starts_with("http://")
+            || path.starts_with("https://")
+            || path.starts_with("file://")
+        {
+            urls.push(path.to_string());
+        } else {
+            paths.push(parse_path_with_position(path)?)
+        }
+    }
 
     let sender: JoinHandle<anyhow::Result<()>> = thread::spawn({
         let exit_status = exit_status.clone();
@@ -134,6 +143,7 @@ fn main() -> Result<()> {
             let (tx, rx) = (handshake.requests, handshake.responses);
             tx.send(CliRequest::Open {
                 paths,
+                urls,
                 wait: args.wait,
                 open_new_workspace,
                 dev_server_token: args.dev_server_token,

crates/zed/resources/zed.desktop.in 🔗

@@ -4,13 +4,13 @@ Type=Application
 Name=$APP_NAME
 GenericName=Text Editor
 Comment=A high-performance, multiplayer code editor.
-TryExec=$APP_CLI
+TryExec=$APP
 StartupNotify=$DO_STARTUP_NOTIFY
 Exec=$APP_CLI $APP_ARGS
 Icon=$APP_ICON
 Categories=Utility;TextEditor;Development;IDE;
 Keywords=zed;
-MimeType=text/plain;inode/directory;
+MimeType=text/plain;inode/directory;x-scheme-handler/zed;
 Actions=NewWorkspace;
 
 [Desktop Action NewWorkspace]

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

@@ -22,7 +22,7 @@ use welcome::{show_welcome_view, FIRST_OPEN};
 use workspace::item::ItemHandle;
 use workspace::{AppState, Workspace};
 
-use crate::{init_headless, init_ui};
+use crate::{handle_open_request, init_headless, init_ui};
 
 #[derive(Default, Debug)]
 pub struct OpenRequest {
@@ -223,6 +223,7 @@ pub async fn handle_cli_connection(
     if let Some(request) = requests.next().await {
         match request {
             CliRequest::Open {
+                urls,
                 paths,
                 wait,
                 open_new_workspace,
@@ -257,6 +258,27 @@ pub async fn handle_cli_connection(
                     return;
                 }
 
+                if !urls.is_empty() {
+                    cx.update(|cx| {
+                        match OpenRequest::parse(urls, cx) {
+                            Ok(open_request) => {
+                                handle_open_request(open_request, app_state.clone(), cx);
+                                responses.send(CliResponse::Exit { status: 0 }).log_err();
+                            }
+                            Err(e) => {
+                                responses
+                                    .send(CliResponse::Stderr {
+                                        message: format!("{e}"),
+                                    })
+                                    .log_err();
+                                responses.send(CliResponse::Exit { status: 1 }).log_err();
+                            }
+                        };
+                    })
+                    .log_err();
+                    return;
+                }
+
                 if let Err(e) = cx
                     .update(|cx| init_ui(app_state.clone(), cx))
                     .and_then(|r| r)