Code to allow opening zed:/channel/1234

Conrad Irwin created

Refactored a bit how url arguments are handled to avoid adding too much
extra complexity to main.

Change summary

crates/cli/src/main.rs               |   1 
crates/collab_ui/src/collab_panel.rs |  83 ++++++------------
crates/util/src/channel.rs           |  17 --
crates/workspace/src/workspace.rs    |  82 +++++++++++++++++
crates/zed/src/main.rs               | 137 ++++++++++++-----------------
crates/zed/src/open_url.rs           | 101 ++++++++++++++++++++++
6 files changed, 272 insertions(+), 149 deletions(-)

Detailed changes

crates/cli/src/main.rs 🔗

@@ -182,6 +182,7 @@ impl Bundle {
                         kCFStringEncodingUTF8,
                         ptr::null(),
                     ));
+                    // equivalent to: open zed-cli:... -a /Applications/Zed\ Preview.app
                     let urls_to_open = CFArray::from_copyable(&[url_to_open.as_concrete_TypeRef()]);
                     LSOpenFromURLSpec(
                         &LSLaunchURLSpec {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -1969,18 +1969,21 @@ impl CollabPanel {
                     let style = collab_theme.channel_name.inactive_state();
                     Flex::row()
                         .with_child(
-                            Label::new(channel.name.clone(), style.text.clone())
-                                .contained()
-                                .with_style(style.container)
-                                .aligned()
-                                .left()
-                                .with_tooltip::<ChannelTooltip>(
-                                    ix,
-                                    "Join channel",
-                                    None,
-                                    theme.tooltip.clone(),
-                                    cx,
-                                ),
+                            Label::new(
+                                channel.name.clone().to_owned() + channel_id.to_string().as_str(),
+                                style.text.clone(),
+                            )
+                            .contained()
+                            .with_style(style.container)
+                            .aligned()
+                            .left()
+                            .with_tooltip::<ChannelTooltip>(
+                                ix,
+                                "Join channel",
+                                None,
+                                theme.tooltip.clone(),
+                                cx,
+                            ),
                         )
                         .with_children({
                             let participants =
@@ -3187,49 +3190,19 @@ impl CollabPanel {
     }
 
     fn join_channel(&self, channel_id: u64, cx: &mut ViewContext<Self>) {
-        let workspace = self.workspace.clone();
-        let window = cx.window();
-        let active_call = ActiveCall::global(cx);
-        cx.spawn(|_, mut cx| async move {
-            if active_call.read_with(&mut cx, |active_call, cx| {
-                if let Some(room) = active_call.room() {
-                    let room = room.read(cx);
-                    room.is_sharing_project() && room.remote_participants().len() > 0
-                } else {
-                    false
-                }
-            }) {
-                let answer = window.prompt(
-                    PromptLevel::Warning,
-                    "Leaving this call will unshare your current project.\nDo you want to switch channels?",
-                    &["Yes, Join Channel", "Cancel"],
-                    &mut cx,
-                );
-
-                if let Some(mut answer) = answer {
-                    if answer.next().await == Some(1) {
-                        return anyhow::Ok(());
-                    }
-                }
-            }
-
-            let room = active_call
-                .update(&mut cx, |call, cx| call.join_channel(channel_id, cx))
-                .await?;
-
-            let task = room.update(&mut cx, |room, cx| {
-                let workspace = workspace.upgrade(cx)?;
-                let (project, host) = room.most_active_project()?;
-                let app_state = workspace.read(cx).app_state().clone();
-                Some(workspace::join_remote_project(project, host, app_state, cx))
-            });
-            if let Some(task) = task {
-                task.await?;
-            }
-
-            anyhow::Ok(())
-        })
-        .detach_and_log_err(cx);
+        let Some(workspace) = self.workspace.upgrade(cx) else {
+            return;
+        };
+        let Some(handle) = cx.window().downcast::<Workspace>() else {
+            return;
+        };
+        workspace::join_channel(
+            channel_id,
+            workspace.read(cx).app_state().clone(),
+            Some(handle),
+            cx,
+        )
+        .detach_and_log_err(cx)
     }
 
     fn join_channel_chat(&mut self, action: &JoinChannelChat, cx: &mut ViewContext<Self>) {

crates/util/src/channel.rs 🔗

@@ -17,15 +17,14 @@ lazy_static! {
         _ => panic!("invalid release channel {}", *RELEASE_CHANNEL_NAME),
     };
 
-    static ref URL_SCHEME: Url = Url::parse(match RELEASE_CHANNEL_NAME.as_str() {
+    pub static ref URL_SCHEME_PREFIX: String = match RELEASE_CHANNEL_NAME.as_str() {
         "dev" => "zed-dev:/",
         "preview" => "zed-preview:/",
         "stable" => "zed:/",
-        // NOTE: this must be kept in sync with ./script/bundle and https://zed.dev.
+        // NOTE: this must be kept in sync with osx_url_schemes in Cargo.toml and with https://zed.dev.
         _ => unreachable!(),
-    })
-    .unwrap();
-    static ref LINK_PREFIX: Url = Url::parse(match RELEASE_CHANNEL_NAME.as_str() {
+    }.to_string();
+    pub static ref LINK_PREFIX: Url = Url::parse(match RELEASE_CHANNEL_NAME.as_str() {
         "dev" => "http://localhost:3000/dev/",
         "preview" => "https://zed.dev/preview/",
         "stable" => "https://zed.dev/",
@@ -59,12 +58,4 @@ impl ReleaseChannel {
             ReleaseChannel::Stable => "stable",
         }
     }
-
-    pub fn url_scheme(&self) -> &'static Url {
-        &URL_SCHEME
-    }
-
-    pub fn link_prefix(&self) -> &'static Url {
-        &LINK_PREFIX
-    }
 }

crates/workspace/src/workspace.rs 🔗

@@ -4154,6 +4154,88 @@ pub async fn last_opened_workspace_paths() -> Option<WorkspaceLocation> {
     DB.last_workspace().await.log_err().flatten()
 }
 
+pub fn join_channel(
+    channel_id: u64,
+    app_state: Arc<AppState>,
+    requesting_window: Option<WindowHandle<Workspace>>,
+    cx: &mut AppContext,
+) -> Task<Result<()>> {
+    let active_call = ActiveCall::global(cx);
+    cx.spawn(|mut cx| async move {
+        let should_prompt = active_call.read_with(&mut cx, |active_call, cx| {
+            let Some(room) = active_call.room().map( |room| room.read(cx) ) else {
+                return false
+            };
+
+            room.is_sharing_project() && room.remote_participants().len() > 0 &&
+            room.channel_id() != Some(channel_id)
+        });
+
+        if should_prompt {
+            if let Some(workspace) = requesting_window {
+                if let Some(window) = workspace.update(&mut cx, |cx| {
+                    cx.window()
+                }) {
+                    let answer = window.prompt(
+                        PromptLevel::Warning,
+                        "Leaving this call will unshare your current project.\nDo you want to switch channels?",
+                        &["Yes, Join Channel", "Cancel"],
+                        &mut cx,
+                    );
+
+                    if let Some(mut answer) = answer {
+                        if answer.next().await == Some(1) {
+                            return Ok(());
+                        }
+                    }
+                }
+            }
+        }
+
+        let room = active_call.update(&mut cx, |active_call, cx| {
+            active_call.join_channel(channel_id, cx)
+        }).await?;
+
+        let task = room.update(&mut cx, |room, cx| {
+            if let Some((project, host)) = room.most_active_project() {
+                return Some(join_remote_project(project, host, app_state.clone(), cx))
+            }
+
+            None
+        });
+        if let Some(task) = task {
+            task.await?;
+            return anyhow::Ok(());
+        }
+
+        if requesting_window.is_some() {
+            return anyhow::Ok(());
+        }
+
+        // find an existing workspace to focus and show call controls
+        for window in cx.windows() {
+            let found = window.update(&mut cx, |cx| {
+                let is_workspace = cx.root_view().clone().downcast::<Workspace>().is_some();
+                if is_workspace {
+                    cx.activate_window();
+                }
+                is_workspace
+            });
+
+            if found.unwrap_or(false) {
+                return anyhow::Ok(())
+            }
+        }
+
+        // no open workspaces
+        cx.update(|cx| {
+        Workspace::new_local(vec![], app_state.clone(), requesting_window, cx)
+        }).await;
+
+        return anyhow::Ok(());
+    })
+}
+
 #[allow(clippy::type_complexity)]
 pub fn open_paths(
     abs_paths: &[PathBuf],

crates/zed/src/main.rs 🔗

@@ -45,7 +45,7 @@ use std::{
 };
 use sum_tree::Bias;
 use util::{
-    channel::ReleaseChannel,
+    channel::{ReleaseChannel, URL_SCHEME_PREFIX},
     http::{self, HttpClient},
     paths::PathLikeWithPosition,
 };
@@ -61,6 +61,10 @@ use zed::{
     only_instance::{ensure_only_instance, IsOnlyInstance},
 };
 
+use crate::open_url::{OpenListener, OpenRequest};
+
+mod open_url;
+
 fn main() {
     let http = http::client();
     init_paths();
@@ -92,29 +96,20 @@ fn main() {
         })
     };
 
-    let (cli_connections_tx, mut cli_connections_rx) = mpsc::unbounded();
-    let cli_connections_tx = Arc::new(cli_connections_tx);
-    let (open_paths_tx, mut open_paths_rx) = mpsc::unbounded();
-    let open_paths_tx = Arc::new(open_paths_tx);
-    let urls_callback_triggered = Arc::new(AtomicBool::new(false));
-
-    let callback_cli_connections_tx = Arc::clone(&cli_connections_tx);
-    let callback_open_paths_tx = Arc::clone(&open_paths_tx);
-    let callback_urls_callback_triggered = Arc::clone(&urls_callback_triggered);
-    app.on_open_urls(move |urls, _| {
-        callback_urls_callback_triggered.store(true, Ordering::Release);
-        open_urls(urls, &callback_cli_connections_tx, &callback_open_paths_tx);
-    })
-    .on_reopen(move |cx| {
-        if cx.has_global::<Weak<AppState>>() {
-            if let Some(app_state) = cx.global::<Weak<AppState>>().upgrade() {
-                workspace::open_new(&app_state, cx, |workspace, cx| {
-                    Editor::new_file(workspace, &Default::default(), cx)
-                })
-                .detach();
+    let (listener, mut open_rx) = OpenListener::new();
+    let listener = Arc::new(listener);
+    let callback_listener = listener.clone();
+    app.on_open_urls(move |urls, _| callback_listener.open_urls(urls))
+        .on_reopen(move |cx| {
+            if cx.has_global::<Weak<AppState>>() {
+                if let Some(app_state) = cx.global::<Weak<AppState>>().upgrade() {
+                    workspace::open_new(&app_state, cx, |workspace, cx| {
+                        Editor::new_file(workspace, &Default::default(), cx)
+                    })
+                    .detach();
+                }
             }
-        }
-    });
+        });
 
     app.run(move |cx| {
         cx.set_global(*RELEASE_CHANNEL);
@@ -226,41 +221,52 @@ fn main() {
             // TODO Development mode that forces the CLI mode usually runs Zed binary as is instead
             // of an *app, hence gets no specific callbacks run. Emulate them here, if needed.
             if std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_some()
-                && !urls_callback_triggered.load(Ordering::Acquire)
+                && !listener.triggered.load(Ordering::Acquire)
             {
-                open_urls(collect_url_args(), &cli_connections_tx, &open_paths_tx)
+                listener.open_urls(collect_url_args())
             }
 
-            if let Ok(Some(connection)) = cli_connections_rx.try_next() {
-                cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
-                    .detach();
-            } else if let Ok(Some(paths)) = open_paths_rx.try_next() {
-                cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
-                    .detach();
-            } else {
-                cx.spawn({
-                    let app_state = app_state.clone();
-                    |cx| async move { restore_or_create_workspace(&app_state, cx).await }
-                })
-                .detach()
-            }
-
-            cx.spawn(|cx| {
-                let app_state = app_state.clone();
-                async move {
-                    while let Some(connection) = cli_connections_rx.next().await {
-                        handle_cli_connection(connection, app_state.clone(), cx.clone()).await;
-                    }
+            match open_rx.try_next() {
+                Ok(Some(OpenRequest::Paths { paths })) => {
+                    cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
+                        .detach();
                 }
-            })
-            .detach();
+                Ok(Some(OpenRequest::CliConnection { connection })) => {
+                    cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
+                        .detach();
+                }
+                Ok(Some(OpenRequest::JoinChannel { channel_id })) => cx
+                    .update(|cx| workspace::join_channel(channel_id, app_state.clone(), None, cx))
+                    .detach(),
+                Ok(None) | Err(_) => cx
+                    .spawn({
+                        let app_state = app_state.clone();
+                        |cx| async move { restore_or_create_workspace(&app_state, cx).await }
+                    })
+                    .detach(),
+            }
 
             cx.spawn(|mut cx| {
                 let app_state = app_state.clone();
                 async move {
-                    while let Some(paths) = open_paths_rx.next().await {
-                        cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
-                            .detach();
+                    while let Some(request) = open_rx.next().await {
+                        match request {
+                            OpenRequest::Paths { paths } => {
+                                cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
+                                    .detach();
+                            }
+                            OpenRequest::CliConnection { connection } => {
+                                cx.spawn(|cx| {
+                                    handle_cli_connection(connection, app_state.clone(), cx)
+                                })
+                                .detach();
+                            }
+                            OpenRequest::JoinChannel { channel_id } => cx
+                                .update(|cx| {
+                                    workspace::join_channel(channel_id, app_state.clone(), None, cx)
+                                })
+                                .detach(),
+                        }
                     }
                 }
             })
@@ -297,37 +303,6 @@ async fn installation_id() -> Result<String> {
     }
 }
 
-fn open_urls(
-    urls: Vec<String>,
-    cli_connections_tx: &mpsc::UnboundedSender<(
-        mpsc::Receiver<CliRequest>,
-        IpcSender<CliResponse>,
-    )>,
-    open_paths_tx: &mpsc::UnboundedSender<Vec<PathBuf>>,
-) {
-    if let Some(server_name) = urls.first().and_then(|url| url.strip_prefix("zed-cli://")) {
-        if let Some(cli_connection) = connect_to_cli(server_name).log_err() {
-            cli_connections_tx
-                .unbounded_send(cli_connection)
-                .map_err(|_| anyhow!("no listener for cli connections"))
-                .log_err();
-        };
-    } else {
-        let paths: Vec<_> = urls
-            .iter()
-            .flat_map(|url| url.strip_prefix("file://"))
-            .map(|url| {
-                let decoded = urlencoding::decode_binary(url.as_bytes());
-                PathBuf::from(OsStr::from_bytes(decoded.as_ref()))
-            })
-            .collect();
-        open_paths_tx
-            .unbounded_send(paths)
-            .map_err(|_| anyhow!("no listener for open urls requests"))
-            .log_err();
-    }
-}
-
 async fn restore_or_create_workspace(app_state: &Arc<AppState>, mut cx: AsyncAppContext) {
     if let Some(location) = workspace::last_opened_workspace_paths().await {
         cx.update(|cx| workspace::open_paths(location.paths().as_ref(), app_state, None, cx))

crates/zed/src/open_url.rs 🔗

@@ -0,0 +1,101 @@
+use anyhow::anyhow;
+use cli::{ipc::IpcSender, CliRequest, CliResponse};
+use futures::channel::mpsc;
+use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
+use std::ffi::OsStr;
+use std::os::unix::prelude::OsStrExt;
+use std::sync::atomic::Ordering;
+use std::{path::PathBuf, sync::atomic::AtomicBool};
+use util::channel::URL_SCHEME_PREFIX;
+use util::ResultExt;
+
+use crate::{connect_to_cli, handle_cli_connection};
+
+pub enum OpenRequest {
+    Paths {
+        paths: Vec<PathBuf>,
+    },
+    CliConnection {
+        connection: (mpsc::Receiver<CliRequest>, IpcSender<CliResponse>),
+    },
+    JoinChannel {
+        channel_id: u64,
+    },
+}
+
+pub struct OpenListener {
+    tx: UnboundedSender<OpenRequest>,
+    pub triggered: AtomicBool,
+}
+
+impl OpenListener {
+    pub fn new() -> (Self, UnboundedReceiver<OpenRequest>) {
+        let (tx, rx) = mpsc::unbounded();
+        (
+            OpenListener {
+                tx,
+                triggered: AtomicBool::new(false),
+            },
+            rx,
+        )
+    }
+
+    pub fn open_urls(&self, urls: Vec<String>) {
+        self.triggered.store(true, Ordering::Release);
+        dbg!(&urls);
+        let request = if let Some(server_name) =
+            urls.first().and_then(|url| url.strip_prefix("zed-cli://"))
+        {
+            self.handle_cli_connection(server_name)
+        } else if let Some(request_path) = urls
+            .first()
+            .and_then(|url| url.strip_prefix(URL_SCHEME_PREFIX.as_str()))
+        {
+            self.handle_zed_url_scheme(request_path)
+        } else {
+            self.handle_file_urls(urls)
+        };
+
+        if let Some(request) = request {
+            self.tx
+                .unbounded_send(request)
+                .map_err(|_| anyhow!("no listener for open requests"))
+                .log_err();
+        }
+    }
+
+    fn handle_cli_connection(&self, server_name: &str) -> Option<OpenRequest> {
+        if let Some(connection) = connect_to_cli(server_name).log_err() {
+            return Some(OpenRequest::CliConnection { connection });
+        }
+
+        None
+    }
+
+    fn handle_zed_url_scheme(&self, request_path: &str) -> Option<OpenRequest> {
+        let mut parts = request_path.split("/");
+        if parts.next() == Some("channel") {
+            if let Some(slug) = parts.next() {
+                if let Some(id_str) = slug.split("-").last() {
+                    if let Ok(channel_id) = id_str.parse::<u64>() {
+                        return Some(OpenRequest::JoinChannel { channel_id });
+                    }
+                }
+            }
+        }
+        None
+    }
+
+    fn handle_file_urls(&self, urls: Vec<String>) -> Option<OpenRequest> {
+        let paths: Vec<_> = urls
+            .iter()
+            .flat_map(|url| url.strip_prefix("file://"))
+            .map(|url| {
+                let decoded = urlencoding::decode_binary(url.as_bytes());
+                PathBuf::from(OsStr::from_bytes(decoded.as_ref()))
+            })
+            .collect();
+
+        Some(OpenRequest::Paths { paths })
+    }
+}