Merge branch 'main' into gpui2

Antonio Scandurra created

Change summary

.github/pull_request_template.md                                                 |   7 
.github/workflows/release_actions.yml                                            |  21 
Cargo.lock                                                                       |  12 
README.md                                                                        |   4 
assets/keymaps/vim.json                                                          |   1 
assets/settings/default.json                                                     |   2 
crates/assistant/src/assistant_panel.rs                                          |  46 
crates/assistant/src/codegen.rs                                                  |  23 
crates/assistant/src/prompts.rs                                                  |  22 
crates/call/Cargo.toml                                                           |   1 
crates/call/src/call.rs                                                          |   3 
crates/call/src/room.rs                                                          |  62 
crates/channel/src/channel.rs                                                    |  10 
crates/channel/src/channel_store.rs                                              |  32 
crates/channel/src/channel_store_tests.rs                                        |   4 
crates/cli/src/main.rs                                                           |   1 
crates/client/src/client.rs                                                      |  18 
crates/collab/Cargo.toml                                                         |   2 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql                   |   2 
crates/collab/migrations/20231009181554_add_release_channel_to_rooms.sql         |   1 
crates/collab/migrations/20231010114600_add_unique_index_on_rooms_channel_id.sql |   1 
crates/collab/src/db/queries/channels.rs                                         |  57 
crates/collab/src/db/queries/rooms.rs                                            |  37 
crates/collab/src/db/tables/room.rs                                              |   1 
crates/collab/src/db/tests.rs                                                    |   2 
crates/collab/src/db/tests/buffer_tests.rs                                       |   6 
crates/collab/src/db/tests/channel_tests.rs                                      |  83 
crates/collab/src/db/tests/db_tests.rs                                           |  92 
crates/collab/src/db/tests/message_tests.rs                                      |  20 
crates/collab/src/rpc.rs                                                         |  39 
crates/collab/src/tests/channel_tests.rs                                         |   2 
crates/collab/src/tests/following_tests.rs                                       | 156 
crates/collab/src/tests/random_channel_buffer_tests.rs                           |   7 
crates/collab/src/tests/test_server.rs                                           |  39 
crates/collab_ui/src/channel_view.rs                                             |   2 
crates/collab_ui/src/chat_panel.rs                                               |   2 
crates/collab_ui/src/collab_panel.rs                                             |  85 
crates/collab_ui/src/collab_titlebar_item.rs                                     |  49 
crates/editor/src/editor_tests.rs                                                |  31 
crates/editor/src/movement.rs                                                    |   4 
crates/file_finder/src/file_finder.rs                                            | 237 
crates/fs/Cargo.toml                                                             |   1 
crates/fs/src/repository.rs                                                      |  20 
crates/fuzzy/src/matcher.rs                                                      |   2 
crates/fuzzy/src/paths.rs                                                        |   6 
crates/gpui/src/platform/mac/platform.rs                                         |  24 
crates/language/Cargo.toml                                                       |   1 
crates/live_kit_client/src/test.rs                                               |   5 
crates/project/src/worktree.rs                                                   |  22 
crates/util/src/channel.rs                                                       |  32 
crates/util/src/paths.rs                                                         |  15 
crates/vim/src/normal.rs                                                         |   9 
crates/vim/src/test.rs                                                           |  25 
crates/vim/src/vim.rs                                                            |   2 
crates/vim/test_data/test_paragraphs_dont_wrap.json                              |   8 
crates/welcome/Cargo.toml                                                        |   1 
crates/welcome/src/welcome.rs                                                    |  25 
crates/workspace/Cargo.toml                                                      |   1 
crates/workspace/src/pane_group.rs                                               |  33 
crates/workspace/src/workspace.rs                                                | 352 
crates/zed/Cargo.toml                                                            |   5 
crates/zed/src/main.rs                                                           | 227 
crates/zed/src/open_listener.rs                                                  |  98 
crates/zed/src/zed.rs                                                            |   1 
docs/building-zed.md                                                             |   3 
docs/local-collaboration.md                                                      |   2 
script/bundle                                                                    |  35 
script/crate-dep-graph                                                           |  19 
script/start-local-collaboration                                                 |  59 
script/zed-local                                                                 |  88 
script/zed-with-local-servers                                                    |   6 
71 files changed, 1,579 insertions(+), 774 deletions(-)

Detailed changes

.github/pull_request_template.md 🔗

@@ -2,11 +2,4 @@
 
 Release Notes:
 
-- N/A
-
-or
-
 - (Added|Fixed|Improved) ... ([#<public_issue_number_if_exists>](https://github.com/zed-industries/community/issues/<public_issue_number_if_exists>)).
-
-If the release notes are only intended for a specific release channel only, add `(<release_channel>-only)` to the end of the release note line.
-These will be removed by the person making the release.

.github/workflows/release_actions.yml 🔗

@@ -6,8 +6,8 @@ jobs:
   discord_release:
     runs-on: ubuntu-latest
     steps:
-    - name: Get appropriate URL
-      id: get-appropriate-url
+    - name: Get release URL
+      id: get-release-url
       run: |
         if [ "${{ github.event.release.prerelease }}" == "true" ]; then
           URL="https://zed.dev/releases/preview/latest"
@@ -15,14 +15,19 @@ jobs:
           URL="https://zed.dev/releases/stable/latest"
         fi
         echo "::set-output name=URL::$URL"
-
-    - name: Discord Webhook Action
-      uses: tsickert/discord-webhook@v5.3.0
+    - name: Get content
+      uses: 2428392/gh-truncate-string-action@v1.2.0
+      id: get-content
       with:
-        webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
-        content: |
+        stringToTruncate: |
           📣 Zed ${{ github.event.release.tag_name }} was just released!
 
-          Restart your Zed or head to ${{ steps.get-appropriate-url.outputs.URL }} to grab it.
+          Restart your Zed or head to ${{ steps.get-release-url.outputs.URL }} to grab it.
 
           ${{ github.event.release.body }}
+        maxLength: 2000
+    - name: Discord Webhook Action
+      uses: tsickert/discord-webhook@v5.3.0
+      with:
+        webhook-url: ${{ secrets.DISCORD_WEBHOOK_URL }}
+        content: ${{ steps.get-content.outputs.string }}

Cargo.lock 🔗

@@ -1093,7 +1093,6 @@ dependencies = [
  "anyhow",
  "async-broadcast",
  "audio",
- "channel",
  "client",
  "collections",
  "fs",
@@ -1497,7 +1496,7 @@ dependencies = [
 
 [[package]]
 name = "collab"
-version = "0.23.3"
+version = "0.24.0"
 dependencies = [
  "anyhow",
  "async-trait",
@@ -2109,9 +2108,9 @@ dependencies = [
 
 [[package]]
 name = "curl-sys"
-version = "0.4.66+curl-8.3.0"
+version = "0.4.67+curl-8.3.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70c44a72e830f0e40ad90dda8a6ab6ed6314d39776599a58a2e5e37fbc6db5b9"
+checksum = "3cc35d066510b197a0f72de863736641539957628c8a42e70e27c66849e77c34"
 dependencies = [
  "cc",
  "libc",
@@ -2862,7 +2861,6 @@ dependencies = [
  "parking_lot 0.11.2",
  "regex",
  "rope",
- "rpc",
  "serde",
  "serde_derive",
  "serde_json",
@@ -9809,6 +9807,7 @@ dependencies = [
  "theme",
  "theme_selector",
  "util",
+ "vim",
  "workspace",
 ]
 
@@ -10115,7 +10114,6 @@ dependencies = [
  "async-recursion 1.0.5",
  "bincode",
  "call",
- "channel",
  "client",
  "collections",
  "context_menu",
@@ -10227,7 +10225,7 @@ dependencies = [
 
 [[package]]
 name = "zed"
-version = "0.108.0"
+version = "0.109.0"
 dependencies = [
  "activity_indicator",
  "anyhow",

README.md 🔗

@@ -83,9 +83,7 @@ foreman start
 If you want to run Zed pointed at the local servers, you can run:
 
 ```
-script/zed-with-local-servers
-# or...
-script/zed-with-local-servers --release
+script/zed-local
 ```
 
 ### Dump element JSON

assets/keymaps/vim.json 🔗

@@ -408,6 +408,7 @@
         "vim::PushOperator",
         "Yank"
       ],
+      "shift-y": "vim::YankLine",
       "i": "vim::InsertBefore",
       "shift-i": "vim::InsertFirstNonWhitespace",
       "a": "vim::InsertAfter",

assets/settings/default.json 🔗

@@ -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": {

crates/assistant/src/assistant_panel.rs 🔗

@@ -17,7 +17,7 @@ use editor::{
         BlockContext, BlockDisposition, BlockId, BlockProperties, BlockStyle, ToDisplayPoint,
     },
     scroll::autoscroll::{Autoscroll, AutoscrollStrategy},
-    Anchor, Editor, MoveDown, MoveUp, MultiBufferSnapshot, ToOffset,
+    Anchor, Editor, MoveDown, MoveUp, MultiBufferSnapshot, ToOffset, ToPoint,
 };
 use fs::Fs;
 use futures::StreamExt;
@@ -278,22 +278,36 @@ impl AssistantPanel {
         if selection.start.excerpt_id() != selection.end.excerpt_id() {
             return;
         }
-
-        let inline_assist_id = post_inc(&mut self.next_inline_assist_id);
         let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
-        let provider = Arc::new(OpenAICompletionProvider::new(
-            api_key,
-            cx.background().clone(),
-        ));
-        let codegen_kind = if editor.read(cx).selections.newest::<usize>(cx).is_empty() {
+
+        // Extend the selection to the start and the end of the line.
+        let mut point_selection = selection.map(|selection| selection.to_point(&snapshot));
+        if point_selection.end > point_selection.start {
+            point_selection.start.column = 0;
+            // If the selection ends at the start of the line, we don't want to include it.
+            if point_selection.end.column == 0 {
+                point_selection.end.row -= 1;
+            }
+            point_selection.end.column = snapshot.line_len(point_selection.end.row);
+        }
+
+        let codegen_kind = if point_selection.start == point_selection.end {
             CodegenKind::Generate {
-                position: selection.start,
+                position: snapshot.anchor_after(point_selection.start),
             }
         } else {
             CodegenKind::Transform {
-                range: selection.start..selection.end,
+                range: snapshot.anchor_before(point_selection.start)
+                    ..snapshot.anchor_after(point_selection.end),
             }
         };
+
+        let inline_assist_id = post_inc(&mut self.next_inline_assist_id);
+        let provider = Arc::new(OpenAICompletionProvider::new(
+            api_key,
+            cx.background().clone(),
+        ));
+
         let codegen = cx.add_model(|cx| {
             Codegen::new(editor.read(cx).buffer().clone(), codegen_kind, provider, cx)
         });
@@ -319,7 +333,7 @@ impl AssistantPanel {
             editor.insert_blocks(
                 [BlockProperties {
                     style: BlockStyle::Flex,
-                    position: selection.head().bias_left(&snapshot),
+                    position: snapshot.anchor_before(point_selection.head()),
                     height: 2,
                     render: Arc::new({
                         let inline_assistant = inline_assistant.clone();
@@ -578,10 +592,7 @@ impl AssistantPanel {
 
         let codegen_kind = codegen.read(cx).kind().clone();
         let user_prompt = user_prompt.to_string();
-        let prompt = cx.background().spawn(async move {
-            let language_name = language_name.as_deref();
-            generate_content_prompt(user_prompt, language_name, &buffer, range, codegen_kind)
-        });
+
         let mut messages = Vec::new();
         let mut model = settings::get::<AssistantSettings>(cx)
             .default_open_ai_model
@@ -597,6 +608,11 @@ impl AssistantPanel {
             model = conversation.model.clone();
         }
 
+        let prompt = cx.background().spawn(async move {
+            let language_name = language_name.as_deref();
+            generate_content_prompt(user_prompt, language_name, &buffer, range, codegen_kind)
+        });
+
         cx.spawn(|_, mut cx| async move {
             let prompt = prompt.await;
 

crates/assistant/src/codegen.rs 🔗

@@ -1,9 +1,7 @@
 use crate::streaming_diff::{Hunk, StreamingDiff};
 use ai::completion::{CompletionProvider, OpenAIRequest};
 use anyhow::Result;
-use editor::{
-    multi_buffer, Anchor, AnchorRangeExt, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint,
-};
+use editor::{multi_buffer, Anchor, MultiBuffer, MultiBufferSnapshot, ToOffset, ToPoint};
 use futures::{channel::mpsc, SinkExt, Stream, StreamExt};
 use gpui::{Entity, ModelContext, ModelHandle, Task};
 use language::{Rope, TransactionId};
@@ -40,26 +38,11 @@ impl Entity for Codegen {
 impl Codegen {
     pub fn new(
         buffer: ModelHandle<MultiBuffer>,
-        mut kind: CodegenKind,
+        kind: CodegenKind,
         provider: Arc<dyn CompletionProvider>,
         cx: &mut ModelContext<Self>,
     ) -> Self {
         let snapshot = buffer.read(cx).snapshot(cx);
-        match &mut kind {
-            CodegenKind::Transform { range } => {
-                let mut point_range = range.to_point(&snapshot);
-                point_range.start.column = 0;
-                if point_range.end.column > 0 || point_range.start.row == point_range.end.row {
-                    point_range.end.column = snapshot.line_len(point_range.end.row);
-                }
-                range.start = snapshot.anchor_before(point_range.start);
-                range.end = snapshot.anchor_after(point_range.end);
-            }
-            CodegenKind::Generate { position } => {
-                *position = position.bias_right(&snapshot);
-            }
-        }
-
         Self {
             provider,
             buffer: buffer.clone(),
@@ -386,7 +369,7 @@ mod tests {
         let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx));
         let range = buffer.read_with(cx, |buffer, cx| {
             let snapshot = buffer.snapshot(cx);
-            snapshot.anchor_before(Point::new(1, 4))..snapshot.anchor_after(Point::new(4, 4))
+            snapshot.anchor_before(Point::new(1, 0))..snapshot.anchor_after(Point::new(4, 5))
         });
         let provider = Arc::new(TestCompletionProvider::new());
         let codegen = cx.add_model(|cx| {

crates/assistant/src/prompts.rs 🔗

@@ -4,6 +4,7 @@ use std::cmp::{self, Reverse};
 use std::fmt::Write;
 use std::ops::Range;
 
+#[allow(dead_code)]
 fn summarize(buffer: &BufferSnapshot, selected_range: Range<impl ToOffset>) -> String {
     #[derive(Debug)]
     struct Match {
@@ -121,6 +122,7 @@ pub fn generate_content_prompt(
     range: Range<impl ToOffset>,
     kind: CodegenKind,
 ) -> String {
+    let range = range.to_offset(buffer);
     let mut prompt = String::new();
 
     // General Preamble
@@ -130,17 +132,29 @@ pub fn generate_content_prompt(
         writeln!(prompt, "You're an expert engineer.\n").unwrap();
     }
 
-    let outline = summarize(buffer, range);
+    let mut content = String::new();
+    content.extend(buffer.text_for_range(0..range.start));
+    if range.start == range.end {
+        content.push_str("<|START|>");
+    } else {
+        content.push_str("<|START|");
+    }
+    content.extend(buffer.text_for_range(range.clone()));
+    if range.start != range.end {
+        content.push_str("|END|>");
+    }
+    content.extend(buffer.text_for_range(range.end..buffer.len()));
+
     writeln!(
         prompt,
-        "The file you are currently working on has the following outline:"
+        "The file you are currently working on has the following content:"
     )
     .unwrap();
     if let Some(language_name) = language_name {
         let language_name = language_name.to_lowercase();
-        writeln!(prompt, "```{language_name}\n{outline}\n```").unwrap();
+        writeln!(prompt, "```{language_name}\n{content}\n```").unwrap();
     } else {
-        writeln!(prompt, "```\n{outline}\n```").unwrap();
+        writeln!(prompt, "```\n{content}\n```").unwrap();
     }
 
     match kind {

crates/call/Cargo.toml 🔗

@@ -20,7 +20,6 @@ test-support = [
 
 [dependencies]
 audio = { path = "../audio" }
-channel = { path = "../channel" }
 client = { path = "../client" }
 collections = { path = "../collections" }
 gpui = { path = "../gpui" }

crates/call/src/call.rs 🔗

@@ -5,7 +5,6 @@ pub mod room;
 use anyhow::{anyhow, Result};
 use audio::Audio;
 use call_settings::CallSettings;
-use channel::ChannelId;
 use client::{
     proto, ClickhouseEvent, Client, TelemetrySettings, TypedEnvelope, User, UserStore,
     ZED_ALWAYS_ACTIVE,
@@ -79,7 +78,7 @@ impl ActiveCall {
         }
     }
 
-    pub fn channel_id(&self, cx: &AppContext) -> Option<ChannelId> {
+    pub fn channel_id(&self, cx: &AppContext) -> Option<u64> {
         self.room()?.read(cx).channel_id()
     }
 

crates/call/src/room.rs 🔗

@@ -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,28 +605,40 @@ impl Room {
     }
 
     /// Returns the most 'active' projects, defined as most people in the project
-    pub fn most_active_project(&self) -> Option<(u64, u64)> {
-        let mut projects = HashMap::default();
-        let mut hosts = HashMap::default();
+    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 {
                 ParticipantLocation::SharedProject { project_id } => {
-                    *projects.entry(project_id).or_insert(0) += 1;
+                    project_hosts_and_guest_counts
+                        .entry(project_id)
+                        .or_default()
+                        .1 += 1;
                 }
                 ParticipantLocation::External | ParticipantLocation::UnsharedProject => {}
             }
             for project in &participant.projects {
-                *projects.entry(project.id).or_insert(0) += 1;
-                hosts.insert(project.id, participant.user.id);
+                project_hosts_and_guest_counts
+                    .entry(project.id)
+                    .or_default()
+                    .0 = Some(participant.user.id);
             }
         }
 
-        let mut pairs: Vec<(u64, usize)> = projects.into_iter().collect();
-        pairs.sort_by_key(|(_, count)| *count as i32);
+        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);
+            }
+        }
 
-        pairs
-            .first()
-            .map(|(project_id, _)| (*project_id, hosts[&project_id]))
+        project_hosts_and_guest_counts
+            .into_iter()
+            .filter_map(|(id, (host, guest_count))| Some((id, host?, guest_count)))
+            .max_by_key(|(_, _, guest_count)| *guest_count)
+            .map(|(id, host, _)| (id, host))
     }
 
     async fn handle_room_updated(
@@ -686,6 +704,7 @@ impl Room {
                         let Some(peer_id) = participant.peer_id else {
                             continue;
                         };
+                        let participant_index = ParticipantIndex(participant.participant_index);
                         this.participant_user_ids.insert(participant.user_id);
 
                         let old_projects = this
@@ -736,8 +755,9 @@ impl Room {
                         if let Some(remote_participant) =
                             this.remote_participants.get_mut(&participant.user_id)
                         {
-                            remote_participant.projects = participant.projects;
                             remote_participant.peer_id = peer_id;
+                            remote_participant.projects = participant.projects;
+                            remote_participant.participant_index = participant_index;
                             if location != remote_participant.location {
                                 remote_participant.location = location;
                                 cx.emit(Event::ParticipantLocationChanged {
@@ -749,9 +769,7 @@ impl Room {
                                 participant.user_id,
                                 RemoteParticipant {
                                     user: user.clone(),
-                                    participant_index: ParticipantIndex(
-                                        participant.participant_index,
-                                    ),
+                                    participant_index,
                                     peer_id,
                                     projects: participant.projects,
                                     location,
@@ -855,6 +873,7 @@ impl Room {
                 });
 
                 this.check_invariants();
+                this.room_update_completed_tx.try_send(Some(())).ok();
                 cx.notify();
             });
         }));
@@ -863,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,

crates/channel/src/channel.rs 🔗

@@ -2,19 +2,21 @@ mod channel_buffer;
 mod channel_chat;
 mod channel_store;
 
+use client::{Client, UserStore};
+use gpui::{AppContext, ModelHandle};
+use std::sync::Arc;
+
 pub use channel_buffer::{ChannelBuffer, ChannelBufferEvent, ACKNOWLEDGE_DEBOUNCE_INTERVAL};
 pub use channel_chat::{ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId};
 pub use channel_store::{
     Channel, ChannelData, ChannelEvent, ChannelId, ChannelMembership, ChannelPath, ChannelStore,
 };
 
-use client::Client;
-use std::sync::Arc;
-
 #[cfg(test)]
 mod channel_store_tests;
 
-pub fn init(client: &Arc<Client>) {
+pub fn init(client: &Arc<Client>, user_store: ModelHandle<UserStore>, cx: &mut AppContext) {
+    channel_store::init(client, user_store, cx);
     channel_buffer::init(client);
     channel_chat::init(client);
 }

crates/channel/src/channel_store.rs 🔗

@@ -2,8 +2,10 @@ mod channel_index;
 
 use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat};
 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::{
@@ -14,7 +16,11 @@ use serde_derive::{Deserialize, Serialize};
 use std::{borrow::Cow, hash::Hash, mem, ops::Deref, sync::Arc, time::Duration};
 use util::ResultExt;
 
-use self::channel_index::ChannelIndex;
+pub fn init(client: &Arc<Client>, user_store: ModelHandle<UserStore>, cx: &mut AppContext) {
+    let channel_store =
+        cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
+    cx.set_global(channel_store);
+}
 
 pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30);
 
@@ -47,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]>);
 
@@ -71,6 +97,10 @@ enum OpenedModelHandle<E: Entity> {
 }
 
 impl ChannelStore {
+    pub fn global(cx: &AppContext) -> ModelHandle<Self> {
+        cx.global::<ModelHandle<Self>>().clone()
+    }
+
     pub fn new(
         client: Arc<Client>,
         user_store: ModelHandle<UserStore>,

crates/channel/src/channel_store_tests.rs 🔗

@@ -340,10 +340,10 @@ fn init_test(cx: &mut AppContext) -> ModelHandle<ChannelStore> {
 
     cx.foreground().forbid_parking();
     cx.set_global(SettingsStore::test(cx));
-    crate::init(&client);
     client::init(&client, cx);
+    crate::init(&client, user_store, cx);
 
-    cx.add_model(|cx| ChannelStore::new(client, user_store, cx))
+    ChannelStore::global(cx)
 }
 
 fn update_channels(

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/client/src/client.rs 🔗

@@ -70,7 +70,7 @@ pub const ZED_SECRET_CLIENT_TOKEN: &str = "618033988749894";
 pub const INITIAL_RECONNECTION_DELAY: Duration = Duration::from_millis(100);
 pub const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5);
 
-actions!(client, [SignIn, SignOut]);
+actions!(client, [SignIn, SignOut, Reconnect]);
 
 pub fn init_settings(cx: &mut AppContext) {
     settings::register::<TelemetrySettings>(cx);
@@ -102,6 +102,17 @@ pub fn init(client: &Arc<Client>, cx: &mut AppContext) {
             }
         }
     });
+    cx.add_global_action({
+        let client = client.clone();
+        move |_: &Reconnect, cx| {
+            if let Some(client) = client.upgrade() {
+                cx.spawn(|cx| async move {
+                    client.reconnect(&cx);
+                })
+                .detach();
+            }
+        }
+    });
 }
 
 pub struct Client {
@@ -1212,6 +1223,11 @@ impl Client {
         self.set_status(Status::SignedOut, cx);
     }
 
+    pub fn reconnect(self: &Arc<Self>, cx: &AsyncAppContext) {
+        self.peer.teardown();
+        self.set_status(Status::ConnectionLost, cx);
+    }
+
     fn connection_id(&self) -> Result<ConnectionId> {
         if let Status::Connected { connection_id, .. } = *self.status().borrow() {
             Ok(connection_id)

crates/collab/Cargo.toml 🔗

@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathan@zed.dev>"]
 default-run = "collab"
 edition = "2021"
 name = "collab"
-version = "0.23.3"
+version = "0.24.0"
 publish = false
 
 [[bin]]

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -37,8 +37,10 @@ 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,
+    "enviroment" VARCHAR,
     "channel_id" INTEGER REFERENCES channels (id) ON DELETE CASCADE
 );
+CREATE UNIQUE INDEX "index_rooms_on_channel_id" ON "rooms" ("channel_id");
 
 CREATE TABLE "projects" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,

crates/collab/src/db/queries/channels.rs 🔗

@@ -19,21 +19,14 @@ impl Database {
         .await
     }
 
-    pub async fn create_root_channel(
-        &self,
-        name: &str,
-        live_kit_room: &str,
-        creator_id: UserId,
-    ) -> Result<ChannelId> {
-        self.create_channel(name, None, live_kit_room, creator_id)
-            .await
+    pub async fn create_root_channel(&self, name: &str, creator_id: UserId) -> Result<ChannelId> {
+        self.create_channel(name, None, creator_id).await
     }
 
     pub async fn create_channel(
         &self,
         name: &str,
         parent: Option<ChannelId>,
-        live_kit_room: &str,
         creator_id: UserId,
     ) -> Result<ChannelId> {
         let name = Self::sanitize_channel_name(name)?;
@@ -90,14 +83,6 @@ impl Database {
             .insert(&*tx)
             .await?;
 
-            room::ActiveModel {
-                channel_id: ActiveValue::Set(Some(channel.id)),
-                live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
-                ..Default::default()
-            }
-            .insert(&*tx)
-            .await?;
-
             Ok(channel.id)
         })
         .await
@@ -797,18 +782,36 @@ impl Database {
         .await
     }
 
-    pub async fn room_id_for_channel(&self, channel_id: ChannelId) -> Result<RoomId> {
+    pub async fn get_or_create_channel_room(
+        &self,
+        channel_id: ChannelId,
+        live_kit_room: &str,
+        enviroment: &str,
+    ) -> Result<RoomId> {
         self.transaction(|tx| async move {
             let tx = tx;
-            let room = channel::Model {
-                id: channel_id,
-                ..Default::default()
-            }
-            .find_related(room::Entity)
-            .one(&*tx)
-            .await?
-            .ok_or_else(|| anyhow!("invalid channel"))?;
-            Ok(room.id)
+
+            let room = room::Entity::find()
+                .filter(room::Column::ChannelId.eq(channel_id))
+                .one(&*tx)
+                .await?;
+
+            let room_id = if let Some(room) = room {
+                room.id
+            } else {
+                let result = room::Entity::insert(room::ActiveModel {
+                    channel_id: ActiveValue::Set(Some(channel_id)),
+                    live_kit_room: ActiveValue::Set(live_kit_room.to_string()),
+                    enviroment: ActiveValue::Set(Some(enviroment.to_string())),
+                    ..Default::default()
+                })
+                .exec(&*tx)
+                .await?;
+
+                result.last_insert_id
+            };
+
+            Ok(room_id)
         })
         .await
     }

crates/collab/src/db/queries/rooms.rs 🔗

@@ -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()),
+                enviroment: 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,
+        enviroment: &str,
     ) -> Result<RoomGuard<JoinRoom>> {
         self.room_transaction(room_id, |tx| async move {
             #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
-            enum QueryChannelId {
+            enum QueryChannelIdAndEnviroment {
                 ChannelId,
+                Enviroment,
+            }
+
+            let (channel_id, release_channel): (Option<ChannelId>, Option<String>) =
+                room::Entity::find()
+                    .select_only()
+                    .column(room::Column::ChannelId)
+                    .column(room::Column::Enviroment)
+                    .filter(room::Column::Id.eq(room_id))
+                    .into_values::<_, QueryChannelIdAndEnviroment>()
+                    .one(&*tx)
+                    .await?
+                    .ok_or_else(|| anyhow!("no such room"))?;
+
+            if let Some(release_channel) = release_channel {
+                if &release_channel != enviroment {
+                    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;
@@ -818,10 +832,7 @@ impl Database {
 
                 let (channel_id, room) = self.get_channel_room(room_id, &tx).await?;
                 let deleted = if room.participants.is_empty() {
-                    let result = room::Entity::delete_by_id(room_id)
-                        .filter(room::Column::ChannelId.is_null())
-                        .exec(&*tx)
-                        .await?;
+                    let result = room::Entity::delete_by_id(room_id).exec(&*tx).await?;
                     result.rows_affected > 0
                 } else {
                     false

crates/collab/src/db/tables/room.rs 🔗

@@ -8,6 +8,7 @@ pub struct Model {
     pub id: RoomId,
     pub live_kit_room: String,
     pub channel_id: Option<ChannelId>,
+    pub enviroment: Option<String>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

crates/collab/src/db/tests.rs 🔗

@@ -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>,

crates/collab/src/db/tests/buffer_tests.rs 🔗

@@ -54,7 +54,7 @@ async fn test_channel_buffers(db: &Arc<Database>) {
 
     let owner_id = db.create_server("production").await.unwrap().0 as u32;
 
-    let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap();
+    let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
 
     db.invite_channel_member(zed_id, b_id, a_id, false)
         .await
@@ -141,7 +141,7 @@ async fn test_channel_buffers(db: &Arc<Database>) {
 
     assert_eq!(left_buffer.connections, &[connection_id_a],);
 
-    let cargo_id = db.create_root_channel("cargo", "2", a_id).await.unwrap();
+    let cargo_id = db.create_root_channel("cargo", a_id).await.unwrap();
     let _ = db
         .join_channel_buffer(cargo_id, a_id, connection_id_a)
         .await
@@ -207,7 +207,7 @@ async fn test_channel_buffers_last_operations(db: &Database) {
     let mut text_buffers = Vec::new();
     for i in 0..3 {
         let channel = db
-            .create_root_channel(&format!("channel-{i}"), &format!("room-{i}"), user_id)
+            .create_root_channel(&format!("channel-{i}"), user_id)
             .await
             .unwrap();
 

crates/collab/src/db/tests/channel_tests.rs 🔗

@@ -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;
@@ -41,7 +45,7 @@ async fn test_channels(db: &Arc<Database>) {
         .unwrap()
         .user_id;
 
-    let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap();
+    let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
 
     // Make sure that people cannot read channels they haven't been invited to
     assert!(db.get_channel(zed_id, b_id).await.unwrap().is_none());
@@ -54,16 +58,13 @@ async fn test_channels(db: &Arc<Database>) {
         .await
         .unwrap();
 
-    let crdb_id = db
-        .create_channel("crdb", Some(zed_id), "2", a_id)
-        .await
-        .unwrap();
+    let crdb_id = db.create_channel("crdb", Some(zed_id), a_id).await.unwrap();
     let livestreaming_id = db
-        .create_channel("livestreaming", Some(zed_id), "3", a_id)
+        .create_channel("livestreaming", Some(zed_id), a_id)
         .await
         .unwrap();
     let replace_id = db
-        .create_channel("replace", Some(zed_id), "4", a_id)
+        .create_channel("replace", Some(zed_id), a_id)
         .await
         .unwrap();
 
@@ -71,14 +72,14 @@ async fn test_channels(db: &Arc<Database>) {
     members.sort();
     assert_eq!(members, &[a_id, b_id]);
 
-    let rust_id = db.create_root_channel("rust", "5", a_id).await.unwrap();
+    let rust_id = db.create_root_channel("rust", a_id).await.unwrap();
     let cargo_id = db
-        .create_channel("cargo", Some(rust_id), "6", a_id)
+        .create_channel("cargo", Some(rust_id), a_id)
         .await
         .unwrap();
 
     let cargo_ra_id = db
-        .create_channel("cargo-ra", Some(cargo_id), "7", a_id)
+        .create_channel("cargo-ra", Some(cargo_id), a_id)
         .await
         .unwrap();
 
@@ -198,15 +199,20 @@ async fn test_joining_channels(db: &Arc<Database>) {
         .unwrap()
         .user_id;
 
-    let channel_1 = db
-        .create_root_channel("channel_1", "1", user_1)
+    let channel_1 = db.create_root_channel("channel_1", user_1).await.unwrap();
+    let room_1 = db
+        .get_or_create_channel_room(channel_1, "1", TEST_RELEASE_CHANNEL)
         .await
         .unwrap();
-    let room_1 = db.room_id_for_channel(channel_1).await.unwrap();
 
     // 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 +220,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());
 }
@@ -269,15 +280,9 @@ async fn test_channel_invites(db: &Arc<Database>) {
         .unwrap()
         .user_id;
 
-    let channel_1_1 = db
-        .create_root_channel("channel_1", "1", user_1)
-        .await
-        .unwrap();
+    let channel_1_1 = db.create_root_channel("channel_1", user_1).await.unwrap();
 
-    let channel_1_2 = db
-        .create_root_channel("channel_2", "2", user_1)
-        .await
-        .unwrap();
+    let channel_1_2 = db.create_root_channel("channel_2", user_1).await.unwrap();
 
     db.invite_channel_member(channel_1_1, user_2, user_1, false)
         .await
@@ -339,7 +344,7 @@ async fn test_channel_invites(db: &Arc<Database>) {
         .unwrap();
 
     let channel_1_3 = db
-        .create_channel("channel_3", Some(channel_1_1), "1", user_1)
+        .create_channel("channel_3", Some(channel_1_1), user_1)
         .await
         .unwrap();
 
@@ -401,7 +406,7 @@ async fn test_channel_renames(db: &Arc<Database>) {
         .unwrap()
         .user_id;
 
-    let zed_id = db.create_root_channel("zed", "1", user_1).await.unwrap();
+    let zed_id = db.create_root_channel("zed", user_1).await.unwrap();
 
     db.rename_channel(zed_id, user_1, "#zed-archive")
         .await
@@ -446,25 +451,22 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
         .unwrap()
         .user_id;
 
-    let zed_id = db.create_root_channel("zed", "1", a_id).await.unwrap();
+    let zed_id = db.create_root_channel("zed", a_id).await.unwrap();
 
-    let crdb_id = db
-        .create_channel("crdb", Some(zed_id), "2", a_id)
-        .await
-        .unwrap();
+    let crdb_id = db.create_channel("crdb", Some(zed_id), a_id).await.unwrap();
 
     let gpui2_id = db
-        .create_channel("gpui2", Some(zed_id), "3", a_id)
+        .create_channel("gpui2", Some(zed_id), a_id)
         .await
         .unwrap();
 
     let livestreaming_id = db
-        .create_channel("livestreaming", Some(crdb_id), "4", a_id)
+        .create_channel("livestreaming", Some(crdb_id), a_id)
         .await
         .unwrap();
 
     let livestreaming_dag_id = db
-        .create_channel("livestreaming_dag", Some(livestreaming_id), "5", a_id)
+        .create_channel("livestreaming_dag", Some(livestreaming_id), a_id)
         .await
         .unwrap();
 
@@ -517,12 +519,7 @@ async fn test_db_channel_moving(db: &Arc<Database>) {
     // ========================================================================
     // Create a new channel below a channel with multiple parents
     let livestreaming_dag_sub_id = db
-        .create_channel(
-            "livestreaming_dag_sub",
-            Some(livestreaming_dag_id),
-            "6",
-            a_id,
-        )
+        .create_channel("livestreaming_dag_sub", Some(livestreaming_dag_id), a_id)
         .await
         .unwrap();
 
@@ -812,15 +809,15 @@ async fn test_db_channel_moving_bugs(db: &Arc<Database>) {
         .unwrap()
         .user_id;
 
-    let zed_id = db.create_root_channel("zed", "1", user_id).await.unwrap();
+    let zed_id = db.create_root_channel("zed", user_id).await.unwrap();
 
     let projects_id = db
-        .create_channel("projects", Some(zed_id), "2", user_id)
+        .create_channel("projects", Some(zed_id), user_id)
         .await
         .unwrap();
 
     let livestreaming_id = db
-        .create_channel("livestreaming", Some(projects_id), "3", user_id)
+        .create_channel("livestreaming", Some(projects_id), user_id)
         .await
         .unwrap();
 

crates/collab/src/db/tests/db_tests.rs 🔗

@@ -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()
 }

crates/collab/src/db/tests/message_tests.rs 🔗

@@ -25,10 +25,7 @@ async fn test_channel_message_retrieval(db: &Arc<Database>) {
         .await
         .unwrap()
         .user_id;
-    let channel = db
-        .create_channel("channel", None, "room", user)
-        .await
-        .unwrap();
+    let channel = db.create_channel("channel", None, user).await.unwrap();
 
     let owner_id = db.create_server("test").await.unwrap().0 as u32;
     db.join_channel_chat(channel, rpc::ConnectionId { owner_id, id: 0 }, user)
@@ -90,10 +87,7 @@ async fn test_channel_message_nonces(db: &Arc<Database>) {
         .await
         .unwrap()
         .user_id;
-    let channel = db
-        .create_channel("channel", None, "room", user)
-        .await
-        .unwrap();
+    let channel = db.create_channel("channel", None, user).await.unwrap();
 
     let owner_id = db.create_server("test").await.unwrap().0 as u32;
 
@@ -157,15 +151,9 @@ async fn test_channel_message_new_notification(db: &Arc<Database>) {
         .unwrap()
         .user_id;
 
-    let channel_1 = db
-        .create_channel("channel", None, "room", user)
-        .await
-        .unwrap();
+    let channel_1 = db.create_channel("channel", None, user).await.unwrap();
 
-    let channel_2 = db
-        .create_channel("channel-2", None, "room", user)
-        .await
-        .unwrap();
+    let channel_2 = db.create_channel("channel-2", None, user).await.unwrap();
 
     db.invite_channel_member(channel_1, observer, user, false)
         .await

crates/collab/src/rpc.rs 🔗

@@ -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);
@@ -937,11 +938,6 @@ async fn create_room(
         util::async_iife!({
             let live_kit = live_kit?;
 
-            live_kit
-                .create_room(live_kit_room.clone())
-                .await
-                .trace_err()?;
-
             let token = live_kit
                 .room_token(&live_kit_room, &session.user_id.to_string())
                 .trace_err()?;
@@ -957,7 +953,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 +980,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()
@@ -2195,15 +2201,10 @@ async fn create_channel(
     session: Session,
 ) -> Result<()> {
     let db = session.db().await;
-    let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
-
-    if let Some(live_kit) = session.live_kit_client.as_ref() {
-        live_kit.create_room(live_kit_room.clone()).await?;
-    }
 
     let parent_id = request.parent_id.map(|id| ChannelId::from_proto(id));
     let id = db
-        .create_channel(&request.name, parent_id, &live_kit_room, session.user_id)
+        .create_channel(&request.name, parent_id, session.user_id)
         .await?;
 
     let channel = proto::Channel {
@@ -2608,15 +2609,23 @@ async fn join_channel(
     session: Session,
 ) -> Result<()> {
     let channel_id = ChannelId::from_proto(request.channel_id);
+    let live_kit_room = format!("channel-{}", nanoid::nanoid!(30));
 
     let joined_room = {
         leave_room_for_session(&session).await?;
         let db = session.db().await;
 
-        let room_id = db.room_id_for_channel(channel_id).await?;
+        let room_id = db
+            .get_or_create_channel_room(channel_id, &live_kit_room, &*RELEASE_CHANNEL_NAME)
+            .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| {

crates/collab/src/tests/channel_tests.rs 🔗

@@ -380,6 +380,8 @@ async fn test_channel_room(
 
     // Give everyone a chance to observe user A joining
     deterministic.run_until_parked();
+    let room_a = active_call_a.read_with(cx_a, |call, _| call.room().unwrap().clone());
+    room_a.read_with(cx_a, |room, _| assert!(room.is_connected()));
 
     client_a.channel_store().read_with(cx_a, |channels, _| {
         assert_participants_eq(

crates/collab/src/tests/following_tests.rs 🔗

@@ -184,20 +184,12 @@ async fn test_basic_following(
 
     // All clients see that clients B and C are following client A.
     cx_c.foreground().run_until_parked();
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_a, project_id),
-                &[peer_id_b, peer_id_c],
-                "checking followers for A as {name}"
-            );
-        });
+    for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
+        assert_eq!(
+            followers_by_leader(project_id, cx),
+            &[(peer_id_a, vec![peer_id_b, peer_id_c])],
+            "followers seen by {name}"
+        );
     }
 
     // Client C unfollows client A.
@@ -207,46 +199,39 @@ async fn test_basic_following(
 
     // All clients see that clients B is following client A.
     cx_c.foreground().run_until_parked();
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_a, project_id),
-                &[peer_id_b],
-                "checking followers for A as {name}"
-            );
-        });
+    for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
+        assert_eq!(
+            followers_by_leader(project_id, cx),
+            &[(peer_id_a, vec![peer_id_b])],
+            "followers seen by {name}"
+        );
     }
 
     // Client C re-follows client A.
-    workspace_c.update(cx_c, |workspace, cx| {
-        workspace.follow(peer_id_a, cx);
-    });
+    workspace_c
+        .update(cx_c, |workspace, cx| {
+            workspace.follow(peer_id_a, cx).unwrap()
+        })
+        .await
+        .unwrap();
 
     // All clients see that clients B and C are following client A.
     cx_c.foreground().run_until_parked();
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_a, project_id),
-                &[peer_id_b, peer_id_c],
-                "checking followers for A as {name}"
-            );
-        });
+    for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
+        assert_eq!(
+            followers_by_leader(project_id, cx),
+            &[(peer_id_a, vec![peer_id_b, peer_id_c])],
+            "followers seen by {name}"
+        );
     }
 
-    // Client D follows client C.
+    // Client D follows client B, then switches to following client C.
+    workspace_d
+        .update(cx_d, |workspace, cx| {
+            workspace.follow(peer_id_b, cx).unwrap()
+        })
+        .await
+        .unwrap();
     workspace_d
         .update(cx_d, |workspace, cx| {
             workspace.follow(peer_id_c, cx).unwrap()
@@ -256,20 +241,15 @@ async fn test_basic_following(
 
     // All clients see that D is following C
     cx_d.foreground().run_until_parked();
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_c, project_id),
-                &[peer_id_d],
-                "checking followers for C as {name}"
-            );
-        });
+    for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
+        assert_eq!(
+            followers_by_leader(project_id, cx),
+            &[
+                (peer_id_a, vec![peer_id_b, peer_id_c]),
+                (peer_id_c, vec![peer_id_d])
+            ],
+            "followers seen by {name}"
+        );
     }
 
     // Client C closes the project.
@@ -278,32 +258,12 @@ async fn test_basic_following(
 
     // Clients A and B see that client B is following A, and client C is not present in the followers.
     cx_c.foreground().run_until_parked();
-    for (name, active_call, cx) in [("A", &active_call_a, &cx_a), ("B", &active_call_b, &cx_b)] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_a, project_id),
-                &[peer_id_b],
-                "checking followers for A as {name}"
-            );
-        });
-    }
-
-    // All clients see that no-one is following C
-    for (name, active_call, cx) in [
-        ("A", &active_call_a, &cx_a),
-        ("B", &active_call_b, &cx_b),
-        ("C", &active_call_c, &cx_c),
-        ("D", &active_call_d, &cx_d),
-    ] {
-        active_call.read_with(*cx, |call, cx| {
-            let room = call.room().unwrap().read(cx);
-            assert_eq!(
-                room.followers_for(peer_id_c, project_id),
-                &[],
-                "checking followers for C as {name}"
-            );
-        });
+    for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] {
+        assert_eq!(
+            followers_by_leader(project_id, cx),
+            &[(peer_id_a, vec![peer_id_b]),],
+            "followers seen by {name}"
+        );
     }
 
     // When client A activates a different editor, client B does so as well.
@@ -1667,6 +1627,30 @@ struct PaneSummary {
     items: Vec<(bool, String)>,
 }
 
+fn followers_by_leader(project_id: u64, cx: &TestAppContext) -> Vec<(PeerId, Vec<PeerId>)> {
+    cx.read(|cx| {
+        let active_call = ActiveCall::global(cx).read(cx);
+        let peer_id = active_call.client().peer_id();
+        let room = active_call.room().unwrap().read(cx);
+        let mut result = room
+            .remote_participants()
+            .values()
+            .map(|participant| participant.peer_id)
+            .chain(peer_id)
+            .filter_map(|peer_id| {
+                let followers = room.followers_for(peer_id, project_id);
+                if followers.is_empty() {
+                    None
+                } else {
+                    Some((peer_id, followers.to_vec()))
+                }
+            })
+            .collect::<Vec<_>>();
+        result.sort_by_key(|e| e.0);
+        result
+    })
+}
+
 fn pane_summaries(workspace: &ViewHandle<Workspace>, cx: &mut TestAppContext) -> Vec<PaneSummary> {
     workspace.read_with(cx, |workspace, cx| {
         let active_pane = workspace.active_pane();

crates/collab/src/tests/random_channel_buffer_tests.rs 🔗

@@ -46,12 +46,7 @@ impl RandomizedTest for RandomChannelBufferTest {
         let db = &server.app_state.db;
         for ix in 0..CHANNEL_COUNT {
             let id = db
-                .create_channel(
-                    &format!("channel-{ix}"),
-                    None,
-                    &format!("livekit-room-{ix}"),
-                    users[0].user_id,
-                )
+                .create_channel(&format!("channel-{ix}"), None, users[0].user_id)
                 .await
                 .unwrap();
             for user in &users[1..] {

crates/collab/src/tests/test_server.rs 🔗

@@ -44,6 +44,7 @@ pub struct TestServer {
 pub struct TestClient {
     pub username: String,
     pub app_state: Arc<workspace::AppState>,
+    channel_store: ModelHandle<ChannelStore>,
     state: RefCell<TestClientState>,
 }
 
@@ -206,15 +207,12 @@ impl TestServer {
         let fs = FakeFs::new(cx.background());
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
         let workspace_store = cx.add_model(|cx| WorkspaceStore::new(client.clone(), cx));
-        let channel_store =
-            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
         let mut language_registry = LanguageRegistry::test();
         language_registry.set_executor(cx.background());
         let app_state = Arc::new(workspace::AppState {
             client: client.clone(),
             user_store: user_store.clone(),
             workspace_store,
-            channel_store: channel_store.clone(),
             languages: Arc::new(language_registry),
             fs: fs.clone(),
             build_window_options: |_, _, _| Default::default(),
@@ -231,7 +229,7 @@ impl TestServer {
             workspace::init(app_state.clone(), cx);
             audio::init((), cx);
             call::init(client.clone(), user_store.clone(), cx);
-            channel::init(&client);
+            channel::init(&client, user_store, cx);
         });
 
         client
@@ -242,6 +240,7 @@ impl TestServer {
         let client = TestClient {
             app_state,
             username: name.to_string(),
+            channel_store: cx.read(ChannelStore::global).clone(),
             state: Default::default(),
         };
         client.wait_for_current_user(cx).await;
@@ -310,10 +309,9 @@ impl TestServer {
         admin: (&TestClient, &mut TestAppContext),
         members: &mut [(&TestClient, &mut TestAppContext)],
     ) -> u64 {
-        let (admin_client, admin_cx) = admin;
-        let channel_id = admin_client
-            .app_state
-            .channel_store
+        let (_, admin_cx) = admin;
+        let channel_id = admin_cx
+            .read(ChannelStore::global)
             .update(admin_cx, |channel_store, cx| {
                 channel_store.create_channel(channel, parent, cx)
             })
@@ -321,9 +319,8 @@ impl TestServer {
             .unwrap();
 
         for (member_client, member_cx) in members {
-            admin_client
-                .app_state
-                .channel_store
+            admin_cx
+                .read(ChannelStore::global)
                 .update(admin_cx, |channel_store, cx| {
                     channel_store.invite_member(
                         channel_id,
@@ -337,9 +334,8 @@ impl TestServer {
 
             admin_cx.foreground().run_until_parked();
 
-            member_client
-                .app_state
-                .channel_store
+            member_cx
+                .read(ChannelStore::global)
                 .update(*member_cx, |channels, _| {
                     channels.respond_to_channel_invite(channel_id, true)
                 })
@@ -447,7 +443,7 @@ impl TestClient {
     }
 
     pub fn channel_store(&self) -> &ModelHandle<ChannelStore> {
-        &self.app_state.channel_store
+        &self.channel_store
     }
 
     pub fn user_store(&self) -> &ModelHandle<UserStore> {
@@ -614,8 +610,8 @@ impl TestClient {
     ) {
         let (other_client, other_cx) = user;
 
-        self.app_state
-            .channel_store
+        cx_self
+            .read(ChannelStore::global)
             .update(cx_self, |channel_store, cx| {
                 channel_store.invite_member(channel, other_client.user_id().unwrap(), true, cx)
             })
@@ -624,11 +620,10 @@ impl TestClient {
 
         cx_self.foreground().run_until_parked();
 
-        other_client
-            .app_state
-            .channel_store
-            .update(other_cx, |channels, _| {
-                channels.respond_to_channel_invite(channel, true)
+        other_cx
+            .read(ChannelStore::global)
+            .update(other_cx, |channel_store, _| {
+                channel_store.respond_to_channel_invite(channel, true)
             })
             .await
             .unwrap();

crates/collab_ui/src/channel_view.rs 🔗

@@ -73,7 +73,7 @@ impl ChannelView {
     ) -> Task<Result<ViewHandle<Self>>> {
         let workspace = workspace.read(cx);
         let project = workspace.project().to_owned();
-        let channel_store = workspace.app_state().channel_store.clone();
+        let channel_store = ChannelStore::global(cx);
         let markdown = workspace
             .app_state()
             .languages

crates/collab_ui/src/chat_panel.rs 🔗

@@ -81,7 +81,7 @@ impl ChatPanel {
     pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
         let fs = workspace.app_state().fs.clone();
         let client = workspace.app_state().client.clone();
-        let channel_store = workspace.app_state().channel_store.clone();
+        let channel_store = ChannelStore::global(cx);
         let languages = workspace.app_state().languages.clone();
 
         let input_editor = cx.add_view(|cx| {

crates/collab_ui/src/collab_panel.rs 🔗

@@ -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>| {
@@ -648,7 +655,7 @@ impl CollabPanel {
                 channel_editing_state: None,
                 selection: None,
                 user_store: workspace.user_store().clone(),
-                channel_store: workspace.app_state().channel_store.clone(),
+                channel_store: ChannelStore::global(cx),
                 project: workspace.project().clone(),
                 subscriptions: Vec::default(),
                 match_candidates: Vec::default(),
@@ -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(

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -2,6 +2,7 @@ use crate::{
     contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute,
     toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing,
 };
+use auto_update::AutoUpdateStatus;
 use call::{ActiveCall, ParticipantLocation, Room};
 use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore};
 use clock::ReplicaId;
@@ -1177,22 +1178,38 @@ impl CollabTitlebarItem {
                     .with_style(theme.titlebar.offline_icon.container)
                     .into_any(),
             ),
-            client::Status::UpgradeRequired => Some(
-                MouseEventHandler::new::<ConnectionStatusButton, _>(0, cx, |_, _| {
-                    Label::new(
-                        "Please update Zed to collaborate",
-                        theme.titlebar.outdated_warning.text.clone(),
-                    )
-                    .contained()
-                    .with_style(theme.titlebar.outdated_warning.container)
-                    .aligned()
-                })
-                .with_cursor_style(CursorStyle::PointingHand)
-                .on_click(MouseButton::Left, |_, _, cx| {
-                    auto_update::check(&Default::default(), cx);
-                })
-                .into_any(),
-            ),
+            client::Status::UpgradeRequired => {
+                let auto_updater = auto_update::AutoUpdater::get(cx);
+                let label = match auto_updater.map(|auto_update| auto_update.read(cx).status()) {
+                    Some(AutoUpdateStatus::Updated) => "Please restart Zed to Collaborate",
+                    Some(AutoUpdateStatus::Installing)
+                    | Some(AutoUpdateStatus::Downloading)
+                    | Some(AutoUpdateStatus::Checking) => "Updating...",
+                    Some(AutoUpdateStatus::Idle) | Some(AutoUpdateStatus::Errored) | None => {
+                        "Please update Zed to Collaborate"
+                    }
+                };
+
+                Some(
+                    MouseEventHandler::new::<ConnectionStatusButton, _>(0, cx, |_, _| {
+                        Label::new(label, theme.titlebar.outdated_warning.text.clone())
+                            .contained()
+                            .with_style(theme.titlebar.outdated_warning.container)
+                            .aligned()
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .on_click(MouseButton::Left, |_, _, cx| {
+                        if let Some(auto_updater) = auto_update::AutoUpdater::get(cx) {
+                            if auto_updater.read(cx).status() == AutoUpdateStatus::Updated {
+                                workspace::restart(&Default::default(), cx);
+                                return;
+                            }
+                        }
+                        auto_update::check(&Default::default(), cx);
+                    })
+                    .into_any(),
+                )
+            }
             _ => None,
         }
     }

crates/editor/src/editor_tests.rs 🔗

@@ -1333,7 +1333,7 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut gpui::TestAppCon
 
     cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
     cx.assert_editor_state(
-        &r#"ˇone
+        &r#"one
         two
 
         three
@@ -1344,54 +1344,41 @@ async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut gpui::TestAppCon
             .unindent(),
     );
 
-    cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx));
+    cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
     cx.assert_editor_state(
-        &r#"ˇone
+        &r#"one
         two
-        ˇ
+
         three
         four
         five
-
+        ˇ
         six"#
             .unindent(),
     );
 
     cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
     cx.assert_editor_state(
-        &r#"ˇone
+        &r#"one
         two
-
+        ˇ
         three
         four
         five
 
-        sixˇ"#
+        six"#
             .unindent(),
     );
 
     cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
     cx.assert_editor_state(
-        &r#"one
+        &r#"ˇone
         two
 
         three
         four
         five
-        ˇ
-        sixˇ"#
-            .unindent(),
-    );
 
-    cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx));
-    cx.assert_editor_state(
-        &r#"one
-        two
-        ˇ
-        three
-        four
-        five
-        ˇ
         six"#
             .unindent(),
     );

crates/editor/src/movement.rs 🔗

@@ -234,7 +234,7 @@ pub fn start_of_paragraph(
 ) -> DisplayPoint {
     let point = display_point.to_point(map);
     if point.row == 0 {
-        return map.max_point();
+        return DisplayPoint::zero();
     }
 
     let mut found_non_blank_line = false;
@@ -261,7 +261,7 @@ pub fn end_of_paragraph(
 ) -> DisplayPoint {
     let point = display_point.to_point(map);
     if point.row == map.max_buffer_row() {
-        return DisplayPoint::zero();
+        return map.max_point();
     }
 
     let mut found_non_blank_line = false;

crates/file_finder/src/file_finder.rs 🔗

@@ -107,13 +107,23 @@ fn matching_history_item_paths(
 ) -> HashMap<Arc<Path>, PathMatch> {
     let history_items_by_worktrees = history_items
         .iter()
-        .map(|found_path| {
-            let path = &found_path.project.path;
+        .filter_map(|found_path| {
             let candidate = PathMatchCandidate {
-                path,
-                char_bag: CharBag::from_iter(path.to_string_lossy().to_lowercase().chars()),
+                path: &found_path.project.path,
+                // Only match history items names, otherwise their paths may match too many queries, producing false positives.
+                // E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
+                // it would be shown first always, despite the latter being a better match.
+                char_bag: CharBag::from_iter(
+                    found_path
+                        .project
+                        .path
+                        .file_name()?
+                        .to_string_lossy()
+                        .to_lowercase()
+                        .chars(),
+                ),
             };
-            (found_path.project.worktree_id, candidate)
+            Some((found_path.project.worktree_id, candidate))
         })
         .fold(
             HashMap::default(),
@@ -212,6 +222,10 @@ fn toggle_or_cycle_file_finder(
                                         .as_ref()
                                         .and_then(|found_path| found_path.absolute.as_ref())
                             })
+                            .filter(|(_, history_abs_path)| match history_abs_path {
+                                Some(abs_path) => history_file_exists(abs_path),
+                                None => true,
+                            })
                             .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)),
                     )
                     .collect();
@@ -236,6 +250,16 @@ fn toggle_or_cycle_file_finder(
     }
 }
 
+#[cfg(not(test))]
+fn history_file_exists(abs_path: &PathBuf) -> bool {
+    abs_path.exists()
+}
+
+#[cfg(test)]
+fn history_file_exists(abs_path: &PathBuf) -> bool {
+    !abs_path.ends_with("nonexistent.rs")
+}
+
 pub enum Event {
     Selected(ProjectPath),
     Dismissed,
@@ -505,12 +529,7 @@ impl PickerDelegate for FileFinderDelegate {
                         project
                             .worktree_for_id(history_item.project.worktree_id, cx)
                             .is_some()
-                            || (project.is_local()
-                                && history_item
-                                    .absolute
-                                    .as_ref()
-                                    .filter(|abs_path| abs_path.exists())
-                                    .is_some())
+                            || (project.is_local() && history_item.absolute.is_some())
                     })
                     .cloned()
                     .map(|p| (p, None))
@@ -1803,6 +1822,202 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_history_items_vs_very_good_external_match(
+        deterministic: Arc<gpui::executor::Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/src",
+                json!({
+                    "collab_ui": {
+                        "first.rs": "// First Rust file",
+                        "second.rs": "// Second Rust file",
+                        "third.rs": "// Third Rust file",
+                        "collab_ui.rs": "// Fourth Rust file",
+                    }
+                }),
+            )
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
+        let workspace = window.root(cx);
+        // generate some history to select from
+        open_close_queried_buffer(
+            "fir",
+            1,
+            "first.rs",
+            window.into(),
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        open_close_queried_buffer(
+            "sec",
+            1,
+            "second.rs",
+            window.into(),
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        open_close_queried_buffer(
+            "thi",
+            1,
+            "third.rs",
+            window.into(),
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        open_close_queried_buffer(
+            "sec",
+            1,
+            "second.rs",
+            window.into(),
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+
+        cx.dispatch_action(window.into(), Toggle);
+        let query = "collab_ui";
+        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
+        finder
+            .update(cx, |finder, cx| {
+                finder.delegate_mut().update_matches(query.to_string(), cx)
+            })
+            .await;
+        finder.read_with(cx, |finder, _| {
+            let delegate = finder.delegate();
+            assert!(
+                delegate.matches.history.is_empty(),
+                "History items should not math query {query}, they should be matched by name only"
+            );
+
+            let search_entries = delegate
+                .matches
+                .search
+                .iter()
+                .map(|path_match| path_match.path.to_path_buf())
+                .collect::<Vec<_>>();
+            assert_eq!(
+                search_entries,
+                vec![
+                    PathBuf::from("collab_ui/collab_ui.rs"),
+                    PathBuf::from("collab_ui/third.rs"),
+                    PathBuf::from("collab_ui/first.rs"),
+                    PathBuf::from("collab_ui/second.rs"),
+                ],
+                "Despite all search results having the same directory name, the most matching one should be on top"
+            );
+        });
+    }
+
+    #[gpui::test]
+    async fn test_nonexistent_history_items_not_shown(
+        deterministic: Arc<gpui::executor::Deterministic>,
+        cx: &mut gpui::TestAppContext,
+    ) {
+        let app_state = init_test(cx);
+
+        app_state
+            .fs
+            .as_fake()
+            .insert_tree(
+                "/src",
+                json!({
+                    "test": {
+                        "first.rs": "// First Rust file",
+                        "nonexistent.rs": "// Second Rust file",
+                        "third.rs": "// Third Rust file",
+                    }
+                }),
+            )
+            .await;
+
+        let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+        let window = cx.add_window(|cx| Workspace::test_new(project, cx));
+        let workspace = window.root(cx);
+        // generate some history to select from
+        open_close_queried_buffer(
+            "fir",
+            1,
+            "first.rs",
+            window.into(),
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        open_close_queried_buffer(
+            "non",
+            1,
+            "nonexistent.rs",
+            window.into(),
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        open_close_queried_buffer(
+            "thi",
+            1,
+            "third.rs",
+            window.into(),
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+        open_close_queried_buffer(
+            "fir",
+            1,
+            "first.rs",
+            window.into(),
+            &workspace,
+            &deterministic,
+            cx,
+        )
+        .await;
+
+        cx.dispatch_action(window.into(), Toggle);
+        let query = "rs";
+        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
+        finder
+            .update(cx, |finder, cx| {
+                finder.delegate_mut().update_matches(query.to_string(), cx)
+            })
+            .await;
+        finder.read_with(cx, |finder, _| {
+            let delegate = finder.delegate();
+            let history_entries = delegate
+                .matches
+                .history
+                .iter()
+                .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
+                .collect::<Vec<_>>();
+            assert_eq!(
+                history_entries,
+                vec![
+                    PathBuf::from("test/first.rs"),
+                    PathBuf::from("test/third.rs"),
+                ],
+                "Should have all opened files in the history, except the ones that do not exist on disk"
+            );
+        });
+    }
+
     async fn open_close_queried_buffer(
         input: &str,
         expected_matches: usize,

crates/fs/Cargo.toml 🔗

@@ -13,7 +13,6 @@ rope = { path = "../rope" }
 text = { path = "../text" }
 util = { path = "../util" }
 sum_tree = { path = "../sum_tree" }
-rpc = { path = "../rpc" }
 
 anyhow.workspace = true
 async-trait.workspace = true

crates/fs/src/repository.rs 🔗

@@ -2,7 +2,6 @@ use anyhow::Result;
 use collections::HashMap;
 use git2::{BranchType, StatusShow};
 use parking_lot::Mutex;
-use rpc::proto;
 use serde_derive::{Deserialize, Serialize};
 use std::{
     cmp::Ordering,
@@ -23,6 +22,7 @@ pub struct Branch {
     /// Timestamp of most recent commit, normalized to Unix Epoch format.
     pub unix_timestamp: Option<i64>,
 }
+
 #[async_trait::async_trait]
 pub trait GitRepository: Send {
     fn reload_index(&self);
@@ -358,24 +358,6 @@ impl GitFileStatus {
             }
         }
     }
-
-    pub fn from_proto(git_status: Option<i32>) -> Option<GitFileStatus> {
-        git_status.and_then(|status| {
-            proto::GitStatus::from_i32(status).map(|status| match status {
-                proto::GitStatus::Added => GitFileStatus::Added,
-                proto::GitStatus::Modified => GitFileStatus::Modified,
-                proto::GitStatus::Conflict => GitFileStatus::Conflict,
-            })
-        })
-    }
-
-    pub fn to_proto(self) -> i32 {
-        match self {
-            GitFileStatus::Added => proto::GitStatus::Added as i32,
-            GitFileStatus::Modified => proto::GitStatus::Modified as i32,
-            GitFileStatus::Conflict => proto::GitStatus::Conflict as i32,
-        }
-    }
 }
 
 #[derive(Clone, Debug, Ord, Hash, PartialOrd, Eq, PartialEq)]

crates/fuzzy/src/matcher.rs 🔗

@@ -441,7 +441,7 @@ mod tests {
                 score,
                 worktree_id: 0,
                 positions: Vec::new(),
-                path: candidate.path.clone(),
+                path: Arc::from(candidate.path),
                 path_prefix: "".into(),
                 distance_to_relative_ancestor: usize::MAX,
             },

crates/fuzzy/src/paths.rs 🔗

@@ -14,7 +14,7 @@ use crate::{
 
 #[derive(Clone, Debug)]
 pub struct PathMatchCandidate<'a> {
-    pub path: &'a Arc<Path>,
+    pub path: &'a Path,
     pub char_bag: CharBag,
 }
 
@@ -120,7 +120,7 @@ pub fn match_fixed_path_set(
             score,
             worktree_id,
             positions: Vec::new(),
-            path: candidate.path.clone(),
+            path: Arc::from(candidate.path),
             path_prefix: Arc::from(""),
             distance_to_relative_ancestor: usize::MAX,
         },
@@ -195,7 +195,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
                                     score,
                                     worktree_id,
                                     positions: Vec::new(),
-                                    path: candidate.path.clone(),
+                                    path: Arc::from(candidate.path),
                                     path_prefix: candidate_set.prefix(),
                                     distance_to_relative_ancestor: relative_to.as_ref().map_or(
                                         usize::MAX,

crates/gpui/src/platform/mac/platform.rs 🔗

@@ -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);

crates/language/Cargo.toml 🔗

@@ -22,7 +22,6 @@ test-support = [
 ]
 
 [dependencies]
-client = { path = "../client" }
 clock = { path = "../clock" }
 collections = { path = "../collections" }
 fuzzy = { path = "../fuzzy" }

crates/live_kit_client/src/test.rs 🔗

@@ -91,9 +91,8 @@ impl TestServer {
         let identity = claims.sub.unwrap().to_string();
         let room_name = claims.video.room.unwrap();
         let mut server_rooms = self.rooms.lock();
-        let room = server_rooms
-            .get_mut(&*room_name)
-            .ok_or_else(|| anyhow!("room {:?} does not exist", room_name))?;
+        let room = (*server_rooms).entry(room_name.to_string()).or_default();
+
         if room.client_rooms.contains_key(&identity) {
             Err(anyhow!(
                 "{:?} attempted to join room {:?} twice",

crates/project/src/worktree.rs 🔗

@@ -4310,7 +4310,7 @@ impl<'a> From<&'a Entry> for proto::Entry {
             is_symlink: entry.is_symlink,
             is_ignored: entry.is_ignored,
             is_external: entry.is_external,
-            git_status: entry.git_status.map(|status| status.to_proto()),
+            git_status: entry.git_status.map(git_status_to_proto),
         }
     }
 }
@@ -4337,7 +4337,7 @@ impl<'a> TryFrom<(&'a CharBag, proto::Entry)> for Entry {
                 is_symlink: entry.is_symlink,
                 is_ignored: entry.is_ignored,
                 is_external: entry.is_external,
-                git_status: GitFileStatus::from_proto(entry.git_status),
+                git_status: git_status_from_proto(entry.git_status),
             })
         } else {
             Err(anyhow!(
@@ -4366,3 +4366,21 @@ fn combine_git_statuses(
         unstaged
     }
 }
+
+fn git_status_from_proto(git_status: Option<i32>) -> Option<GitFileStatus> {
+    git_status.and_then(|status| {
+        proto::GitStatus::from_i32(status).map(|status| match status {
+            proto::GitStatus::Added => GitFileStatus::Added,
+            proto::GitStatus::Modified => GitFileStatus::Modified,
+            proto::GitStatus::Conflict => GitFileStatus::Conflict,
+        })
+    })
+}
+
+fn git_status_to_proto(status: GitFileStatus) -> i32 {
+    match status {
+        GitFileStatus::Added => proto::GitStatus::Added as i32,
+        GitFileStatus::Modified => proto::GitStatus::Modified as i32,
+        GitFileStatus::Conflict => proto::GitStatus::Conflict as i32,
+    }
+}

crates/util/src/channel.rs 🔗

@@ -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
 }

crates/util/src/paths.rs 🔗

@@ -139,6 +139,12 @@ impl<P> PathLikeWithPosition<P> {
                                     column: None,
                                 })
                             } else {
+                                let maybe_col_str =
+                                    if maybe_col_str.ends_with(FILE_ROW_COLUMN_DELIMITER) {
+                                        &maybe_col_str[..maybe_col_str.len() - 1]
+                                    } else {
+                                        maybe_col_str
+                                    };
                                 match maybe_col_str.parse::<u32>() {
                                     Ok(col) => Ok(Self {
                                         path_like: parse_path_like_str(path_like_str)?,
@@ -241,7 +247,6 @@ mod tests {
             "test_file.rs:1::",
             "test_file.rs::1:2",
             "test_file.rs:1::2",
-            "test_file.rs:1:2:",
             "test_file.rs:1:2:3",
         ] {
             let actual = parse_str(input);
@@ -277,6 +282,14 @@ mod tests {
                     column: None,
                 },
             ),
+            (
+                "crates/file_finder/src/file_finder.rs:1902:13:",
+                PathLikeWithPosition {
+                    path_like: "crates/file_finder/src/file_finder.rs".to_string(),
+                    row: Some(1902),
+                    column: Some(13),
+                },
+            ),
         ];
 
         for (input, expected) in input_and_expected {

crates/vim/src/normal.rs 🔗

@@ -46,6 +46,7 @@ actions!(
         ChangeToEndOfLine,
         DeleteToEndOfLine,
         Yank,
+        YankLine,
         ChangeCase,
         JoinLines,
     ]
@@ -66,6 +67,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(insert_line_above);
     cx.add_action(insert_line_below);
     cx.add_action(change_case);
+    cx.add_action(yank_line);
 
     cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| {
         Vim::update(cx, |vim, cx| {
@@ -308,6 +310,13 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
     });
 }
 
+fn yank_line(_: &mut Workspace, _: &YankLine, cx: &mut ViewContext<Workspace>) {
+    Vim::update(cx, |vim, cx| {
+        let count = vim.take_count(cx);
+        yank_motion(vim, motion::Motion::CurrentLine, count, cx)
+    })
+}
+
 pub(crate) fn normal_replace(text: Arc<str>, cx: &mut WindowContext) {
     Vim::update(cx, |vim, cx| {
         vim.stop_recording();

crates/vim/src/test.rs 🔗

@@ -652,3 +652,28 @@ async fn test_selection_goal(cx: &mut gpui::TestAppContext) {
         Lorem Ipsum"})
         .await;
 }
+
+#[gpui::test]
+async fn test_paragraphs_dont_wrap(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+
+    cx.set_shared_state(indoc! {"
+        one
+        ˇ
+        two"})
+        .await;
+
+    cx.simulate_shared_keystrokes(["}", "}"]).await;
+    cx.assert_shared_state(indoc! {"
+        one
+
+        twˇo"})
+        .await;
+
+    cx.simulate_shared_keystrokes(["{", "{", "{"]).await;
+    cx.assert_shared_state(indoc! {"
+        ˇone
+
+        two"})
+        .await;
+}

crates/vim/src/vim.rs 🔗

@@ -33,7 +33,7 @@ use workspace::{self, Workspace};
 
 use crate::state::ReplayableAction;
 
-struct VimModeSetting(bool);
+pub struct VimModeSetting(pub bool);
 
 #[derive(Clone, Deserialize, PartialEq)]
 pub struct SwitchMode(pub Mode);

crates/welcome/Cargo.toml 🔗

@@ -25,6 +25,7 @@ theme_selector = { path = "../theme_selector" }
 util = { path = "../util" }
 picker = { path = "../picker" }
 workspace = { path = "../workspace" }
+vim = { path = "../vim" }
 
 anyhow.workspace = true
 log.workspace = true

crates/welcome/src/welcome.rs 🔗

@@ -10,6 +10,7 @@ use gpui::{
 };
 use settings::{update_settings_file, SettingsStore};
 use std::{borrow::Cow, sync::Arc};
+use vim::VimModeSetting;
 use workspace::{
     dock::DockPosition, item::Item, open_new, AppState, PaneBackdrop, Welcome, Workspace,
     WorkspaceId,
@@ -65,6 +66,7 @@ impl View for WelcomePage {
         let width = theme.welcome.page_width;
 
         let telemetry_settings = *settings::get::<TelemetrySettings>(cx);
+        let vim_mode_setting = settings::get::<VimModeSetting>(cx).0;
 
         enum Metrics {}
         enum Diagnostics {}
@@ -144,6 +146,27 @@ impl View for WelcomePage {
                 )
                 .with_child(
                     Flex::column()
+                        .with_child(
+                            theme::ui::checkbox::<Diagnostics, Self, _>(
+                                "Enable vim mode",
+                                &theme.welcome.checkbox,
+                                vim_mode_setting,
+                                0,
+                                cx,
+                                |this, checked, cx| {
+                                    if let Some(workspace) = this.workspace.upgrade(cx) {
+                                        let fs = workspace.read(cx).app_state().fs.clone();
+                                        update_settings_file::<VimModeSetting>(
+                                            fs,
+                                            cx,
+                                            move |setting| *setting = Some(checked),
+                                        )
+                                    }
+                                },
+                            )
+                            .contained()
+                            .with_style(theme.welcome.checkbox_container),
+                        )
                         .with_child(
                             theme::ui::checkbox_with_label::<Metrics, _, Self, _>(
                                 Flex::column()
@@ -186,7 +209,7 @@ impl View for WelcomePage {
                                 "Send crash reports",
                                 &theme.welcome.checkbox,
                                 telemetry_settings.diagnostics,
-                                0,
+                                1,
                                 cx,
                                 |this, checked, cx| {
                                     if let Some(workspace) = this.workspace.upgrade(cx) {

crates/workspace/Cargo.toml 🔗

@@ -22,7 +22,6 @@ test-support = [
 db = { path = "../db" }
 call = { path = "../call" }
 client = { path = "../client" }
-channel = { path = "../channel" }
 collections = { path = "../collections" }
 context_menu = { path = "../context_menu" }
 drag_and_drop = { path = "../drag_and_drop" }

crates/workspace/src/pane_group.rs 🔗

@@ -1,10 +1,7 @@
-use std::{cell::RefCell, rc::Rc, sync::Arc};
-
-use crate::{
-    pane_group::element::PaneAxisElement, AppState, FollowerStatesByLeader, Pane, Workspace,
-};
+use crate::{pane_group::element::PaneAxisElement, AppState, FollowerState, Pane, Workspace};
 use anyhow::{anyhow, Result};
 use call::{ActiveCall, ParticipantLocation};
+use collections::HashMap;
 use gpui::{
     elements::*,
     geometry::{rect::RectF, vector::Vector2F},
@@ -13,6 +10,7 @@ use gpui::{
 };
 use project::Project;
 use serde::Deserialize;
+use std::{cell::RefCell, rc::Rc, sync::Arc};
 use theme::Theme;
 
 const HANDLE_HITBOX_SIZE: f32 = 4.0;
@@ -95,7 +93,7 @@ impl PaneGroup {
         &self,
         project: &ModelHandle<Project>,
         theme: &Theme,
-        follower_states: &FollowerStatesByLeader,
+        follower_states: &HashMap<ViewHandle<Pane>, FollowerState>,
         active_call: Option<&ModelHandle<ActiveCall>>,
         active_pane: &ViewHandle<Pane>,
         zoomed: Option<&AnyViewHandle>,
@@ -162,7 +160,7 @@ impl Member {
         project: &ModelHandle<Project>,
         basis: usize,
         theme: &Theme,
-        follower_states: &FollowerStatesByLeader,
+        follower_states: &HashMap<ViewHandle<Pane>, FollowerState>,
         active_call: Option<&ModelHandle<ActiveCall>>,
         active_pane: &ViewHandle<Pane>,
         zoomed: Option<&AnyViewHandle>,
@@ -179,19 +177,10 @@ impl Member {
                     ChildView::new(pane, cx).into_any()
                 };
 
-                let leader = follower_states
-                    .iter()
-                    .find_map(|(leader_id, follower_states)| {
-                        if follower_states.contains_key(pane) {
-                            Some(leader_id)
-                        } else {
-                            None
-                        }
-                    })
-                    .and_then(|leader_id| {
-                        let room = active_call?.read(cx).room()?.read(cx);
-                        room.remote_participant_for_peer_id(*leader_id)
-                    });
+                let leader = follower_states.get(pane).and_then(|state| {
+                    let room = active_call?.read(cx).room()?.read(cx);
+                    room.remote_participant_for_peer_id(state.leader_id)
+                });
 
                 let mut leader_border = Border::default();
                 let mut leader_status_box = None;
@@ -486,7 +475,7 @@ impl PaneAxis {
         project: &ModelHandle<Project>,
         basis: usize,
         theme: &Theme,
-        follower_state: &FollowerStatesByLeader,
+        follower_states: &HashMap<ViewHandle<Pane>, FollowerState>,
         active_call: Option<&ModelHandle<ActiveCall>>,
         active_pane: &ViewHandle<Pane>,
         zoomed: Option<&AnyViewHandle>,
@@ -515,7 +504,7 @@ impl PaneAxis {
                 project,
                 (basis + ix) * 10,
                 theme,
-                follower_state,
+                follower_states,
                 active_call,
                 active_pane,
                 zoomed,

crates/workspace/src/workspace.rs 🔗

@@ -12,10 +12,9 @@ mod workspace_settings;
 
 use anyhow::{anyhow, Context, Result};
 use call::ActiveCall;
-use channel::ChannelStore;
 use client::{
     proto::{self, PeerId},
-    Client, TypedEnvelope, UserStore,
+    Client, Status, TypedEnvelope, UserStore,
 };
 use collections::{hash_map, HashMap, HashSet};
 use drag_and_drop::DragAndDrop;
@@ -36,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;
@@ -450,7 +449,6 @@ pub struct AppState {
     pub languages: Arc<LanguageRegistry>,
     pub client: Arc<Client>,
     pub user_store: ModelHandle<UserStore>,
-    pub channel_store: ModelHandle<ChannelStore>,
     pub workspace_store: ModelHandle<WorkspaceStore>,
     pub fs: Arc<dyn fs::Fs>,
     pub build_window_options:
@@ -487,8 +485,6 @@ impl AppState {
         let http_client = util::http::FakeHttpClient::with_404_response();
         let client = Client::new(http_client.clone(), cx);
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http_client, cx));
-        let channel_store =
-            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
         let workspace_store = cx.add_model(|cx| WorkspaceStore::new(client.clone(), cx));
 
         theme::init((), cx);
@@ -500,7 +496,7 @@ impl AppState {
             fs,
             languages,
             user_store,
-            channel_store,
+            // channel_store,
             workspace_store,
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
             build_window_options: |_, _, _| Default::default(),
@@ -578,7 +574,7 @@ pub struct Workspace {
     titlebar_item: Option<AnyViewHandle>,
     notifications: Vec<(TypeId, usize, Box<dyn NotificationHandle>)>,
     project: ModelHandle<Project>,
-    follower_states_by_leader: FollowerStatesByLeader,
+    follower_states: HashMap<ViewHandle<Pane>, FollowerState>,
     last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>,
     window_edited: bool,
     active_call: Option<(ModelHandle<ActiveCall>, Vec<Subscription>)>,
@@ -603,10 +599,9 @@ pub struct ViewId {
     pub id: u64,
 }
 
-type FollowerStatesByLeader = HashMap<PeerId, HashMap<ViewHandle<Pane>, FollowerState>>;
-
 #[derive(Default)]
 struct FollowerState {
+    leader_id: PeerId,
     active_view_id: Option<ViewId>,
     items_by_leader_view_id: HashMap<ViewId, Box<dyn FollowableItemHandle>>,
 }
@@ -795,7 +790,7 @@ impl Workspace {
             bottom_dock,
             right_dock,
             project: project.clone(),
-            follower_states_by_leader: Default::default(),
+            follower_states: Default::default(),
             last_leaders_by_pane: Default::default(),
             window_edited: false,
             active_call,
@@ -2513,13 +2508,16 @@ impl Workspace {
     }
 
     fn collaborator_left(&mut self, peer_id: PeerId, cx: &mut ViewContext<Self>) {
-        if let Some(states_by_pane) = self.follower_states_by_leader.remove(&peer_id) {
-            for state in states_by_pane.into_values() {
-                for item in state.items_by_leader_view_id.into_values() {
+        self.follower_states.retain(|_, state| {
+            if state.leader_id == peer_id {
+                for item in state.items_by_leader_view_id.values() {
                     item.set_leader_peer_id(None, cx);
                 }
+                false
+            } else {
+                true
             }
-        }
+        });
         cx.notify();
     }
 
@@ -2532,10 +2530,15 @@ impl Workspace {
 
         self.last_leaders_by_pane
             .insert(pane.downgrade(), leader_id);
-        self.follower_states_by_leader
-            .entry(leader_id)
-            .or_default()
-            .insert(pane.clone(), Default::default());
+        self.unfollow(&pane, cx);
+        self.follower_states.insert(
+            pane.clone(),
+            FollowerState {
+                leader_id,
+                active_view_id: None,
+                items_by_leader_view_id: Default::default(),
+            },
+        );
         cx.notify();
 
         let room_id = self.active_call()?.read(cx).room()?.read(cx).id();
@@ -2550,9 +2553,8 @@ impl Workspace {
             let response = request.await?;
             this.update(&mut cx, |this, _| {
                 let state = this
-                    .follower_states_by_leader
-                    .get_mut(&leader_id)
-                    .and_then(|states_by_pane| states_by_pane.get_mut(&pane))
+                    .follower_states
+                    .get_mut(&pane)
                     .ok_or_else(|| anyhow!("following interrupted"))?;
                 state.active_view_id = if let Some(active_view_id) = response.active_view_id {
                     Some(ViewId::from_proto(active_view_id)?)
@@ -2647,12 +2649,10 @@ impl Workspace {
         }
 
         // if you're already following, find the right pane and focus it.
-        for (existing_leader_id, states_by_pane) in &mut self.follower_states_by_leader {
-            if leader_id == *existing_leader_id {
-                for (pane, _) in states_by_pane {
-                    cx.focus(pane);
-                    return None;
-                }
+        for (pane, state) in &self.follower_states {
+            if leader_id == state.leader_id {
+                cx.focus(pane);
+                return None;
             }
         }
 
@@ -2665,36 +2665,37 @@ impl Workspace {
         pane: &ViewHandle<Pane>,
         cx: &mut ViewContext<Self>,
     ) -> Option<PeerId> {
-        for (leader_id, states_by_pane) in &mut self.follower_states_by_leader {
-            let leader_id = *leader_id;
-            if let Some(state) = states_by_pane.remove(pane) {
-                for (_, item) in state.items_by_leader_view_id {
-                    item.set_leader_peer_id(None, cx);
-                }
-
-                if states_by_pane.is_empty() {
-                    self.follower_states_by_leader.remove(&leader_id);
-                    let project_id = self.project.read(cx).remote_id();
-                    let room_id = self.active_call()?.read(cx).room()?.read(cx).id();
-                    self.app_state
-                        .client
-                        .send(proto::Unfollow {
-                            room_id,
-                            project_id,
-                            leader_id: Some(leader_id),
-                        })
-                        .log_err();
-                }
+        let state = self.follower_states.remove(pane)?;
+        let leader_id = state.leader_id;
+        for (_, item) in state.items_by_leader_view_id {
+            item.set_leader_peer_id(None, cx);
+        }
 
-                cx.notify();
-                return Some(leader_id);
-            }
+        if self
+            .follower_states
+            .values()
+            .all(|state| state.leader_id != state.leader_id)
+        {
+            let project_id = self.project.read(cx).remote_id();
+            let room_id = self.active_call()?.read(cx).room()?.read(cx).id();
+            self.app_state
+                .client
+                .send(proto::Unfollow {
+                    room_id,
+                    project_id,
+                    leader_id: Some(leader_id),
+                })
+                .log_err();
         }
-        None
+
+        cx.notify();
+        Some(leader_id)
     }
 
     pub fn is_being_followed(&self, peer_id: PeerId) -> bool {
-        self.follower_states_by_leader.contains_key(&peer_id)
+        self.follower_states
+            .values()
+            .any(|state| state.leader_id == peer_id)
     }
 
     fn render_titlebar(&self, theme: &Theme, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
@@ -2877,8 +2878,7 @@ impl Workspace {
                         let cx = &cx;
                         move |item| {
                             let item = item.to_followable_item_handle(cx)?;
-                            if project_id.is_some()
-                                && project_id != follower_project_id
+                            if (project_id.is_none() || project_id != follower_project_id)
                                 && item.is_project_item(cx)
                             {
                                 return None;
@@ -2917,8 +2917,8 @@ impl Workspace {
         match update.variant.ok_or_else(|| anyhow!("invalid update"))? {
             proto::update_followers::Variant::UpdateActiveView(update_active_view) => {
                 this.update(cx, |this, _| {
-                    if let Some(state) = this.follower_states_by_leader.get_mut(&leader_id) {
-                        for state in state.values_mut() {
+                    for (_, state) in &mut this.follower_states {
+                        if state.leader_id == leader_id {
                             state.active_view_id =
                                 if let Some(active_view_id) = update_active_view.id.clone() {
                                     Some(ViewId::from_proto(active_view_id)?)
@@ -2940,8 +2940,8 @@ impl Workspace {
                 let mut tasks = Vec::new();
                 this.update(cx, |this, cx| {
                     let project = this.project.clone();
-                    if let Some(state) = this.follower_states_by_leader.get_mut(&leader_id) {
-                        for state in state.values_mut() {
+                    for (_, state) in &mut this.follower_states {
+                        if state.leader_id == leader_id {
                             let view_id = ViewId::from_proto(id.clone())?;
                             if let Some(item) = state.items_by_leader_view_id.get(&view_id) {
                                 tasks.push(item.apply_update_proto(&project, variant.clone(), cx));
@@ -2954,10 +2954,9 @@ impl Workspace {
             }
             proto::update_followers::Variant::CreateView(view) => {
                 let panes = this.read_with(cx, |this, _| {
-                    this.follower_states_by_leader
-                        .get(&leader_id)
-                        .into_iter()
-                        .flat_map(|states_by_pane| states_by_pane.keys())
+                    this.follower_states
+                        .iter()
+                        .filter_map(|(pane, state)| (state.leader_id == leader_id).then_some(pane))
                         .cloned()
                         .collect()
                 })?;
@@ -3016,11 +3015,7 @@ impl Workspace {
         for (pane, (item_tasks, leader_view_ids)) in item_tasks_by_pane {
             let items = futures::future::try_join_all(item_tasks).await?;
             this.update(cx, |this, cx| {
-                let state = this
-                    .follower_states_by_leader
-                    .get_mut(&leader_id)?
-                    .get_mut(&pane)?;
-
+                let state = this.follower_states.get_mut(&pane)?;
                 for (id, item) in leader_view_ids.into_iter().zip(items) {
                     item.set_leader_peer_id(Some(leader_id), cx);
                     state.items_by_leader_view_id.insert(id, item);
@@ -3077,15 +3072,7 @@ impl Workspace {
     }
 
     pub fn leader_for_pane(&self, pane: &ViewHandle<Pane>) -> Option<PeerId> {
-        self.follower_states_by_leader
-            .iter()
-            .find_map(|(leader_id, state)| {
-                if state.contains_key(pane) {
-                    Some(*leader_id)
-                } else {
-                    None
-                }
-            })
+        self.follower_states.get(pane).map(|state| state.leader_id)
     }
 
     fn leader_updated(&mut self, leader_id: PeerId, cx: &mut ViewContext<Self>) -> Option<()> {
@@ -3113,17 +3100,23 @@ impl Workspace {
             }
         };
 
-        for (pane, state) in self.follower_states_by_leader.get(&leader_id)? {
-            if leader_in_this_app {
-                let item = state
-                    .active_view_id
-                    .and_then(|id| state.items_by_leader_view_id.get(&id));
-                if let Some(item) = item {
+        for (pane, state) in &self.follower_states {
+            if state.leader_id != leader_id {
+                continue;
+            }
+            if let (Some(active_view_id), true) = (state.active_view_id, leader_in_this_app) {
+                if let Some(item) = state.items_by_leader_view_id.get(&active_view_id) {
                     if leader_in_this_project || !item.is_project_item(cx) {
                         items_to_activate.push((pane.clone(), item.boxed_clone()));
                     }
-                    continue;
+                } else {
+                    log::warn!(
+                        "unknown view id {:?} for leader {:?}",
+                        active_view_id,
+                        leader_id
+                    );
                 }
+                continue;
             }
             if let Some(shared_screen) = self.shared_screen_for_peer(leader_id, pane, cx) {
                 items_to_activate.push((pane.clone(), Box::new(shared_screen)));
@@ -3527,15 +3520,12 @@ impl Workspace {
         let client = project.read(cx).client();
         let user_store = project.read(cx).user_store();
 
-        let channel_store =
-            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
         let workspace_store = cx.add_model(|cx| WorkspaceStore::new(client.clone(), cx));
         let app_state = Arc::new(AppState {
             languages: project.read(cx).languages().clone(),
             workspace_store,
             client,
             user_store,
-            channel_store,
             fs: project.read(cx).fs().clone(),
             build_window_options: |_, _, _| Default::default(),
             initialize_workspace: |_, _, _, _| Task::ready(Ok(())),
@@ -3811,7 +3801,7 @@ impl View for Workspace {
                                                     self.center.render(
                                                         &project,
                                                         &theme,
-                                                        &self.follower_states_by_leader,
+                                                        &self.follower_states,
                                                         self.active_call(),
                                                         self.active_pane(),
                                                         self.zoomed
@@ -4148,6 +4138,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],

crates/zed/Cargo.toml 🔗

@@ -3,7 +3,7 @@ authors = ["Nathan Sobo <nathansobo@gmail.com>"]
 description = "The fast, collaborative code editor."
 edition = "2021"
 name = "zed"
-version = "0.108.0"
+version = "0.109.0"
 publish = false
 
 [lib]
@@ -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"]

crates/zed/src/main.rs 🔗

@@ -3,12 +3,13 @@
 
 use anyhow::{anyhow, Context, Result};
 use backtrace::Backtrace;
-use channel::ChannelStore;
 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::{
@@ -32,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,
@@ -45,7 +44,7 @@ use std::{
 };
 use sum_tree::Bias;
 use util::{
-    channel::ReleaseChannel,
+    channel::{parse_zed_link, ReleaseChannel},
     http::{self, HttpClient},
     paths::PathLikeWithPosition,
 };
@@ -61,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();
@@ -93,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);
@@ -138,8 +132,6 @@ fn main() {
 
         languages::init(languages.clone(), node_runtime.clone(), cx);
         let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
-        let channel_store =
-            cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
         let workspace_store = cx.add_model(|cx| WorkspaceStore::new(client.clone(), cx));
 
         cx.set_global(client.clone());
@@ -156,7 +148,7 @@ fn main() {
         outline::init(cx);
         project_symbols::init(cx);
         project_panel::init(Assets, cx);
-        channel::init(&client);
+        channel::init(&client, user_store.clone(), cx);
         diagnostics::init(cx);
         search::init(cx);
         semantic_index::init(fs.clone(), http.clone(), languages.clone(), cx);
@@ -184,7 +176,6 @@ fn main() {
             languages,
             client: client.clone(),
             user_store,
-            channel_store,
             fs,
             build_window_options,
             initialize_workspace,
@@ -214,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);
@@ -227,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";
 
@@ -298,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))
@@ -495,11 +476,11 @@ fn init_panic_hook(app: &App, installation_id: Option<String>, session_id: Strin
             session_id: session_id.clone(),
         };
 
-        if is_pty {
-            if let Some(panic_data_json) = serde_json::to_string_pretty(&panic_data).log_err() {
-                eprintln!("{}", panic_data_json);
-            }
-        } else {
+        if let Some(panic_data_json) = serde_json::to_string_pretty(&panic_data).log_err() {
+            log::error!("{}", panic_data_json);
+        }
+
+        if !is_pty {
             if let Some(panic_data_json) = serde_json::to_string(&panic_data).log_err() {
                 let timestamp = chrono::Utc::now().format("%Y_%m_%d %H_%M_%S").to_string();
                 let panic_file_path = paths::LOGS_DIR.join(format!("zed-{}.panic", timestamp));
@@ -638,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());

crates/zed/src/open_listener.rs 🔗

@@ -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 })
+    }
+}

crates/zed/src/zed.rs 🔗

@@ -2424,6 +2424,7 @@ mod tests {
             state.build_window_options = build_window_options;
             theme::init((), cx);
             audio::init((), cx);
+            channel::init(&app_state.client, app_state.user_store.clone(), cx);
             call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
             workspace::init(app_state.clone(), cx);
             Project::init_settings(cx);

docs/building-zed.md 🔗

@@ -75,8 +75,7 @@ Expect this to take 30min to an hour! Some of these steps will take quite a whil
     - If you are just using the latest version, but not working on zed:
       - `cargo run --release`
     - If you need to run the collaboration server locally:
-      - `script/zed-with-local-servers`
-    - If you need to test collaboration with mutl
+      - `script/zed-local`
 
 ## Troubleshooting
 

docs/local-collaboration.md 🔗

@@ -17,6 +17,6 @@
 ## Testing collab locally
 
 1. Run `foreman start` from the root of the repo.
-1. In another terminal run `script/start-local-collaboration`.
+1. In another terminal run `script/zed-local -2`.
 1. Two copies of Zed will open. Add yourself as a contact in the one that is not you.
 1. Start a collaboration session as normal with any open project.

script/bundle 🔗

@@ -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

script/crate-dep-graph 🔗

@@ -0,0 +1,19 @@
+#!/bin/bash
+
+set -e
+
+if [[ -x cargo-depgraph ]]; then
+    cargo install cargo-depgraph
+fi
+
+graph_file=target/crate-graph.html
+
+cargo depgraph \
+    --workspace-only \
+    --offline \
+    --root=zed,cli,collab \
+    --dedup-transitive-deps \
+    | dot -Tsvg > $graph_file
+
+echo "open $graph_file"
+open $graph_file

script/start-local-collaboration 🔗

@@ -1,59 +0,0 @@
-#!/bin/bash
-
-set -e
-
-if [[ -z "$GITHUB_TOKEN" ]]; then
-  cat <<-MESSAGE
-Missing \`GITHUB_TOKEN\` environment variable. This token is needed
-for fetching your GitHub identity from the command-line.
-
-Create an access token here: https://github.com/settings/tokens
-Then edit your \`~/.zshrc\` (or other shell initialization script),
-adding a line like this:
-
-    export GITHUB_TOKEN="(the token)"
-
-MESSAGE
-  exit 1
-fi
-
-# Install jq if it's not installed
-if ! command -v jq &> /dev/null; then
-    echo "Installing jq..."
-    brew install jq
-fi
-
-# Start one Zed instance as the current user and a second instance with a different user.
-username_1=$(curl -sH "Authorization: bearer $GITHUB_TOKEN" https://api.github.com/user | jq -r .login)
-username_2=nathansobo
-if [[ $username_1 == $username_2 ]]; then
-  username_2=as-cii
-fi
-
-# Make each Zed instance take up half of the screen.
-output=$(system_profiler SPDisplaysDataType -json)
-main_display=$(echo "$output" | jq '.SPDisplaysDataType[].spdisplays_ndrvs[] | select(.spdisplays_main == "spdisplays_yes")')
-resolution=$(echo "$main_display" | jq -r '._spdisplays_resolution')
-width=$(echo "$resolution" | jq -Rr 'match("(\\d+) x (\\d+)").captures[0].string')
-half_width=$(($width / 2))
-height=$(echo "$resolution" | jq -Rr 'match("(\\d+) x (\\d+)").captures[1].string')
-y=0
-
-position_1=0,${y}
-position_2=${half_width},${y}
-
-# Authenticate using the collab server's admin secret.
-export ZED_STATELESS=1
-export ZED_ALWAYS_ACTIVE=1
-export ZED_ADMIN_API_TOKEN=secret
-export ZED_SERVER_URL=http://localhost:8080
-export ZED_WINDOW_SIZE=${half_width},${height}
-
-cargo build
-sleep 0.5
-
-# Start the two Zed child processes. Open the given paths with the first instance.
-trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT
-ZED_IMPERSONATE=${ZED_IMPERSONATE:=${username_1}} ZED_WINDOW_POSITION=${position_1} target/debug/Zed $@ &
-SECOND=true ZED_IMPERSONATE=${username_2} ZED_WINDOW_POSITION=${position_2} target/debug/Zed &
-wait

script/zed-local 🔗

@@ -0,0 +1,88 @@
+#!/usr/bin/env node
+
+const {spawn, execFileSync} = require('child_process')
+
+const RESOLUTION_REGEX = /(\d+) x (\d+)/
+const DIGIT_FLAG_REGEX = /^--?(\d+)$/
+
+const args = process.argv.slice(2)
+
+// Parse the number of Zed instances to spawn.
+let instanceCount = 1
+const digitMatch = args[0]?.match(DIGIT_FLAG_REGEX)
+if (digitMatch) {
+  instanceCount = parseInt(digitMatch[1])
+  args.shift()
+}
+if (instanceCount > 4) {
+  throw new Error('Cannot spawn more than 4 instances')
+}
+
+// Parse the resolution of the main screen
+const displayInfo = JSON.parse(
+  execFileSync(
+    'system_profiler',
+    ['SPDisplaysDataType', '-json'],
+    {encoding: 'utf8'}
+  )
+)
+const mainDisplayResolution = displayInfo
+  ?.SPDisplaysDataType[0]
+  ?.spdisplays_ndrvs
+  ?.find(entry => entry.spdisplays_main === "spdisplays_yes")
+  ?._spdisplays_resolution
+  ?.match(RESOLUTION_REGEX)
+if (!mainDisplayResolution) {
+  throw new Error('Could not parse screen resolution')
+}
+const screenWidth = parseInt(mainDisplayResolution[1])
+const screenHeight = parseInt(mainDisplayResolution[2])
+
+// Determine the window size for each instance
+let instanceWidth = screenWidth
+let instanceHeight = screenHeight
+if (instanceCount > 1) {
+  instanceWidth = Math.floor(screenWidth / 2)
+  if (instanceCount > 2) {
+    instanceHeight = Math.floor(screenHeight / 2)
+  }
+}
+
+let users = [
+  'nathansobo',
+  'as-cii',
+  'maxbrunsfeld',
+  'iamnbutler'
+]
+
+// If a user is specified, make sure it's first in the list
+const user = process.env.ZED_IMPERSONATE
+if (user) {
+  users = [user].concat(users.filter(u => u !== user))
+}
+
+const positions = [
+  '0,0',
+  `${instanceWidth},0`,
+  `0,${instanceHeight}`,
+  `${instanceWidth},${instanceHeight}`
+]
+
+execFileSync('cargo', ['build'], {stdio: 'inherit'})
+
+setTimeout(() => {
+  for (let i = 0; i < instanceCount; i++) {
+    spawn('target/debug/Zed', i == 0 ? args : [], {
+      stdio: 'inherit',
+      env: {
+        ZED_IMPERSONATE: users[i],
+        ZED_WINDOW_POSITION: positions[i],
+        ZED_STATELESS: '1',
+        ZED_ALWAYS_ACTIVE: '1',
+        ZED_SERVER_URL: 'http://localhost:8080',
+        ZED_ADMIN_API_TOKEN: 'secret',
+        ZED_WINDOW_SIZE: `${instanceWidth},${instanceHeight}`
+      }
+    })
+  }
+}, 0.1)

script/zed-with-local-servers 🔗

@@ -1,6 +0,0 @@
-#!/bin/bash
-
-: "${ZED_IMPERSONATE:=as-cii}"
-export ZED_IMPERSONATE
-
-ZED_ADMIN_API_TOKEN=secret ZED_SERVER_URL=http://localhost:8080 cargo run $@