Detailed changes
@@ -76,7 +76,7 @@
// Settings related to calls in Zed
"calls": {
// Join calls with the microphone muted by default
- "mute_on_join": true
+ "mute_on_join": false
},
// Scrollbar related settings
"scrollbar": {
@@ -18,7 +18,7 @@ use live_kit_client::{
LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RemoteAudioTrackUpdate,
RemoteVideoTrackUpdate,
};
-use postage::stream::Stream;
+use postage::{sink::Sink, stream::Stream, watch};
use project::Project;
use std::{future::Future, mem, pin::Pin, sync::Arc, time::Duration};
use util::{post_inc, ResultExt, TryFutureExt};
@@ -70,6 +70,8 @@ pub struct Room {
user_store: ModelHandle<UserStore>,
follows_by_leader_id_project_id: HashMap<(PeerId, u64), Vec<PeerId>>,
subscriptions: Vec<client::Subscription>,
+ room_update_completed_tx: watch::Sender<Option<()>>,
+ room_update_completed_rx: watch::Receiver<Option<()>>,
pending_room_update: Option<Task<()>>,
maintain_connection: Option<Task<Option<()>>>,
}
@@ -211,6 +213,8 @@ impl Room {
Audio::play_sound(Sound::Joined, cx);
+ let (room_update_completed_tx, room_update_completed_rx) = watch::channel();
+
Self {
id,
channel_id,
@@ -230,6 +234,8 @@ impl Room {
user_store,
follows_by_leader_id_project_id: Default::default(),
maintain_connection: Some(maintain_connection),
+ room_update_completed_tx,
+ room_update_completed_rx,
}
}
@@ -599,7 +605,7 @@ impl Room {
}
/// Returns the most 'active' projects, defined as most people in the project
- pub fn most_active_project(&self) -> Option<(u64, u64)> {
+ pub fn most_active_project(&self, cx: &AppContext) -> Option<(u64, u64)> {
let mut project_hosts_and_guest_counts = HashMap::<u64, (Option<u64>, u32)>::default();
for participant in self.remote_participants.values() {
match participant.location {
@@ -619,6 +625,15 @@ impl Room {
}
}
+ if let Some(user) = self.user_store.read(cx).current_user() {
+ for project in &self.local_participant.projects {
+ project_hosts_and_guest_counts
+ .entry(project.id)
+ .or_default()
+ .0 = Some(user.id);
+ }
+ }
+
project_hosts_and_guest_counts
.into_iter()
.filter_map(|(id, (host, guest_count))| Some((id, host?, guest_count)))
@@ -858,6 +873,7 @@ impl Room {
});
this.check_invariants();
+ this.room_update_completed_tx.try_send(Some(())).ok();
cx.notify();
});
}));
@@ -866,6 +882,17 @@ impl Room {
Ok(())
}
+ pub fn room_update_completed(&mut self) -> impl Future<Output = ()> {
+ let mut done_rx = self.room_update_completed_rx.clone();
+ async move {
+ while let Some(result) = done_rx.next().await {
+ if result.is_some() {
+ break;
+ }
+ }
+ }
+ }
+
fn remote_video_track_updated(
&mut self,
change: RemoteVideoTrackUpdate,
@@ -5,6 +5,7 @@ use anyhow::{anyhow, Result};
use channel_index::ChannelIndex;
use client::{Client, Subscription, User, UserId, UserStore};
use collections::{hash_map, HashMap, HashSet};
+use db::RELEASE_CHANNEL;
use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt};
use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task, WeakModelHandle};
use rpc::{
@@ -52,6 +53,26 @@ pub struct Channel {
pub unseen_message_id: Option<u64>,
}
+impl Channel {
+ pub fn link(&self) -> String {
+ RELEASE_CHANNEL.link_prefix().to_owned()
+ + "channel/"
+ + &self.slug()
+ + "-"
+ + &self.id.to_string()
+ }
+
+ pub fn slug(&self) -> String {
+ let slug: String = self
+ .name
+ .chars()
+ .map(|c| if c.is_alphanumeric() { c } else { '-' })
+ .collect();
+
+ slug.trim_matches(|c| c == '-').to_string()
+ }
+}
+
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)]
pub struct ChannelPath(Arc<[ChannelId]>);
@@ -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 {
@@ -37,6 +37,7 @@ CREATE INDEX "index_contacts_user_id_b" ON "contacts" ("user_id_b");
CREATE TABLE "rooms" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"live_kit_room" VARCHAR NOT NULL,
+ "release_channel" VARCHAR,
"channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE
);
@@ -0,0 +1 @@
+ALTER TABLE rooms ADD COLUMN release_channel TEXT;
@@ -107,10 +107,12 @@ impl Database {
user_id: UserId,
connection: ConnectionId,
live_kit_room: &str,
+ release_channel: &str,
) -> Result<proto::Room> {
self.transaction(|tx| async move {
let room = room::ActiveModel {
live_kit_room: ActiveValue::set(live_kit_room.into()),
+ release_channel: ActiveValue::set(Some(release_channel.to_string())),
..Default::default()
}
.insert(&*tx)
@@ -270,20 +272,31 @@ impl Database {
room_id: RoomId,
user_id: UserId,
connection: ConnectionId,
+ collab_release_channel: &str,
) -> Result<RoomGuard<JoinRoom>> {
self.room_transaction(room_id, |tx| async move {
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
- enum QueryChannelId {
+ enum QueryChannelIdAndReleaseChannel {
ChannelId,
+ ReleaseChannel,
+ }
+
+ let (channel_id, release_channel): (Option<ChannelId>, Option<String>) =
+ room::Entity::find()
+ .select_only()
+ .column(room::Column::ChannelId)
+ .column(room::Column::ReleaseChannel)
+ .filter(room::Column::Id.eq(room_id))
+ .into_values::<_, QueryChannelIdAndReleaseChannel>()
+ .one(&*tx)
+ .await?
+ .ok_or_else(|| anyhow!("no such room"))?;
+
+ if let Some(release_channel) = release_channel {
+ if &release_channel != collab_release_channel {
+ Err(anyhow!("must join using the {} release", release_channel))?;
+ }
}
- let channel_id: Option<ChannelId> = room::Entity::find()
- .select_only()
- .column(room::Column::ChannelId)
- .filter(room::Column::Id.eq(room_id))
- .into_values::<_, QueryChannelId>()
- .one(&*tx)
- .await?
- .ok_or_else(|| anyhow!("no such room"))?;
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
enum QueryParticipantIndices {
@@ -300,6 +313,7 @@ impl Database {
.into_values::<_, QueryParticipantIndices>()
.all(&*tx)
.await?;
+
let mut participant_index = 0;
while existing_participant_indices.contains(&participant_index) {
participant_index += 1;
@@ -8,6 +8,7 @@ pub struct Model {
pub id: RoomId,
pub live_kit_room: String,
pub channel_id: Option<ChannelId>,
+ pub release_channel: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -12,6 +12,8 @@ use sea_orm::ConnectionTrait;
use sqlx::migrate::MigrateDatabase;
use std::sync::Arc;
+const TEST_RELEASE_CHANNEL: &'static str = "test";
+
pub struct TestDb {
pub db: Option<Arc<Database>>,
pub connection: Option<sqlx::AnyConnection>,
@@ -5,7 +5,11 @@ use rpc::{
};
use crate::{
- db::{queries::channels::ChannelGraph, tests::graph, ChannelId, Database, NewUserParams},
+ db::{
+ queries::channels::ChannelGraph,
+ tests::{graph, TEST_RELEASE_CHANNEL},
+ ChannelId, Database, NewUserParams,
+ },
test_both_dbs,
};
use std::sync::Arc;
@@ -206,7 +210,12 @@ async fn test_joining_channels(db: &Arc<Database>) {
// can join a room with membership to its channel
let joined_room = db
- .join_room(room_1, user_1, ConnectionId { owner_id, id: 1 })
+ .join_room(
+ room_1,
+ user_1,
+ ConnectionId { owner_id, id: 1 },
+ TEST_RELEASE_CHANNEL,
+ )
.await
.unwrap();
assert_eq!(joined_room.room.participants.len(), 1);
@@ -214,7 +223,12 @@ async fn test_joining_channels(db: &Arc<Database>) {
drop(joined_room);
// cannot join a room without membership to its channel
assert!(db
- .join_room(room_1, user_2, ConnectionId { owner_id, id: 1 })
+ .join_room(
+ room_1,
+ user_2,
+ ConnectionId { owner_id, id: 1 },
+ TEST_RELEASE_CHANNEL
+ )
.await
.is_err());
}
@@ -479,7 +479,7 @@ async fn test_project_count(db: &Arc<Database>) {
.unwrap();
let room_id = RoomId::from_proto(
- db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "")
+ db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "", "dev")
.await
.unwrap()
.id,
@@ -493,9 +493,14 @@ async fn test_project_count(db: &Arc<Database>) {
)
.await
.unwrap();
- db.join_room(room_id, user2.user_id, ConnectionId { owner_id, id: 1 })
- .await
- .unwrap();
+ db.join_room(
+ room_id,
+ user2.user_id,
+ ConnectionId { owner_id, id: 1 },
+ "dev",
+ )
+ .await
+ .unwrap();
assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[])
@@ -575,6 +580,85 @@ async fn test_fuzzy_search_users() {
}
}
+test_both_dbs!(
+ test_non_matching_release_channels,
+ test_non_matching_release_channels_postgres,
+ test_non_matching_release_channels_sqlite
+);
+
+async fn test_non_matching_release_channels(db: &Arc<Database>) {
+ let owner_id = db.create_server("test").await.unwrap().0 as u32;
+
+ let user1 = db
+ .create_user(
+ &format!("admin@example.com"),
+ true,
+ NewUserParams {
+ github_login: "admin".into(),
+ github_user_id: 0,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap();
+ let user2 = db
+ .create_user(
+ &format!("user@example.com"),
+ false,
+ NewUserParams {
+ github_login: "user".into(),
+ github_user_id: 1,
+ invite_count: 0,
+ },
+ )
+ .await
+ .unwrap();
+
+ let room = db
+ .create_room(
+ user1.user_id,
+ ConnectionId { owner_id, id: 0 },
+ "",
+ "stable",
+ )
+ .await
+ .unwrap();
+
+ db.call(
+ RoomId::from_proto(room.id),
+ user1.user_id,
+ ConnectionId { owner_id, id: 0 },
+ user2.user_id,
+ None,
+ )
+ .await
+ .unwrap();
+
+ // User attempts to join from preview
+ let result = db
+ .join_room(
+ RoomId::from_proto(room.id),
+ user2.user_id,
+ ConnectionId { owner_id, id: 1 },
+ "preview",
+ )
+ .await;
+
+ assert!(result.is_err());
+
+ // User switches to stable
+ let result = db
+ .join_room(
+ RoomId::from_proto(room.id),
+ user2.user_id,
+ ConnectionId { owner_id, id: 1 },
+ "stable",
+ )
+ .await;
+
+ assert!(result.is_ok())
+}
+
fn build_background_executor() -> Arc<Background> {
Deterministic::new(0).build_background()
}
@@ -63,6 +63,7 @@ use time::OffsetDateTime;
use tokio::sync::{watch, Semaphore};
use tower::ServiceBuilder;
use tracing::{info_span, instrument, Instrument};
+use util::channel::RELEASE_CHANNEL_NAME;
pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
@@ -957,7 +958,12 @@ async fn create_room(
let room = session
.db()
.await
- .create_room(session.user_id, session.connection_id, &live_kit_room)
+ .create_room(
+ session.user_id,
+ session.connection_id,
+ &live_kit_room,
+ RELEASE_CHANNEL_NAME.as_str(),
+ )
.await?;
response.send(proto::CreateRoomResponse {
@@ -979,7 +985,12 @@ async fn join_room(
let room = session
.db()
.await
- .join_room(room_id, session.user_id, session.connection_id)
+ .join_room(
+ room_id,
+ session.user_id,
+ session.connection_id,
+ RELEASE_CHANNEL_NAME.as_str(),
+ )
.await?;
room_updated(&room.room, &session.peer);
room.into_inner()
@@ -2616,7 +2627,12 @@ async fn join_channel(
let room_id = db.room_id_for_channel(channel_id).await?;
let joined_room = db
- .join_room(room_id, session.user_id, session.connection_id)
+ .join_room(
+ room_id,
+ session.user_id,
+ session.connection_id,
+ RELEASE_CHANNEL_NAME.as_str(),
+ )
.await?;
let live_kit_connection_info = session.live_kit_client.as_ref().and_then(|live_kit| {
@@ -34,8 +34,8 @@ use gpui::{
},
impl_actions,
platform::{CursorStyle, MouseButton, PromptLevel},
- serde_json, AnyElement, AppContext, AsyncAppContext, Element, Entity, FontCache, ModelHandle,
- Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
+ serde_json, AnyElement, AppContext, AsyncAppContext, ClipboardItem, Element, Entity, FontCache,
+ ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
};
use menu::{Confirm, SelectNext, SelectPrev};
use project::{Fs, Project};
@@ -100,6 +100,11 @@ pub struct JoinChannelChat {
pub channel_id: u64,
}
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
+pub struct CopyChannelLink {
+ pub channel_id: u64,
+}
+
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct StartMoveChannelFor {
channel_id: ChannelId,
@@ -157,6 +162,7 @@ impl_actions!(
OpenChannelNotes,
JoinChannelCall,
JoinChannelChat,
+ CopyChannelLink,
LinkChannel,
StartMoveChannelFor,
StartLinkChannelFor,
@@ -205,6 +211,7 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(CollabPanel::expand_selected_channel);
cx.add_action(CollabPanel::open_channel_notes);
cx.add_action(CollabPanel::join_channel_chat);
+ cx.add_action(CollabPanel::copy_channel_link);
cx.add_action(
|panel: &mut CollabPanel, action: &ToggleSelectedIx, cx: &mut ViewContext<CollabPanel>| {
@@ -2568,6 +2575,13 @@ impl CollabPanel {
},
));
+ items.push(ContextMenuItem::action(
+ "Copy Channel Link",
+ CopyChannelLink {
+ channel_id: path.channel_id(),
+ },
+ ));
+
if self.channel_store.read(cx).is_user_admin(path.channel_id()) {
let parent_id = path.parent_id();
@@ -3187,49 +3201,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>) {
@@ -3246,6 +3230,15 @@ impl CollabPanel {
});
}
}
+
+ fn copy_channel_link(&mut self, action: &CopyChannelLink, cx: &mut ViewContext<Self>) {
+ let channel_store = self.channel_store.read(cx);
+ let Some(channel) = channel_store.channel_for_id(action.channel_id) else {
+ return;
+ };
+ let item = ClipboardItem::new(channel.link());
+ cx.write_to_clipboard(item)
+ }
}
fn render_tree_branch(
@@ -140,6 +140,10 @@ unsafe fn build_classes() {
sel!(application:openURLs:),
open_urls as extern "C" fn(&mut Object, Sel, id, id),
);
+ decl.add_method(
+ sel!(application:continueUserActivity:restorationHandler:),
+ continue_user_activity as extern "C" fn(&mut Object, Sel, id, id, id),
+ );
decl.register()
}
}
@@ -1009,6 +1013,26 @@ extern "C" fn open_urls(this: &mut Object, _: Sel, _: id, urls: id) {
}
}
+extern "C" fn continue_user_activity(this: &mut Object, _: Sel, _: id, user_activity: id, _: id) {
+ let url = unsafe {
+ let url: id = msg_send!(user_activity, webpageURL);
+ if url == nil {
+ log::error!("got unexpected user activity");
+ None
+ } else {
+ Some(
+ CStr::from_ptr(url.absoluteString().UTF8String())
+ .to_string_lossy()
+ .to_string(),
+ )
+ }
+ };
+ let platform = unsafe { get_foreground_platform(this) };
+ if let Some(callback) = platform.0.borrow_mut().open_urls.as_mut() {
+ callback(url.into_iter().collect());
+ }
+}
+
extern "C" fn handle_menu_item(this: &mut Object, _: Sel, item: id) {
unsafe {
let platform = get_foreground_platform(this);
@@ -41,4 +41,36 @@ impl ReleaseChannel {
ReleaseChannel::Stable => "stable",
}
}
+
+ pub fn url_scheme(&self) -> &'static str {
+ match self {
+ ReleaseChannel::Dev => "zed-dev://",
+ ReleaseChannel::Preview => "zed-preview://",
+ ReleaseChannel::Stable => "zed://",
+ }
+ }
+
+ pub fn link_prefix(&self) -> &'static str {
+ match self {
+ ReleaseChannel::Dev => "https://zed.dev/dev/",
+ ReleaseChannel::Preview => "https://zed.dev/preview/",
+ ReleaseChannel::Stable => "https://zed.dev/",
+ }
+ }
+}
+
+pub fn parse_zed_link(link: &str) -> Option<&str> {
+ for release in [
+ ReleaseChannel::Dev,
+ ReleaseChannel::Preview,
+ ReleaseChannel::Stable,
+ ] {
+ if let Some(stripped) = link.strip_prefix(release.link_prefix()) {
+ return Some(stripped);
+ }
+ if let Some(stripped) = link.strip_prefix(release.url_scheme()) {
+ return Some(stripped);
+ }
+ }
+ None
}
@@ -14,7 +14,7 @@ use anyhow::{anyhow, Context, Result};
use call::ActiveCall;
use client::{
proto::{self, PeerId},
- Client, TypedEnvelope, UserStore,
+ Client, Status, TypedEnvelope, UserStore,
};
use collections::{hash_map, HashMap, HashSet};
use drag_and_drop::DragAndDrop;
@@ -35,9 +35,9 @@ use gpui::{
CursorStyle, ModifiersChangedEvent, MouseButton, PathPromptOptions, Platform, PromptLevel,
WindowBounds, WindowOptions,
},
- AnyModelHandle, AnyViewHandle, AnyWeakViewHandle, AppContext, AsyncAppContext, Entity,
- ModelContext, ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext, ViewHandle,
- WeakViewHandle, WindowContext, WindowHandle,
+ AnyModelHandle, AnyViewHandle, AnyWeakViewHandle, AnyWindowHandle, AppContext, AsyncAppContext,
+ Entity, ModelContext, ModelHandle, SizeConstraint, Subscription, Task, View, ViewContext,
+ ViewHandle, WeakViewHandle, WindowContext, WindowHandle,
};
use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem};
use itertools::Itertools;
@@ -4139,6 +4139,188 @@ pub async fn last_opened_workspace_paths() -> Option<WorkspaceLocation> {
DB.last_workspace().await.log_err().flatten()
}
+async fn join_channel_internal(
+ channel_id: u64,
+ app_state: &Arc<AppState>,
+ requesting_window: Option<WindowHandle<Workspace>>,
+ active_call: &ModelHandle<ActiveCall>,
+ cx: &mut AsyncAppContext,
+) -> Result<bool> {
+ let (should_prompt, open_room) = active_call.read_with(cx, |active_call, cx| {
+ let Some(room) = active_call.room().map(|room| room.read(cx)) else {
+ return (false, None);
+ };
+
+ let already_in_channel = room.channel_id() == Some(channel_id);
+ let should_prompt = room.is_sharing_project()
+ && room.remote_participants().len() > 0
+ && !already_in_channel;
+ let open_room = if already_in_channel {
+ active_call.room().cloned()
+ } else {
+ None
+ };
+ (should_prompt, open_room)
+ });
+
+ if let Some(room) = open_room {
+ let task = room.update(cx, |room, cx| {
+ if let Some((project, host)) = room.most_active_project(cx) {
+ return Some(join_remote_project(project, host, app_state.clone(), cx));
+ }
+
+ None
+ });
+ if let Some(task) = task {
+ task.await?;
+ }
+ return anyhow::Ok(true);
+ }
+
+ if should_prompt {
+ if let Some(workspace) = requesting_window {
+ if let Some(window) = workspace.update(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"],
+ cx,
+ );
+
+ if let Some(mut answer) = answer {
+ if answer.next().await == Some(1) {
+ return Ok(false);
+ }
+ }
+ } else {
+ return Ok(false); // unreachable!() hopefully
+ }
+ } else {
+ return Ok(false); // unreachable!() hopefully
+ }
+ }
+
+ let client = cx.read(|cx| active_call.read(cx).client());
+
+ let mut client_status = client.status();
+
+ // this loop will terminate within client::CONNECTION_TIMEOUT seconds.
+ 'outer: loop {
+ let Some(status) = client_status.recv().await else {
+ return Err(anyhow!("error connecting"));
+ };
+
+ match status {
+ Status::Connecting
+ | Status::Authenticating
+ | Status::Reconnecting
+ | Status::Reauthenticating => continue,
+ Status::Connected { .. } => break 'outer,
+ Status::SignedOut => return Err(anyhow!("not signed in")),
+ Status::UpgradeRequired => return Err(anyhow!("zed is out of date")),
+ Status::ConnectionError | Status::ConnectionLost | Status::ReconnectionError { .. } => {
+ return Err(anyhow!("zed is offline"))
+ }
+ }
+ }
+
+ let room = active_call
+ .update(cx, |active_call, cx| {
+ active_call.join_channel(channel_id, cx)
+ })
+ .await?;
+
+ room.update(cx, |room, _| room.room_update_completed())
+ .await;
+
+ let task = room.update(cx, |room, cx| {
+ if let Some((project, host)) = room.most_active_project(cx) {
+ return Some(join_remote_project(project, host, app_state.clone(), cx));
+ }
+
+ None
+ });
+ if let Some(task) = task {
+ task.await?;
+ return anyhow::Ok(true);
+ }
+ anyhow::Ok(false)
+}
+
+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 result = join_channel_internal(
+ channel_id,
+ &app_state,
+ requesting_window,
+ &active_call,
+ &mut cx,
+ )
+ .await;
+
+ // join channel succeeded, and opened a window
+ if matches!(result, Ok(true)) {
+ return anyhow::Ok(());
+ }
+
+ if requesting_window.is_some() {
+ return anyhow::Ok(());
+ }
+
+ // find an existing workspace to focus and show call controls
+ let mut active_window = activate_any_workspace_window(&mut cx);
+ if active_window.is_none() {
+ // no open workspaces, make one to show the error in (blergh)
+ cx.update(|cx| Workspace::new_local(vec![], app_state.clone(), requesting_window, cx))
+ .await;
+ }
+
+ active_window = activate_any_workspace_window(&mut cx);
+ if active_window.is_none() {
+ return result.map(|_| ()); // unreachable!() assuming new_local always opens a window
+ }
+
+ if let Err(err) = result {
+ let prompt = active_window.unwrap().prompt(
+ PromptLevel::Critical,
+ &format!("Failed to join channel: {}", err),
+ &["Ok"],
+ &mut cx,
+ );
+ if let Some(mut prompt) = prompt {
+ prompt.next().await;
+ } else {
+ return Err(err);
+ }
+ }
+
+ // return ok, we showed the error to the user.
+ return anyhow::Ok(());
+ })
+}
+
+pub fn activate_any_workspace_window(cx: &mut AsyncAppContext) -> Option<AnyWindowHandle> {
+ for window in cx.windows() {
+ let found = window.update(cx, |cx| {
+ let is_workspace = cx.root_view().clone().downcast::<Workspace>().is_some();
+ if is_workspace {
+ cx.activate_window();
+ }
+ is_workspace
+ });
+ if found == Some(true) {
+ return Some(window);
+ }
+ }
+ None
+}
+
#[allow(clippy::type_complexity)]
pub fn open_paths(
abs_paths: &[PathBuf],
@@ -162,6 +162,7 @@ identifier = "dev.zed.Zed-Dev"
name = "Zed Dev"
osx_minimum_system_version = "10.15.7"
osx_info_plist_exts = ["resources/info/*"]
+osx_url_schemes = ["zed-dev"]
[package.metadata.bundle-preview]
icon = ["resources/app-icon-preview@2x.png", "resources/app-icon-preview.png"]
@@ -169,6 +170,7 @@ identifier = "dev.zed.Zed-Preview"
name = "Zed Preview"
osx_minimum_system_version = "10.15.7"
osx_info_plist_exts = ["resources/info/*"]
+osx_url_schemes = ["zed-preview"]
[package.metadata.bundle-stable]
@@ -177,3 +179,4 @@ identifier = "dev.zed.Zed"
name = "Zed"
osx_minimum_system_version = "10.15.7"
osx_info_plist_exts = ["resources/info/*"]
+osx_url_schemes = ["zed"]
@@ -7,7 +7,9 @@ use cli::{
ipc::{self, IpcSender},
CliRequest, CliResponse, IpcHandshake, FORCE_CLI_MODE_ENV_VAR_NAME,
};
-use client::{self, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN};
+use client::{
+ self, Client, TelemetrySettings, UserStore, ZED_APP_VERSION, ZED_SECRET_CLIENT_TOKEN,
+};
use db::kvp::KEY_VALUE_STORE;
use editor::{scroll::autoscroll::Autoscroll, Editor};
use futures::{
@@ -31,12 +33,10 @@ use std::{
ffi::OsStr,
fs::OpenOptions,
io::{IsTerminal, Write as _},
- os::unix::prelude::OsStrExt,
panic,
- path::{Path, PathBuf},
- str,
+ path::Path,
sync::{
- atomic::{AtomicBool, AtomicU32, Ordering},
+ atomic::{AtomicU32, Ordering},
Arc, Weak,
},
thread,
@@ -44,7 +44,7 @@ use std::{
};
use sum_tree::Bias;
use util::{
- channel::ReleaseChannel,
+ channel::{parse_zed_link, ReleaseChannel},
http::{self, HttpClient},
paths::PathLikeWithPosition,
};
@@ -60,6 +60,10 @@ use zed::{
only_instance::{ensure_only_instance, IsOnlyInstance},
};
+use crate::open_listener::{OpenListener, OpenRequest};
+
+mod open_listener;
+
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);
@@ -210,12 +205,9 @@ fn main() {
if stdout_is_a_pty() {
cx.platform().activate(true);
- let paths = collect_path_args();
- if paths.is_empty() {
- cx.spawn(|cx| async move { restore_or_create_workspace(&app_state, cx).await })
- .detach()
- } else {
- workspace::open_paths(&paths, &app_state, None, cx).detach_and_log_err(cx);
+ let urls = collect_url_args();
+ if !urls.is_empty() {
+ listener.open_urls(urls)
}
} else {
upload_previous_panics(http.clone(), cx);
@@ -223,61 +215,85 @@ 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() {
+ let mut triggered_authentication = false;
+
+ match open_rx.try_next() {
+ Ok(Some(OpenRequest::Paths { paths })) => {
cx.update(|cx| workspace::open_paths(&paths, &app_state, None, cx))
.detach();
- } else {
- cx.spawn({
+ }
+ Ok(Some(OpenRequest::CliConnection { connection })) => {
+ cx.spawn(|cx| handle_cli_connection(connection, app_state.clone(), cx))
+ .detach();
+ }
+ Ok(Some(OpenRequest::JoinChannel { channel_id })) => {
+ triggered_authentication = true;
+ let app_state = app_state.clone();
+ let client = client.clone();
+ cx.spawn(|mut cx| async move {
+ // ignore errors here, we'll show a generic "not signed in"
+ let _ = authenticate(client, &cx).await;
+ cx.update(|cx| workspace::join_channel(channel_id, app_state, None, cx))
+ .await
+ })
+ .detach_and_log_err(cx)
+ }
+ 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(|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;
- }
- }
- })
- .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();
- }
- }
- })
- .detach();
+ .detach(),
}
- cx.spawn(|cx| async move {
- if stdout_is_a_pty() {
- if client::IMPERSONATE_LOGIN.is_some() {
- client.authenticate_and_connect(false, &cx).await?;
+ cx.spawn(|mut cx| {
+ let app_state = app_state.clone();
+ async move {
+ 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(),
+ }
}
- } else if client.has_keychain_credentials(&cx) {
- client.authenticate_and_connect(true, &cx).await?;
}
- Ok::<_, anyhow::Error>(())
})
- .detach_and_log_err(cx);
+ .detach();
+
+ if !triggered_authentication {
+ cx.spawn(|cx| async move { authenticate(client, &cx).await })
+ .detach_and_log_err(cx);
+ }
});
}
+async fn authenticate(client: Arc<Client>, cx: &AsyncAppContext) -> Result<()> {
+ if stdout_is_a_pty() {
+ if client::IMPERSONATE_LOGIN.is_some() {
+ client.authenticate_and_connect(false, &cx).await?;
+ }
+ } else if client.has_keychain_credentials(&cx) {
+ client.authenticate_and_connect(true, &cx).await?;
+ }
+ Ok::<_, anyhow::Error>(())
+}
+
async fn installation_id() -> Result<String> {
let legacy_key_name = "device_id";
@@ -294,37 +310,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))
@@ -634,23 +619,23 @@ fn stdout_is_a_pty() -> bool {
std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_none() && std::io::stdout().is_terminal()
}
-fn collect_path_args() -> Vec<PathBuf> {
+fn collect_url_args() -> Vec<String> {
env::args()
.skip(1)
- .filter_map(|arg| match std::fs::canonicalize(arg) {
- Ok(path) => Some(path),
+ .filter_map(|arg| match std::fs::canonicalize(Path::new(&arg)) {
+ Ok(path) => Some(format!("file://{}", path.to_string_lossy())),
Err(error) => {
- log::error!("error parsing path argument: {}", error);
- None
+ if let Some(_) = parse_zed_link(&arg) {
+ Some(arg)
+ } else {
+ log::error!("error parsing path argument: {}", error);
+ None
+ }
}
})
.collect()
}
-fn collect_url_args() -> Vec<String> {
- env::args().skip(1).collect()
-}
-
fn load_embedded_fonts(app: &App) {
let font_paths = Assets.list("fonts");
let embedded_fonts = Mutex::new(Vec::new());
@@ -0,0 +1,98 @@
+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::parse_zed_link;
+use util::ResultExt;
+
+use crate::connect_to_cli;
+
+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);
+ 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| parse_zed_link(url)) {
+ 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 });
+ }
+ }
+ }
+ }
+ log::error!("invalid zed url: {}", request_path);
+ 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 })
+ }
+}
@@ -5,6 +5,7 @@ set -e
build_flag="--release"
target_dir="release"
open_result=false
+local_arch=false
local_only=false
overwrite_local_app=false
bundle_name=""
@@ -16,8 +17,8 @@ Usage: ${0##*/} [options] [bundle_name]
Build the application bundle.
Options:
- -d Compile in debug mode and print the app bundle's path.
- -l Compile for local architecture only and copy bundle to /Applications.
+ -d Compile in debug mode
+ -l Compile for local architecture and copy bundle to /Applications, implies -d.
-o Open the resulting DMG or the app itself in local mode.
-f Overwrite the local app bundle if it exists.
-h Display this help and exit.
@@ -32,10 +33,20 @@ do
case "${flag}" in
o) open_result=true;;
d)
+ export CARGO_INCREMENTAL=true
+ export CARGO_BUNDLE_SKIP_BUILD=true
build_flag="";
+ local_arch=true
+ target_dir="debug"
+ ;;
+ l)
+ export CARGO_INCREMENTAL=true
+ export CARGO_BUNDLE_SKIP_BUILD=true
+ build_flag=""
+ local_arch=true
+ local_only=true
target_dir="debug"
;;
- l) local_only=true;;
f) overwrite_local_app=true;;
h)
help_info
@@ -67,7 +78,7 @@ version_info=$(rustc --version --verbose)
host_line=$(echo "$version_info" | grep host)
local_target_triple=${host_line#*: }
-if [ "$local_only" = true ]; then
+if [ "$local_arch" = true ]; then
echo "Building for local target only."
cargo build ${build_flag} --package zed
cargo build ${build_flag} --package cli
@@ -91,7 +102,7 @@ sed \
"s/package.metadata.bundle-${channel}/package.metadata.bundle/" \
Cargo.toml
-if [ "$local_only" = true ]; then
+if [ "$local_arch" = true ]; then
app_path=$(cargo bundle ${build_flag} --select-workspace-root | xargs)
else
app_path=$(cargo bundle ${build_flag} --target x86_64-apple-darwin --select-workspace-root | xargs)
@@ -101,7 +112,7 @@ mv Cargo.toml.backup Cargo.toml
popd
echo "Bundled ${app_path}"
-if [ "$local_only" = false ]; then
+if [ "$local_arch" = false ]; then
echo "Creating fat binaries"
lipo \
-create \
@@ -117,7 +128,11 @@ fi
echo "Copying WebRTC.framework into the frameworks folder"
mkdir "${app_path}/Contents/Frameworks"
-cp -R target/${local_target_triple}/${target_dir}/WebRTC.framework "${app_path}/Contents/Frameworks/"
+if [ "$local_arch" = false ]; then
+ cp -R target/${local_target_triple}/${target_dir}/WebRTC.framework "${app_path}/Contents/Frameworks/"
+else
+ cp -R target/${target_dir}/WebRTC.framework "${app_path}/Contents/Frameworks/"
+fi
if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTARIZATION_USERNAME && -n $APPLE_NOTARIZATION_PASSWORD ]]; then
echo "Signing bundle with Apple-issued certificate"
@@ -133,10 +148,12 @@ if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTAR
else
echo "One or more of the following variables are missing: MACOS_CERTIFICATE, MACOS_CERTIFICATE_PASSWORD, APPLE_NOTARIZATION_USERNAME, APPLE_NOTARIZATION_PASSWORD"
echo "Performing an ad-hoc signature, but this bundle should not be distributed"
- codesign --force --deep --entitlements crates/zed/resources/zed.entitlements --sign - "${app_path}" -v
+ echo "If you see 'The application cannot be opened for an unexpected reason,' you likely don't have the necessary entitlements to run the application in your signing keychain"
+ echo "You will need to download a new signing key from developer.apple.com, add it to keychain, and export MACOS_SIGNING_KEY=<email address of signing key>"
+ codesign --force --deep --entitlements crates/zed/resources/zed.entitlements --sign ${MACOS_SIGNING_KEY:- -} "${app_path}" -v
fi
-if [ "$target_dir" = "debug" ]; then
+if [[ "$target_dir" = "debug" && "$local_only" = false ]]; then
if [ "$open_result" = true ]; then
open "$app_path"
else