Merge branch 'main' into v0.67.x

Antonio Scandurra created

Change summary

Cargo.lock                                                                    |   33 
crates/auto_update/src/update_notification.rs                                 |    2 
crates/call/Cargo.toml                                                        |    1 
crates/call/src/room.rs                                                       |   41 
crates/client/src/client.rs                                                   |   22 
crates/client/src/test.rs                                                     |    4 
crates/collab/.env.toml                                                       |    1 
crates/collab/Cargo.toml                                                      |    2 
crates/collab/k8s/manifest.template.yml                                       |    8 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql                |   29 
crates/collab/migrations/20221111092550_reconnection_support.sql              |    3 
crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql |   30 
crates/collab/src/db.rs                                                       |  519 
crates/collab/src/db/project.rs                                               |   23 
crates/collab/src/db/project_collaborator.rs                                  |    4 
crates/collab/src/db/room_participant.rs                                      |    6 
crates/collab/src/db/server.rs                                                |   15 
crates/collab/src/db/tests.rs                                                 |   28 
crates/collab/src/integration_tests.rs                                        |  329 
crates/collab/src/lib.rs                                                      |    1 
crates/collab/src/main.rs                                                     |   19 
crates/collab/src/rpc.rs                                                      |  322 
crates/collab_ui/src/collab_titlebar_item.rs                                  |   36 
crates/collab_ui/src/collab_ui.rs                                             |    2 
crates/collab_ui/src/contact_list.rs                                          |  143 
crates/db/src/query.rs                                                        |    4 
crates/diagnostics/src/diagnostics.rs                                         |    2 
crates/editor/src/editor.rs                                                   |   55 
crates/editor/src/editor_tests.rs                                             |  268 
crates/editor/src/items.rs                                                    |  402 
crates/editor/src/multi_buffer.rs                                             |  208 
crates/language/src/proto.rs                                                  |   20 
crates/project/Cargo.toml                                                     |    1 
crates/project/src/lsp_command.rs                                             |    2 
crates/project/src/project.rs                                                 |   67 
crates/rpc/proto/zed.proto                                                    |   95 
crates/rpc/src/macros.rs                                                      |    7 
crates/rpc/src/peer.rs                                                        |   88 
crates/rpc/src/proto.rs                                                       |   94 
crates/rpc/src/rpc.rs                                                         |    2 
crates/search/src/project_search.rs                                           |    4 
crates/settings/src/settings.rs                                               |   34 
crates/terminal/Cargo.toml                                                    |   14 
crates/terminal/src/terminal.rs                                               |  192 
crates/terminal/src/terminal_container_view.rs                                |  711 
crates/terminal/src/terminal_view.rs                                          |  471 
crates/terminal/src/tests/terminal_test_context.rs                            |  143 
crates/terminal_view/Cargo.toml                                               |   44 
crates/terminal_view/README.md                                                |    0 
crates/terminal_view/scripts/print256color.sh                                 |    0 
crates/terminal_view/scripts/truecolor.sh                                     |    0 
crates/terminal_view/src/persistence.rs                                       |   11 
crates/terminal_view/src/terminal_element.rs                                  |   24 
crates/terminal_view/src/terminal_view.rs                                     | 1091 
crates/text/src/text.rs                                                       |    4 
crates/workspace/src/dock.rs                                                  |   23 
crates/workspace/src/item.rs                                                  |   45 
crates/workspace/src/notifications.rs                                         |   82 
crates/workspace/src/shared_screen.rs                                         |    2 
crates/workspace/src/workspace.rs                                             |  322 
crates/zed/Cargo.toml                                                         |    2 
crates/zed/resources/app-icon-preview.png                                     |    0 
crates/zed/resources/app-icon-preview@2x.png                                  |    0 
crates/zed/resources/app-icon.png                                             |    0 
crates/zed/resources/app-icon@2x.png                                          |    0 
crates/zed/src/main.rs                                                        |   29 
crates/zed/src/zed.rs                                                         |   31 
script/bundle                                                                 |   33 
script/start-local-collaboration                                              |   50 
styles/src/styleTree/components.ts                                            |   10 
styles/src/styleTree/simpleMessageNotification.ts                             |    4 
71 files changed, 3,804 insertions(+), 2,510 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -823,6 +823,7 @@ dependencies = [
  "futures 0.3.25",
  "gpui",
  "live_kit_client",
+ "log",
  "media",
  "postage",
  "project",
@@ -1130,7 +1131,7 @@ dependencies = [
 
 [[package]]
 name = "collab"
-version = "0.3.0"
+version = "0.4.0"
 dependencies = [
  "anyhow",
  "async-tungstenite",
@@ -4463,6 +4464,7 @@ dependencies = [
  "smol",
  "sum_tree",
  "tempdir",
+ "terminal",
  "text",
  "thiserror",
  "toml",
@@ -6259,6 +6261,32 @@ name = "terminal"
 version = "0.1.0"
 dependencies = [
  "alacritty_terminal",
+ "anyhow",
+ "db",
+ "dirs 4.0.0",
+ "futures 0.3.25",
+ "gpui",
+ "itertools",
+ "lazy_static",
+ "libc",
+ "mio-extras",
+ "ordered-float",
+ "procinfo",
+ "rand 0.8.5",
+ "serde",
+ "settings",
+ "shellexpand",
+ "smallvec",
+ "smol",
+ "theme",
+ "thiserror",
+ "util",
+]
+
+[[package]]
+name = "terminal_view"
+version = "0.1.0"
+dependencies = [
  "anyhow",
  "client",
  "context_menu",
@@ -6281,6 +6309,7 @@ dependencies = [
  "shellexpand",
  "smallvec",
  "smol",
+ "terminal",
  "theme",
  "thiserror",
  "util",
@@ -8166,7 +8195,7 @@ dependencies = [
  "smol",
  "sum_tree",
  "tempdir",
- "terminal",
+ "terminal_view",
  "text",
  "theme",
  "theme_selector",

crates/auto_update/src/update_notification.rs 🔗

@@ -28,7 +28,7 @@ impl View for UpdateNotification {
 
     fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
         let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.simple_message_notification;
+        let theme = &theme.update_notification;
 
         let app_name = cx.global::<ReleaseChannel>().display_name();
 

crates/call/Cargo.toml 🔗

@@ -21,6 +21,7 @@ test-support = [
 client = { path = "../client" }
 collections = { path = "../collections" }
 gpui = { path = "../gpui" }
+log = "0.4"
 live_kit_client = { path = "../live_kit_client" }
 media = { path = "../media" }
 project = { path = "../project" }

crates/call/src/room.rs 🔗

@@ -3,7 +3,7 @@ use crate::{
     IncomingCall,
 };
 use anyhow::{anyhow, Result};
-use client::{proto, Client, PeerId, TypedEnvelope, User, UserStore};
+use client::{proto, Client, TypedEnvelope, User, UserStore};
 use collections::{BTreeMap, HashSet};
 use futures::{FutureExt, StreamExt};
 use gpui::{
@@ -13,17 +13,17 @@ use live_kit_client::{LocalTrackPublication, LocalVideoTrack, RemoteVideoTrackUp
 use postage::stream::Stream;
 use project::Project;
 use std::{mem, sync::Arc, time::Duration};
-use util::{post_inc, ResultExt};
+use util::{post_inc, ResultExt, TryFutureExt};
 
 pub const RECONNECT_TIMEOUT: Duration = client::RECEIVE_TIMEOUT;
 
 #[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Event {
     ParticipantLocationChanged {
-        participant_id: PeerId,
+        participant_id: proto::PeerId,
     },
     RemoteVideoTracksChanged {
-        participant_id: PeerId,
+        participant_id: proto::PeerId,
     },
     RemoteProjectShared {
         owner: Arc<User>,
@@ -41,7 +41,7 @@ pub struct Room {
     live_kit: Option<LiveKitRoom>,
     status: RoomStatus,
     local_participant: LocalParticipant,
-    remote_participants: BTreeMap<PeerId, RemoteParticipant>,
+    remote_participants: BTreeMap<proto::PeerId, RemoteParticipant>,
     pending_participants: Vec<Arc<User>>,
     participant_user_ids: HashSet<u64>,
     pending_call_count: usize,
@@ -50,7 +50,7 @@ pub struct Room {
     user_store: ModelHandle<UserStore>,
     subscriptions: Vec<client::Subscription>,
     pending_room_update: Option<Task<()>>,
-    maintain_connection: Option<Task<Result<()>>>,
+    maintain_connection: Option<Task<Option<()>>>,
 }
 
 impl Entity for Room {
@@ -58,6 +58,7 @@ impl Entity for Room {
 
     fn release(&mut self, _: &mut MutableAppContext) {
         if self.status.is_online() {
+            log::info!("room was released, sending leave message");
             self.client.send(proto::LeaveRoom {}).log_err();
         }
     }
@@ -122,7 +123,7 @@ impl Room {
         };
 
         let maintain_connection =
-            cx.spawn_weak(|this, cx| Self::maintain_connection(this, client.clone(), cx));
+            cx.spawn_weak(|this, cx| Self::maintain_connection(this, client.clone(), cx).log_err());
 
         Self {
             id,
@@ -229,6 +230,7 @@ impl Room {
 
         cx.notify();
         cx.emit(Event::Left);
+        log::info!("leaving room");
         self.status = RoomStatus::Offline;
         self.remote_participants.clear();
         self.pending_participants.clear();
@@ -254,6 +256,7 @@ impl Room {
                 .map_or(false, |s| s.is_connected());
             // Even if we're initially connected, any future change of the status means we momentarily disconnected.
             if !is_connected || client_status.next().await.is_some() {
+                log::info!("detected client disconnection");
                 let room_id = this
                     .upgrade(&cx)
                     .ok_or_else(|| anyhow!("room was dropped"))?
@@ -269,8 +272,13 @@ impl Room {
                     let client_reconnection = async {
                         let mut remaining_attempts = 3;
                         while remaining_attempts > 0 {
+                            log::info!(
+                                "waiting for client status change, remaining attempts {}",
+                                remaining_attempts
+                            );
                             if let Some(status) = client_status.next().await {
                                 if status.is_connected() {
+                                    log::info!("client reconnected, attempting to rejoin room");
                                     let rejoin_room = async {
                                         let response =
                                             client.request(proto::JoinRoom { id: room_id }).await?;
@@ -285,7 +293,7 @@ impl Room {
                                         anyhow::Ok(())
                                     };
 
-                                    if rejoin_room.await.is_ok() {
+                                    if rejoin_room.await.log_err().is_some() {
                                         return true;
                                     } else {
                                         remaining_attempts -= 1;
@@ -303,12 +311,15 @@ impl Room {
                     futures::select_biased! {
                         reconnected = client_reconnection => {
                             if reconnected {
+                                log::info!("successfully reconnected to room");
                                 // If we successfully joined the room, go back around the loop
                                 // waiting for future connection status changes.
                                 continue;
                             }
                         }
-                        _ = reconnection_timeout => {}
+                        _ = reconnection_timeout => {
+                            log::info!("room reconnection timeout expired");
+                        }
                     }
                 }
 
@@ -316,6 +327,7 @@ impl Room {
                 // or an error occurred while trying to re-join the room. Either way
                 // we leave the room and return an error.
                 if let Some(this) = this.upgrade(&cx) {
+                    log::info!("reconnection failed, leaving room");
                     let _ = this.update(&mut cx, |this, cx| this.leave(cx));
                 }
                 return Err(anyhow!(
@@ -337,7 +349,7 @@ impl Room {
         &self.local_participant
     }
 
-    pub fn remote_participants(&self) -> &BTreeMap<PeerId, RemoteParticipant> {
+    pub fn remote_participants(&self) -> &BTreeMap<proto::PeerId, RemoteParticipant> {
         &self.remote_participants
     }
 
@@ -407,7 +419,7 @@ impl Room {
                 if let Some(participants) = remote_participants.log_err() {
                     let mut participant_peer_ids = HashSet::default();
                     for (participant, user) in room.participants.into_iter().zip(participants) {
-                        let peer_id = PeerId(participant.peer_id);
+                        let Some(peer_id) = participant.peer_id else { continue };
                         this.participant_user_ids.insert(participant.user_id);
                         participant_peer_ids.insert(peer_id);
 
@@ -464,7 +476,7 @@ impl Room {
 
                             if let Some(live_kit) = this.live_kit.as_ref() {
                                 let tracks =
-                                    live_kit.room.remote_video_tracks(&peer_id.0.to_string());
+                                    live_kit.room.remote_video_tracks(&peer_id.to_string());
                                 for track in tracks {
                                     this.remote_video_track_updated(
                                         RemoteVideoTrackUpdate::Subscribed(track),
@@ -499,6 +511,7 @@ impl Room {
 
                 this.pending_room_update.take();
                 if this.should_leave() {
+                    log::info!("room is empty, leaving");
                     let _ = this.leave(cx);
                 }
 
@@ -518,7 +531,7 @@ impl Room {
     ) -> Result<()> {
         match change {
             RemoteVideoTrackUpdate::Subscribed(track) => {
-                let peer_id = PeerId(track.publisher_id().parse()?);
+                let peer_id = track.publisher_id().parse()?;
                 let track_id = track.sid().to_string();
                 let participant = self
                     .remote_participants
@@ -538,7 +551,7 @@ impl Room {
                 publisher_id,
                 track_id,
             } => {
-                let peer_id = PeerId(publisher_id.parse()?);
+                let peer_id = publisher_id.parse()?;
                 let participant = self
                     .remote_participants
                     .get_mut(&peer_id)

crates/client/src/client.rs 🔗

@@ -23,7 +23,7 @@ use lazy_static::lazy_static;
 use parking_lot::RwLock;
 use postage::watch;
 use rand::prelude::*;
-use rpc::proto::{AnyTypedEnvelope, EntityMessage, EnvelopedMessage, RequestMessage};
+use rpc::proto::{AnyTypedEnvelope, EntityMessage, EnvelopedMessage, PeerId, RequestMessage};
 use serde::Deserialize;
 use std::{
     any::TypeId,
@@ -140,7 +140,7 @@ impl EstablishConnectionError {
     }
 }
 
-#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+#[derive(Copy, Clone, Debug, PartialEq)]
 pub enum Status {
     SignedOut,
     UpgradeRequired,
@@ -306,7 +306,7 @@ impl Client {
     pub fn new(http: Arc<dyn HttpClient>, cx: &AppContext) -> Arc<Self> {
         Arc::new(Self {
             id: 0,
-            peer: Peer::new(),
+            peer: Peer::new(0),
             telemetry: Telemetry::new(http.clone(), cx),
             http,
             state: Default::default(),
@@ -333,14 +333,14 @@ impl Client {
     }
 
     #[cfg(any(test, feature = "test-support"))]
-    pub fn tear_down(&self) {
+    pub fn teardown(&self) {
         let mut state = self.state.write();
         state._reconnect_task.take();
         state.message_handlers.clear();
         state.models_by_message_type.clear();
         state.entities_by_type_and_remote_id.clear();
         state.entity_id_extractors.clear();
-        self.peer.reset();
+        self.peer.teardown();
     }
 
     #[cfg(any(test, feature = "test-support"))]
@@ -810,7 +810,11 @@ impl Client {
                         hello_message_type_name
                     )
                 })?;
-            Ok(PeerId(hello.payload.peer_id))
+            let peer_id = hello
+                .payload
+                .peer_id
+                .ok_or_else(|| anyhow!("invalid peer id"))?;
+            Ok(peer_id)
         };
 
         let peer_id = match peer_id.await {
@@ -822,7 +826,7 @@ impl Client {
         };
 
         log::info!(
-            "set status to connected (connection id: {}, peer id: {})",
+            "set status to connected (connection id: {:?}, peer id: {:?})",
             connection_id,
             peer_id
         );
@@ -853,7 +857,7 @@ impl Client {
             .spawn(async move {
                 match handle_io.await {
                     Ok(()) => {
-                        if *this.status().borrow()
+                        if this.status().borrow().clone()
                             == (Status::Connected {
                                 connection_id,
                                 peer_id,
@@ -1194,7 +1198,7 @@ impl Client {
         let mut state = self.state.write();
         let type_name = message.payload_type_name();
         let payload_type_id = message.payload_type_id();
-        let sender_id = message.original_sender_id().map(|id| id.0);
+        let sender_id = message.original_sender_id();
 
         let mut subscriber = None;
 

crates/client/src/test.rs 🔗

@@ -35,7 +35,7 @@ impl FakeServer {
         cx: &TestAppContext,
     ) -> Self {
         let server = Self {
-            peer: Peer::new(),
+            peer: Peer::new(0),
             state: Default::default(),
             user_id: client_user_id,
             executor: cx.foreground(),
@@ -92,7 +92,7 @@ impl FakeServer {
                         peer.send(
                             connection_id,
                             proto::Hello {
-                                peer_id: connection_id.0,
+                                peer_id: Some(connection_id.into()),
                             },
                         )
                         .unwrap();

crates/collab/.env.toml 🔗

@@ -2,6 +2,7 @@ DATABASE_URL = "postgres://postgres@localhost/zed"
 HTTP_PORT = 8080
 API_TOKEN = "secret"
 INVITE_LINK_PREFIX = "http://localhost:3000/invites/"
+ZED_ENVIRONMENT = "development"
 LIVE_KIT_SERVER = "http://localhost:7880"
 LIVE_KIT_KEY = "devkey"
 LIVE_KIT_SECRET = "secret"

crates/collab/Cargo.toml 🔗

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

crates/collab/k8s/manifest.template.yml 🔗

@@ -59,6 +59,12 @@ spec:
           ports:
             - containerPort: 8080
               protocol: TCP
+          readinessProbe:
+            httpGet:
+              path: /
+              port: 8080
+            initialDelaySeconds: 1
+            periodSeconds: 1
           env:
             - name: HTTP_PORT
               value: "8080"
@@ -93,6 +99,8 @@ spec:
               value: ${RUST_LOG}
             - name: LOG_JSON
               value: "true"
+            - name: ZED_ENVIRONMENT
+              value: ${ZED_ENVIRONMENT}
           securityContext:
             capabilities:
               # FIXME - Switch to the more restrictive `PERFMON` capability.

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

@@ -43,10 +43,12 @@ CREATE TABLE "projects" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,
     "room_id" INTEGER REFERENCES rooms (id) NOT NULL,
     "host_user_id" INTEGER REFERENCES users (id) NOT NULL,
-    "host_connection_id" INTEGER NOT NULL,
-    "host_connection_epoch" TEXT NOT NULL
+    "host_connection_id" INTEGER,
+    "host_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
+    "unregistered" BOOLEAN NOT NULL DEFAULT FALSE
 );
-CREATE INDEX "index_projects_on_host_connection_epoch" ON "projects" ("host_connection_epoch");
+CREATE INDEX "index_projects_on_host_connection_server_id" ON "projects" ("host_connection_server_id");
+CREATE INDEX "index_projects_on_host_connection_id_and_host_connection_server_id" ON "projects" ("host_connection_id", "host_connection_server_id");
 
 CREATE TABLE "worktrees" (
     "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
@@ -102,34 +104,39 @@ CREATE TABLE "project_collaborators" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,
     "project_id" INTEGER NOT NULL REFERENCES projects (id) ON DELETE CASCADE,
     "connection_id" INTEGER NOT NULL,
-    "connection_epoch" TEXT NOT NULL,
+    "connection_server_id" INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE,
     "user_id" INTEGER NOT NULL,
     "replica_id" INTEGER NOT NULL,
     "is_host" BOOLEAN NOT NULL
 );
 CREATE INDEX "index_project_collaborators_on_project_id" ON "project_collaborators" ("project_id");
 CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_and_replica_id" ON "project_collaborators" ("project_id", "replica_id");
-CREATE INDEX "index_project_collaborators_on_connection_epoch" ON "project_collaborators" ("connection_epoch");
+CREATE INDEX "index_project_collaborators_on_connection_server_id" ON "project_collaborators" ("connection_server_id");
 CREATE INDEX "index_project_collaborators_on_connection_id" ON "project_collaborators" ("connection_id");
-CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_epoch" ON "project_collaborators" ("project_id", "connection_id", "connection_epoch");
+CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_server_id" ON "project_collaborators" ("project_id", "connection_id", "connection_server_id");
 
 CREATE TABLE "room_participants" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,
     "room_id" INTEGER NOT NULL REFERENCES rooms (id),
     "user_id" INTEGER NOT NULL REFERENCES users (id),
     "answering_connection_id" INTEGER,
-    "answering_connection_epoch" TEXT,
+    "answering_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE CASCADE,
     "answering_connection_lost" BOOLEAN NOT NULL,
     "location_kind" INTEGER,
     "location_project_id" INTEGER,
     "initial_project_id" INTEGER,
     "calling_user_id" INTEGER NOT NULL REFERENCES users (id),
     "calling_connection_id" INTEGER NOT NULL,
-    "calling_connection_epoch" TEXT NOT NULL
+    "calling_connection_server_id" INTEGER REFERENCES servers (id) ON DELETE SET NULL
 );
 CREATE UNIQUE INDEX "index_room_participants_on_user_id" ON "room_participants" ("user_id");
 CREATE INDEX "index_room_participants_on_room_id" ON "room_participants" ("room_id");
-CREATE INDEX "index_room_participants_on_answering_connection_epoch" ON "room_participants" ("answering_connection_epoch");
-CREATE INDEX "index_room_participants_on_calling_connection_epoch" ON "room_participants" ("calling_connection_epoch");
+CREATE INDEX "index_room_participants_on_answering_connection_server_id" ON "room_participants" ("answering_connection_server_id");
+CREATE INDEX "index_room_participants_on_calling_connection_server_id" ON "room_participants" ("calling_connection_server_id");
 CREATE INDEX "index_room_participants_on_answering_connection_id" ON "room_participants" ("answering_connection_id");
-CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_epoch" ON "room_participants" ("answering_connection_id", "answering_connection_epoch");
+CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_server_id" ON "room_participants" ("answering_connection_id", "answering_connection_server_id");
+
+CREATE TABLE "servers" (
+    "id" INTEGER PRIMARY KEY AUTOINCREMENT,
+    "environment" VARCHAR NOT NULL
+);

crates/collab/migrations/20221111092550_reconnection_support.sql 🔗

@@ -6,8 +6,7 @@ CREATE TABLE IF NOT EXISTS "rooms" (
 ALTER TABLE "projects"
     ADD "room_id" INTEGER REFERENCES rooms (id),
     ADD "host_connection_id" INTEGER,
-    ADD "host_connection_epoch" UUID,
-    DROP COLUMN "unregistered";
+    ADD "host_connection_epoch" UUID;
 CREATE INDEX "index_projects_on_host_connection_epoch" ON "projects" ("host_connection_epoch");
 
 CREATE TABLE "worktrees" (

crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql 🔗

@@ -0,0 +1,30 @@
+CREATE TABLE servers (
+    id SERIAL PRIMARY KEY,
+    environment VARCHAR NOT NULL
+);
+
+DROP TABLE worktree_extensions;
+DROP TABLE project_activity_periods;
+DELETE from projects;
+ALTER TABLE projects
+    DROP COLUMN host_connection_epoch,
+    ADD COLUMN host_connection_server_id INTEGER REFERENCES servers (id) ON DELETE CASCADE;
+CREATE INDEX "index_projects_on_host_connection_server_id" ON "projects" ("host_connection_server_id");
+CREATE INDEX "index_projects_on_host_connection_id_and_host_connection_server_id" ON "projects" ("host_connection_id", "host_connection_server_id");
+
+DELETE FROM project_collaborators;
+ALTER TABLE project_collaborators
+    DROP COLUMN connection_epoch,
+    ADD COLUMN connection_server_id INTEGER NOT NULL REFERENCES servers (id) ON DELETE CASCADE;
+CREATE INDEX "index_project_collaborators_on_connection_server_id" ON "project_collaborators" ("connection_server_id");
+CREATE UNIQUE INDEX "index_project_collaborators_on_project_id_connection_id_and_server_id" ON "project_collaborators" ("project_id", "connection_id", "connection_server_id");
+
+DELETE FROM room_participants;
+ALTER TABLE room_participants
+    DROP COLUMN answering_connection_epoch,
+    DROP COLUMN calling_connection_epoch,
+    ADD COLUMN answering_connection_server_id INTEGER REFERENCES servers (id) ON DELETE CASCADE,
+    ADD COLUMN calling_connection_server_id INTEGER REFERENCES servers (id) ON DELETE SET NULL;
+CREATE INDEX "index_room_participants_on_answering_connection_server_id" ON "room_participants" ("answering_connection_server_id");
+CREATE INDEX "index_room_participants_on_calling_connection_server_id" ON "room_participants" ("calling_connection_server_id");
+CREATE UNIQUE INDEX "index_room_participants_on_answering_connection_id_and_answering_connection_server_id" ON "room_participants" ("answering_connection_id", "answering_connection_server_id");

crates/collab/src/db.rs 🔗

@@ -5,6 +5,7 @@ mod project;
 mod project_collaborator;
 mod room;
 mod room_participant;
+mod server;
 mod signup;
 #[cfg(test)]
 mod tests;
@@ -48,7 +49,6 @@ pub struct Database {
     background: Option<std::sync::Arc<gpui::executor::Background>>,
     #[cfg(test)]
     runtime: Option<tokio::runtime::Runtime>,
-    epoch: parking_lot::RwLock<Uuid>,
 }
 
 impl Database {
@@ -61,18 +61,12 @@ impl Database {
             background: None,
             #[cfg(test)]
             runtime: None,
-            epoch: parking_lot::RwLock::new(Uuid::new_v4()),
         })
     }
 
     #[cfg(test)]
     pub fn reset(&self) {
         self.rooms.clear();
-        *self.epoch.write() = Uuid::new_v4();
-    }
-
-    fn epoch(&self) -> Uuid {
-        *self.epoch.read()
     }
 
     pub async fn migrate(
@@ -116,14 +110,40 @@ impl Database {
         Ok(new_migrations)
     }
 
-    pub async fn delete_stale_projects(&self) -> Result<()> {
+    pub async fn create_server(&self, environment: &str) -> Result<ServerId> {
+        self.transaction(|tx| async move {
+            let server = server::ActiveModel {
+                environment: ActiveValue::set(environment.into()),
+                ..Default::default()
+            }
+            .insert(&*tx)
+            .await?;
+            Ok(server.id)
+        })
+        .await
+    }
+
+    pub async fn delete_stale_projects(
+        &self,
+        environment: &str,
+        new_server_id: ServerId,
+    ) -> Result<()> {
         self.transaction(|tx| async move {
+            let stale_server_epochs = self
+                .stale_server_ids(environment, new_server_id, &tx)
+                .await?;
             project_collaborator::Entity::delete_many()
-                .filter(project_collaborator::Column::ConnectionEpoch.ne(self.epoch()))
+                .filter(
+                    project_collaborator::Column::ConnectionServerId
+                        .is_in(stale_server_epochs.iter().copied()),
+                )
                 .exec(&*tx)
                 .await?;
             project::Entity::delete_many()
-                .filter(project::Column::HostConnectionEpoch.ne(self.epoch()))
+                .filter(
+                    project::Column::HostConnectionServerId
+                        .is_in(stale_server_epochs.iter().copied()),
+                )
                 .exec(&*tx)
                 .await?;
             Ok(())
@@ -131,18 +151,28 @@ impl Database {
         .await
     }
 
-    pub async fn outdated_room_ids(&self) -> Result<Vec<RoomId>> {
+    pub async fn stale_room_ids(
+        &self,
+        environment: &str,
+        new_server_id: ServerId,
+    ) -> Result<Vec<RoomId>> {
         self.transaction(|tx| async move {
             #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
             enum QueryAs {
                 RoomId,
             }
 
+            let stale_server_epochs = self
+                .stale_server_ids(environment, new_server_id, &tx)
+                .await?;
             Ok(room_participant::Entity::find()
                 .select_only()
                 .column(room_participant::Column::RoomId)
                 .distinct()
-                .filter(room_participant::Column::AnsweringConnectionEpoch.ne(self.epoch()))
+                .filter(
+                    room_participant::Column::AnsweringConnectionServerId
+                        .is_in(stale_server_epochs),
+                )
                 .into_values::<_, QueryAs>()
                 .all(&*tx)
                 .await?)
@@ -150,12 +180,16 @@ impl Database {
         .await
     }
 
-    pub async fn refresh_room(&self, room_id: RoomId) -> Result<RoomGuard<RefreshedRoom>> {
+    pub async fn refresh_room(
+        &self,
+        room_id: RoomId,
+        new_server_id: ServerId,
+    ) -> Result<RoomGuard<RefreshedRoom>> {
         self.room_transaction(|tx| async move {
             let stale_participant_filter = Condition::all()
                 .add(room_participant::Column::RoomId.eq(room_id))
                 .add(room_participant::Column::AnsweringConnectionId.is_not_null())
-                .add(room_participant::Column::AnsweringConnectionEpoch.ne(self.epoch()));
+                .add(room_participant::Column::AnsweringConnectionServerId.ne(new_server_id));
 
             let stale_participant_user_ids = room_participant::Entity::find()
                 .filter(stale_participant_filter.clone())
@@ -199,6 +233,42 @@ impl Database {
         .await
     }
 
+    pub async fn delete_stale_servers(
+        &self,
+        new_server_id: ServerId,
+        environment: &str,
+    ) -> Result<()> {
+        self.transaction(|tx| async move {
+            server::Entity::delete_many()
+                .filter(
+                    Condition::all()
+                        .add(server::Column::Environment.eq(environment))
+                        .add(server::Column::Id.ne(new_server_id)),
+                )
+                .exec(&*tx)
+                .await?;
+            Ok(())
+        })
+        .await
+    }
+
+    async fn stale_server_ids(
+        &self,
+        environment: &str,
+        new_server_id: ServerId,
+        tx: &DatabaseTransaction,
+    ) -> Result<Vec<ServerId>> {
+        let stale_servers = server::Entity::find()
+            .filter(
+                Condition::all()
+                    .add(server::Column::Environment.eq(environment))
+                    .add(server::Column::Id.ne(new_server_id)),
+            )
+            .all(&*tx)
+            .await?;
+        Ok(stale_servers.into_iter().map(|server| server.id).collect())
+    }
+
     // users
 
     pub async fn create_user(
@@ -1076,7 +1146,7 @@ impl Database {
     pub async fn create_room(
         &self,
         user_id: UserId,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
         live_kit_room: &str,
     ) -> Result<RoomGuard<proto::Room>> {
         self.room_transaction(|tx| async move {
@@ -1091,12 +1161,16 @@ impl Database {
             room_participant::ActiveModel {
                 room_id: ActiveValue::set(room_id),
                 user_id: ActiveValue::set(user_id),
-                answering_connection_id: ActiveValue::set(Some(connection_id.0 as i32)),
-                answering_connection_epoch: ActiveValue::set(Some(self.epoch())),
+                answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
+                answering_connection_server_id: ActiveValue::set(Some(ServerId(
+                    connection.owner_id as i32,
+                ))),
                 answering_connection_lost: ActiveValue::set(false),
                 calling_user_id: ActiveValue::set(user_id),
-                calling_connection_id: ActiveValue::set(connection_id.0 as i32),
-                calling_connection_epoch: ActiveValue::set(self.epoch()),
+                calling_connection_id: ActiveValue::set(connection.id as i32),
+                calling_connection_server_id: ActiveValue::set(Some(ServerId(
+                    connection.owner_id as i32,
+                ))),
                 ..Default::default()
             }
             .insert(&*tx)
@@ -1112,7 +1186,7 @@ impl Database {
         &self,
         room_id: RoomId,
         calling_user_id: UserId,
-        calling_connection_id: ConnectionId,
+        calling_connection: ConnectionId,
         called_user_id: UserId,
         initial_project_id: Option<ProjectId>,
     ) -> Result<RoomGuard<(proto::Room, proto::IncomingCall)>> {
@@ -1122,8 +1196,10 @@ impl Database {
                 user_id: ActiveValue::set(called_user_id),
                 answering_connection_lost: ActiveValue::set(false),
                 calling_user_id: ActiveValue::set(calling_user_id),
-                calling_connection_id: ActiveValue::set(calling_connection_id.0 as i32),
-                calling_connection_epoch: ActiveValue::set(self.epoch()),
+                calling_connection_id: ActiveValue::set(calling_connection.id as i32),
+                calling_connection_server_id: ActiveValue::set(Some(ServerId(
+                    calling_connection.owner_id as i32,
+                ))),
                 initial_project_id: ActiveValue::set(initial_project_id),
                 ..Default::default()
             }
@@ -1162,57 +1238,64 @@ impl Database {
         &self,
         expected_room_id: Option<RoomId>,
         user_id: UserId,
-    ) -> Result<RoomGuard<proto::Room>> {
-        self.room_transaction(|tx| async move {
+    ) -> Result<Option<RoomGuard<proto::Room>>> {
+        self.optional_room_transaction(|tx| async move {
+            let mut filter = Condition::all()
+                .add(room_participant::Column::UserId.eq(user_id))
+                .add(room_participant::Column::AnsweringConnectionId.is_null());
+            if let Some(room_id) = expected_room_id {
+                filter = filter.add(room_participant::Column::RoomId.eq(room_id));
+            }
             let participant = room_participant::Entity::find()
-                .filter(
-                    room_participant::Column::UserId
-                        .eq(user_id)
-                        .and(room_participant::Column::AnsweringConnectionId.is_null()),
-                )
+                .filter(filter)
                 .one(&*tx)
-                .await?
-                .ok_or_else(|| anyhow!("could not decline call"))?;
-            let room_id = participant.room_id;
+                .await?;
 
-            if expected_room_id.map_or(false, |expected_room_id| expected_room_id != room_id) {
-                return Err(anyhow!("declining call on unexpected room"))?;
-            }
+            let participant = if let Some(participant) = participant {
+                participant
+            } else if expected_room_id.is_some() {
+                return Err(anyhow!("could not find call to decline"))?;
+            } else {
+                return Ok(None);
+            };
 
+            let room_id = participant.room_id;
             room_participant::Entity::delete(participant.into_active_model())
                 .exec(&*tx)
                 .await?;
 
             let room = self.get_room(room_id, &tx).await?;
-            Ok((room_id, room))
+            Ok(Some((room_id, room)))
         })
         .await
     }
 
     pub async fn cancel_call(
         &self,
-        expected_room_id: Option<RoomId>,
-        calling_connection_id: ConnectionId,
+        room_id: RoomId,
+        calling_connection: ConnectionId,
         called_user_id: UserId,
     ) -> Result<RoomGuard<proto::Room>> {
         self.room_transaction(|tx| async move {
             let participant = room_participant::Entity::find()
                 .filter(
-                    room_participant::Column::UserId
-                        .eq(called_user_id)
-                        .and(
+                    Condition::all()
+                        .add(room_participant::Column::UserId.eq(called_user_id))
+                        .add(room_participant::Column::RoomId.eq(room_id))
+                        .add(
                             room_participant::Column::CallingConnectionId
-                                .eq(calling_connection_id.0 as i32),
+                                .eq(calling_connection.id as i32),
                         )
-                        .and(room_participant::Column::AnsweringConnectionId.is_null()),
+                        .add(
+                            room_participant::Column::CallingConnectionServerId
+                                .eq(calling_connection.owner_id as i32),
+                        )
+                        .add(room_participant::Column::AnsweringConnectionId.is_null()),
                 )
                 .one(&*tx)
                 .await?
-                .ok_or_else(|| anyhow!("could not cancel call"))?;
+                .ok_or_else(|| anyhow!("no call to cancel"))?;
             let room_id = participant.room_id;
-            if expected_room_id.map_or(false, |expected_room_id| expected_room_id != room_id) {
-                return Err(anyhow!("canceling call on unexpected room"))?;
-            }
 
             room_participant::Entity::delete(participant.into_active_model())
                 .exec(&*tx)
@@ -1228,7 +1311,7 @@ impl Database {
         &self,
         room_id: RoomId,
         user_id: UserId,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
     ) -> Result<RoomGuard<proto::Room>> {
         self.room_transaction(|tx| async move {
             let result = room_participant::Entity::update_many()
@@ -1241,14 +1324,16 @@ impl Database {
                                 .add(room_participant::Column::AnsweringConnectionId.is_null())
                                 .add(room_participant::Column::AnsweringConnectionLost.eq(true))
                                 .add(
-                                    room_participant::Column::AnsweringConnectionEpoch
-                                        .ne(self.epoch()),
+                                    room_participant::Column::AnsweringConnectionServerId
+                                        .ne(connection.owner_id as i32),
                                 ),
                         ),
                 )
                 .set(room_participant::ActiveModel {
-                    answering_connection_id: ActiveValue::set(Some(connection_id.0 as i32)),
-                    answering_connection_epoch: ActiveValue::set(Some(self.epoch())),
+                    answering_connection_id: ActiveValue::set(Some(connection.id as i32)),
+                    answering_connection_server_id: ActiveValue::set(Some(ServerId(
+                        connection.owner_id as i32,
+                    ))),
                     answering_connection_lost: ActiveValue::set(false),
                     ..Default::default()
                 })
@@ -1264,10 +1349,23 @@ impl Database {
         .await
     }
 
-    pub async fn leave_room(&self, connection_id: ConnectionId) -> Result<RoomGuard<LeftRoom>> {
-        self.room_transaction(|tx| async move {
+    pub async fn leave_room(
+        &self,
+        connection: ConnectionId,
+    ) -> Result<Option<RoomGuard<LeftRoom>>> {
+        self.optional_room_transaction(|tx| async move {
             let leaving_participant = room_participant::Entity::find()
-                .filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32))
+                .filter(
+                    Condition::all()
+                        .add(
+                            room_participant::Column::AnsweringConnectionId
+                                .eq(connection.id as i32),
+                        )
+                        .add(
+                            room_participant::Column::AnsweringConnectionServerId
+                                .eq(connection.owner_id as i32),
+                        ),
+                )
                 .one(&*tx)
                 .await?;
 
@@ -1281,9 +1379,16 @@ impl Database {
                 // Cancel pending calls initiated by the leaving user.
                 let called_participants = room_participant::Entity::find()
                     .filter(
-                        room_participant::Column::CallingConnectionId
-                            .eq(connection_id.0)
-                            .and(room_participant::Column::AnsweringConnectionId.is_null()),
+                        Condition::all()
+                            .add(
+                                room_participant::Column::CallingConnectionId
+                                    .eq(connection.id as i32),
+                            )
+                            .add(
+                                room_participant::Column::CallingConnectionServerId
+                                    .eq(connection.owner_id as i32),
+                            )
+                            .add(room_participant::Column::AnsweringConnectionId.is_null()),
                     )
                     .all(&*tx)
                     .await?;
@@ -1310,7 +1415,16 @@ impl Database {
                         project_collaborator::Column::ProjectId,
                         QueryProjectIds::ProjectId,
                     )
-                    .filter(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32))
+                    .filter(
+                        Condition::all()
+                            .add(
+                                project_collaborator::Column::ConnectionId.eq(connection.id as i32),
+                            )
+                            .add(
+                                project_collaborator::Column::ConnectionServerId
+                                    .eq(connection.owner_id as i32),
+                            ),
+                    )
                     .into_values::<_, QueryProjectIds>()
                     .all(&*tx)
                     .await?;
@@ -1331,32 +1445,46 @@ impl Database {
                                 host_connection_id: Default::default(),
                             });
 
-                    let collaborator_connection_id =
-                        ConnectionId(collaborator.connection_id as u32);
-                    if collaborator_connection_id != connection_id {
+                    let collaborator_connection_id = ConnectionId {
+                        owner_id: collaborator.connection_server_id.0 as u32,
+                        id: collaborator.connection_id as u32,
+                    };
+                    if collaborator_connection_id != connection {
                         left_project.connection_ids.push(collaborator_connection_id);
                     }
 
                     if collaborator.is_host {
                         left_project.host_user_id = collaborator.user_id;
-                        left_project.host_connection_id =
-                            ConnectionId(collaborator.connection_id as u32);
+                        left_project.host_connection_id = collaborator_connection_id;
                     }
                 }
                 drop(collaborators);
 
                 // Leave projects.
                 project_collaborator::Entity::delete_many()
-                    .filter(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32))
+                    .filter(
+                        Condition::all()
+                            .add(
+                                project_collaborator::Column::ConnectionId.eq(connection.id as i32),
+                            )
+                            .add(
+                                project_collaborator::Column::ConnectionServerId
+                                    .eq(connection.owner_id as i32),
+                            ),
+                    )
                     .exec(&*tx)
                     .await?;
 
                 // Unshare projects.
                 project::Entity::delete_many()
                     .filter(
-                        project::Column::RoomId
-                            .eq(room_id)
-                            .and(project::Column::HostConnectionId.eq(connection_id.0 as i32)),
+                        Condition::all()
+                            .add(project::Column::RoomId.eq(room_id))
+                            .add(project::Column::HostConnectionId.eq(connection.id as i32))
+                            .add(
+                                project::Column::HostConnectionServerId
+                                    .eq(connection.owner_id as i32),
+                            ),
                     )
                     .exec(&*tx)
                     .await?;
@@ -1376,9 +1504,9 @@ impl Database {
                     self.rooms.remove(&room_id);
                 }
 
-                Ok((room_id, left_room))
+                Ok(Some((room_id, left_room)))
             } else {
-                Err(anyhow!("could not leave room"))?
+                Ok(None)
             }
         })
         .await
@@ -1387,7 +1515,7 @@ impl Database {
     pub async fn update_room_participant_location(
         &self,
         room_id: RoomId,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
         location: proto::ParticipantLocation,
     ) -> Result<RoomGuard<proto::Room>> {
         self.room_transaction(|tx| async {
@@ -1414,9 +1542,18 @@ impl Database {
             }
 
             let result = room_participant::Entity::update_many()
-                .filter(room_participant::Column::RoomId.eq(room_id).and(
-                    room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32),
-                ))
+                .filter(
+                    Condition::all()
+                        .add(room_participant::Column::RoomId.eq(room_id))
+                        .add(
+                            room_participant::Column::AnsweringConnectionId
+                                .eq(connection.id as i32),
+                        )
+                        .add(
+                            room_participant::Column::AnsweringConnectionServerId
+                                .eq(connection.owner_id as i32),
+                        ),
+                )
                 .set(room_participant::ActiveModel {
                     location_kind: ActiveValue::set(Some(location_kind)),
                     location_project_id: ActiveValue::set(location_project_id),
@@ -1437,11 +1574,21 @@ impl Database {
 
     pub async fn connection_lost(
         &self,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
     ) -> Result<RoomGuard<Vec<LeftProject>>> {
         self.room_transaction(|tx| async move {
             let participant = room_participant::Entity::find()
-                .filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32))
+                .filter(
+                    Condition::all()
+                        .add(
+                            room_participant::Column::AnsweringConnectionId
+                                .eq(connection.id as i32),
+                        )
+                        .add(
+                            room_participant::Column::AnsweringConnectionServerId
+                                .eq(connection.owner_id as i32),
+                        ),
+                )
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("not a participant in any room"))?;
@@ -1456,11 +1603,25 @@ impl Database {
 
             let collaborator_on_projects = project_collaborator::Entity::find()
                 .find_also_related(project::Entity)
-                .filter(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32))
+                .filter(
+                    Condition::all()
+                        .add(project_collaborator::Column::ConnectionId.eq(connection.id as i32))
+                        .add(
+                            project_collaborator::Column::ConnectionServerId
+                                .eq(connection.owner_id as i32),
+                        ),
+                )
                 .all(&*tx)
                 .await?;
             project_collaborator::Entity::delete_many()
-                .filter(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32))
+                .filter(
+                    Condition::all()
+                        .add(project_collaborator::Column::ConnectionId.eq(connection.id as i32))
+                        .add(
+                            project_collaborator::Column::ConnectionServerId
+                                .eq(connection.owner_id as i32),
+                        ),
+                )
                 .exec(&*tx)
                 .await?;
 
@@ -1473,20 +1634,29 @@ impl Database {
                         .await?;
                     let connection_ids = collaborators
                         .into_iter()
-                        .map(|collaborator| ConnectionId(collaborator.connection_id as u32))
+                        .map(|collaborator| ConnectionId {
+                            id: collaborator.connection_id as u32,
+                            owner_id: collaborator.connection_server_id.0 as u32,
+                        })
                         .collect();
 
                     left_projects.push(LeftProject {
                         id: project.id,
                         host_user_id: project.host_user_id,
-                        host_connection_id: ConnectionId(project.host_connection_id as u32),
+                        host_connection_id: project.host_connection()?,
                         connection_ids,
                     });
                 }
             }
 
             project::Entity::delete_many()
-                .filter(project::Column::HostConnectionId.eq(connection_id.0 as i32))
+                .filter(
+                    Condition::all()
+                        .add(project::Column::HostConnectionId.eq(connection.id as i32))
+                        .add(
+                            project::Column::HostConnectionServerId.eq(connection.owner_id as i32),
+                        ),
+                )
                 .exec(&*tx)
                 .await?;
 
@@ -1537,7 +1707,10 @@ impl Database {
         let mut pending_participants = Vec::new();
         while let Some(db_participant) = db_participants.next().await {
             let db_participant = db_participant?;
-            if let Some(answering_connection_id) = db_participant.answering_connection_id {
+            if let Some((answering_connection_id, answering_connection_server_id)) = db_participant
+                .answering_connection_id
+                .zip(db_participant.answering_connection_server_id)
+            {
                 let location = match (
                     db_participant.location_kind,
                     db_participant.location_project_id,
@@ -1556,11 +1729,16 @@ impl Database {
                         Default::default(),
                     )),
                 };
+
+                let answering_connection = ConnectionId {
+                    owner_id: answering_connection_server_id.0 as u32,
+                    id: answering_connection_id as u32,
+                };
                 participants.insert(
-                    answering_connection_id,
+                    answering_connection,
                     proto::Participant {
                         user_id: db_participant.user_id.to_proto(),
-                        peer_id: answering_connection_id as u32,
+                        peer_id: Some(answering_connection.into()),
                         projects: Default::default(),
                         location: Some(proto::ParticipantLocation { variant: location }),
                     },
@@ -1583,7 +1761,8 @@ impl Database {
 
         while let Some(row) = db_projects.next().await {
             let (db_project, db_worktree) = row?;
-            if let Some(participant) = participants.get_mut(&db_project.host_connection_id) {
+            let host_connection = db_project.host_connection()?;
+            if let Some(participant) = participants.get_mut(&host_connection) {
                 let project = if let Some(project) = participant
                     .projects
                     .iter_mut()
@@ -1637,12 +1816,22 @@ impl Database {
     pub async fn share_project(
         &self,
         room_id: RoomId,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
         worktrees: &[proto::WorktreeMetadata],
     ) -> Result<RoomGuard<(ProjectId, proto::Room)>> {
         self.room_transaction(|tx| async move {
             let participant = room_participant::Entity::find()
-                .filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32))
+                .filter(
+                    Condition::all()
+                        .add(
+                            room_participant::Column::AnsweringConnectionId
+                                .eq(connection.id as i32),
+                        )
+                        .add(
+                            room_participant::Column::AnsweringConnectionServerId
+                                .eq(connection.owner_id as i32),
+                        ),
+                )
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("could not find participant"))?;
@@ -1653,8 +1842,10 @@ impl Database {
             let project = project::ActiveModel {
                 room_id: ActiveValue::set(participant.room_id),
                 host_user_id: ActiveValue::set(participant.user_id),
-                host_connection_id: ActiveValue::set(connection_id.0 as i32),
-                host_connection_epoch: ActiveValue::set(self.epoch()),
+                host_connection_id: ActiveValue::set(Some(connection.id as i32)),
+                host_connection_server_id: ActiveValue::set(Some(ServerId(
+                    connection.owner_id as i32,
+                ))),
                 ..Default::default()
             }
             .insert(&*tx)
@@ -1678,8 +1869,8 @@ impl Database {
 
             project_collaborator::ActiveModel {
                 project_id: ActiveValue::set(project.id),
-                connection_id: ActiveValue::set(connection_id.0 as i32),
-                connection_epoch: ActiveValue::set(self.epoch()),
+                connection_id: ActiveValue::set(connection.id as i32),
+                connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
                 user_id: ActiveValue::set(participant.user_id),
                 replica_id: ActiveValue::set(ReplicaId(0)),
                 is_host: ActiveValue::set(true),
@@ -1697,7 +1888,7 @@ impl Database {
     pub async fn unshare_project(
         &self,
         project_id: ProjectId,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
     ) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
         self.room_transaction(|tx| async move {
             let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?;
@@ -1706,7 +1897,7 @@ impl Database {
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("project not found"))?;
-            if project.host_connection_id == connection_id.0 as i32 {
+            if project.host_connection()? == connection {
                 let room_id = project.room_id;
                 project::Entity::delete(project.into_active_model())
                     .exec(&*tx)
@@ -1723,12 +1914,18 @@ impl Database {
     pub async fn update_project(
         &self,
         project_id: ProjectId,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
         worktrees: &[proto::WorktreeMetadata],
     ) -> Result<RoomGuard<(proto::Room, Vec<ConnectionId>)>> {
         self.room_transaction(|tx| async move {
             let project = project::Entity::find_by_id(project_id)
-                .filter(project::Column::HostConnectionId.eq(connection_id.0 as i32))
+                .filter(
+                    Condition::all()
+                        .add(project::Column::HostConnectionId.eq(connection.id as i32))
+                        .add(
+                            project::Column::HostConnectionServerId.eq(connection.owner_id as i32),
+                        ),
+                )
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("no such project"))?;
@@ -1774,7 +1971,7 @@ impl Database {
     pub async fn update_worktree(
         &self,
         update: &proto::UpdateWorktree,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
     ) -> Result<RoomGuard<Vec<ConnectionId>>> {
         self.room_transaction(|tx| async move {
             let project_id = ProjectId::from_proto(update.project_id);
@@ -1782,7 +1979,13 @@ impl Database {
 
             // Ensure the update comes from the host.
             let project = project::Entity::find_by_id(project_id)
-                .filter(project::Column::HostConnectionId.eq(connection_id.0 as i32))
+                .filter(
+                    Condition::all()
+                        .add(project::Column::HostConnectionId.eq(connection.id as i32))
+                        .add(
+                            project::Column::HostConnectionServerId.eq(connection.owner_id as i32),
+                        ),
+                )
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("no such project"))?;
@@ -1862,7 +2065,7 @@ impl Database {
     pub async fn update_diagnostic_summary(
         &self,
         update: &proto::UpdateDiagnosticSummary,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
     ) -> Result<RoomGuard<Vec<ConnectionId>>> {
         self.room_transaction(|tx| async move {
             let project_id = ProjectId::from_proto(update.project_id);
@@ -1877,7 +2080,7 @@ impl Database {
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("no such project"))?;
-            if project.host_connection_id != connection_id.0 as i32 {
+            if project.host_connection()? != connection {
                 return Err(anyhow!("can't update a project hosted by someone else"))?;
             }
 
@@ -1916,7 +2119,7 @@ impl Database {
     pub async fn start_language_server(
         &self,
         update: &proto::StartLanguageServer,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
     ) -> Result<RoomGuard<Vec<ConnectionId>>> {
         self.room_transaction(|tx| async move {
             let project_id = ProjectId::from_proto(update.project_id);
@@ -1930,7 +2133,7 @@ impl Database {
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("no such project"))?;
-            if project.host_connection_id != connection_id.0 as i32 {
+            if project.host_connection()? != connection {
                 return Err(anyhow!("can't update a project hosted by someone else"))?;
             }
 
@@ -1961,11 +2164,21 @@ impl Database {
     pub async fn join_project(
         &self,
         project_id: ProjectId,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
     ) -> Result<RoomGuard<(Project, ReplicaId)>> {
         self.room_transaction(|tx| async move {
             let participant = room_participant::Entity::find()
-                .filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.0 as i32))
+                .filter(
+                    Condition::all()
+                        .add(
+                            room_participant::Column::AnsweringConnectionId
+                                .eq(connection.id as i32),
+                        )
+                        .add(
+                            room_participant::Column::AnsweringConnectionServerId
+                                .eq(connection.owner_id as i32),
+                        ),
+                )
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("must join a room first"))?;
@@ -1992,8 +2205,8 @@ impl Database {
             }
             let new_collaborator = project_collaborator::ActiveModel {
                 project_id: ActiveValue::set(project_id),
-                connection_id: ActiveValue::set(connection_id.0 as i32),
-                connection_epoch: ActiveValue::set(self.epoch()),
+                connection_id: ActiveValue::set(connection.id as i32),
+                connection_server_id: ActiveValue::set(ServerId(connection.owner_id as i32)),
                 user_id: ActiveValue::set(participant.user_id),
                 replica_id: ActiveValue::set(replica_id),
                 is_host: ActiveValue::set(false),
@@ -2095,14 +2308,18 @@ impl Database {
     pub async fn leave_project(
         &self,
         project_id: ProjectId,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
     ) -> Result<RoomGuard<LeftProject>> {
         self.room_transaction(|tx| async move {
             let result = project_collaborator::Entity::delete_many()
                 .filter(
-                    project_collaborator::Column::ProjectId
-                        .eq(project_id)
-                        .and(project_collaborator::Column::ConnectionId.eq(connection_id.0 as i32)),
+                    Condition::all()
+                        .add(project_collaborator::Column::ProjectId.eq(project_id))
+                        .add(project_collaborator::Column::ConnectionId.eq(connection.id as i32))
+                        .add(
+                            project_collaborator::Column::ConnectionServerId
+                                .eq(connection.owner_id as i32),
+                        ),
                 )
                 .exec(&*tx)
                 .await?;
@@ -2120,13 +2337,16 @@ impl Database {
                 .await?;
             let connection_ids = collaborators
                 .into_iter()
-                .map(|collaborator| ConnectionId(collaborator.connection_id as u32))
+                .map(|collaborator| ConnectionId {
+                    owner_id: collaborator.connection_server_id.0 as u32,
+                    id: collaborator.connection_id as u32,
+                })
                 .collect();
 
             let left_project = LeftProject {
                 id: project_id,
                 host_user_id: project.host_user_id,
-                host_connection_id: ConnectionId(project.host_connection_id as u32),
+                host_connection_id: project.host_connection()?,
                 connection_ids,
             };
             Ok((project.room_id, left_project))
@@ -2137,7 +2357,7 @@ impl Database {
     pub async fn project_collaborators(
         &self,
         project_id: ProjectId,
-        connection_id: ConnectionId,
+        connection: ConnectionId,
     ) -> Result<RoomGuard<Vec<project_collaborator::Model>>> {
         self.room_transaction(|tx| async move {
             let project = project::Entity::find_by_id(project_id)
@@ -2149,10 +2369,13 @@ impl Database {
                 .all(&*tx)
                 .await?;
 
-            if collaborators
-                .iter()
-                .any(|collaborator| collaborator.connection_id == connection_id.0 as i32)
-            {
+            if collaborators.iter().any(|collaborator| {
+                let collaborator_connection = ConnectionId {
+                    owner_id: collaborator.connection_server_id.0 as u32,
+                    id: collaborator.connection_id as u32,
+                };
+                collaborator_connection == connection
+            }) {
                 Ok((project.room_id, collaborators))
             } else {
                 Err(anyhow!("no such project"))?
@@ -2167,29 +2390,22 @@ impl Database {
         connection_id: ConnectionId,
     ) -> Result<RoomGuard<HashSet<ConnectionId>>> {
         self.room_transaction(|tx| async move {
-            #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
-            enum QueryAs {
-                ConnectionId,
-            }
-
             let project = project::Entity::find_by_id(project_id)
                 .one(&*tx)
                 .await?
                 .ok_or_else(|| anyhow!("no such project"))?;
-            let mut db_connection_ids = project_collaborator::Entity::find()
-                .select_only()
-                .column_as(
-                    project_collaborator::Column::ConnectionId,
-                    QueryAs::ConnectionId,
-                )
+            let mut participants = project_collaborator::Entity::find()
                 .filter(project_collaborator::Column::ProjectId.eq(project_id))
-                .into_values::<i32, QueryAs>()
                 .stream(&*tx)
                 .await?;
 
             let mut connection_ids = HashSet::default();
-            while let Some(connection_id) = db_connection_ids.next().await {
-                connection_ids.insert(ConnectionId(connection_id? as u32));
+            while let Some(participant) = participants.next().await {
+                let participant = participant?;
+                connection_ids.insert(ConnectionId {
+                    owner_id: participant.connection_server_id.0 as u32,
+                    id: participant.connection_id as u32,
+                });
             }
 
             if connection_ids.contains(&connection_id) {
@@ -2206,29 +2422,22 @@ impl Database {
         project_id: ProjectId,
         tx: &DatabaseTransaction,
     ) -> Result<Vec<ConnectionId>> {
-        #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
-        enum QueryAs {
-            ConnectionId,
-        }
-
-        let mut db_guest_connection_ids = project_collaborator::Entity::find()
-            .select_only()
-            .column_as(
-                project_collaborator::Column::ConnectionId,
-                QueryAs::ConnectionId,
-            )
+        let mut participants = project_collaborator::Entity::find()
             .filter(
                 project_collaborator::Column::ProjectId
                     .eq(project_id)
                     .and(project_collaborator::Column::IsHost.eq(false)),
             )
-            .into_values::<i32, QueryAs>()
             .stream(tx)
             .await?;
 
         let mut guest_connection_ids = Vec::new();
-        while let Some(connection_id) = db_guest_connection_ids.next().await {
-            guest_connection_ids.push(ConnectionId(connection_id? as u32));
+        while let Some(participant) = participants.next().await {
+            let participant = participant?;
+            guest_connection_ids.push(ConnectionId {
+                owner_id: participant.connection_server_id.0 as u32,
+                id: participant.connection_id as u32,
+            });
         }
         Ok(guest_connection_ids)
     }
@@ -2327,25 +2536,25 @@ impl Database {
         self.run(body).await
     }
 
-    async fn room_transaction<F, Fut, T>(&self, f: F) -> Result<RoomGuard<T>>
+    async fn optional_room_transaction<F, Fut, T>(&self, f: F) -> Result<Option<RoomGuard<T>>>
     where
         F: Send + Fn(TransactionHandle) -> Fut,
-        Fut: Send + Future<Output = Result<(RoomId, T)>>,
+        Fut: Send + Future<Output = Result<Option<(RoomId, T)>>>,
     {
         let body = async {
             loop {
                 let (tx, result) = self.with_transaction(&f).await?;
                 match result {
-                    Ok((room_id, data)) => {
+                    Ok(Some((room_id, data))) => {
                         let lock = self.rooms.entry(room_id).or_default().clone();
                         let _guard = lock.lock_owned().await;
                         match tx.commit().await.map_err(Into::into) {
                             Ok(()) => {
-                                return Ok(RoomGuard {
+                                return Ok(Some(RoomGuard {
                                     data,
                                     _guard,
                                     _not_send: PhantomData,
-                                });
+                                }));
                             }
                             Err(error) => {
                                 if is_serialization_error(&error) {
@@ -2356,6 +2565,18 @@ impl Database {
                             }
                         }
                     }
+                    Ok(None) => {
+                        match tx.commit().await.map_err(Into::into) {
+                            Ok(()) => return Ok(None),
+                            Err(error) => {
+                                if is_serialization_error(&error) {
+                                    // Retry (don't break the loop)
+                                } else {
+                                    return Err(error);
+                                }
+                            }
+                        }
+                    }
                     Err(error) => {
                         tx.rollback().await?;
                         if is_serialization_error(&error) {

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

@@ -1,4 +1,6 @@
-use super::{ProjectId, RoomId, UserId};
+use super::{ProjectId, Result, RoomId, ServerId, UserId};
+use anyhow::anyhow;
+use rpc::ConnectionId;
 use sea_orm::entity::prelude::*;
 
 #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -8,8 +10,23 @@ pub struct Model {
     pub id: ProjectId,
     pub room_id: RoomId,
     pub host_user_id: UserId,
-    pub host_connection_id: i32,
-    pub host_connection_epoch: Uuid,
+    pub host_connection_id: Option<i32>,
+    pub host_connection_server_id: Option<ServerId>,
+}
+
+impl Model {
+    pub fn host_connection(&self) -> Result<ConnectionId> {
+        let host_connection_server_id = self
+            .host_connection_server_id
+            .ok_or_else(|| anyhow!("empty host_connection_server_id"))?;
+        let host_connection_id = self
+            .host_connection_id
+            .ok_or_else(|| anyhow!("empty host_connection_id"))?;
+        Ok(ConnectionId {
+            owner_id: host_connection_server_id.0 as u32,
+            id: host_connection_id as u32,
+        })
+    }
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

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

@@ -1,4 +1,4 @@
-use super::{ProjectCollaboratorId, ProjectId, ReplicaId, UserId};
+use super::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
 use sea_orm::entity::prelude::*;
 
 #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -8,7 +8,7 @@ pub struct Model {
     pub id: ProjectCollaboratorId,
     pub project_id: ProjectId,
     pub connection_id: i32,
-    pub connection_epoch: Uuid,
+    pub connection_server_id: ServerId,
     pub user_id: UserId,
     pub replica_id: ReplicaId,
     pub is_host: bool,

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

@@ -1,4 +1,4 @@
-use super::{ProjectId, RoomId, RoomParticipantId, UserId};
+use super::{ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
 use sea_orm::entity::prelude::*;
 
 #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
@@ -9,14 +9,14 @@ pub struct Model {
     pub room_id: RoomId,
     pub user_id: UserId,
     pub answering_connection_id: Option<i32>,
-    pub answering_connection_epoch: Option<Uuid>,
+    pub answering_connection_server_id: Option<ServerId>,
     pub answering_connection_lost: bool,
     pub location_kind: Option<i32>,
     pub location_project_id: Option<ProjectId>,
     pub initial_project_id: Option<ProjectId>,
     pub calling_user_id: UserId,
     pub calling_connection_id: i32,
-    pub calling_connection_epoch: Uuid,
+    pub calling_connection_server_id: Option<ServerId>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

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

@@ -0,0 +1,15 @@
+use super::ServerId;
+use sea_orm::entity::prelude::*;
+
+#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
+#[sea_orm(table_name = "servers")]
+pub struct Model {
+    #[sea_orm(primary_key)]
+    pub id: ServerId,
+    pub environment: String,
+}
+
+#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
+pub enum Relation {}
+
+impl ActiveModelBehavior for ActiveModel {}

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

@@ -410,6 +410,8 @@ test_both_dbs!(
     test_project_count_sqlite,
     db,
     {
+        let owner_id = db.create_server("test").await.unwrap().0 as u32;
+
         let user1 = db
             .create_user(
                 &format!("admin@example.com"),
@@ -436,36 +438,44 @@ test_both_dbs!(
             .unwrap();
 
         let room_id = RoomId::from_proto(
-            db.create_room(user1.user_id, ConnectionId(0), "")
+            db.create_room(user1.user_id, ConnectionId { owner_id, id: 0 }, "")
                 .await
                 .unwrap()
                 .id,
         );
-        db.call(room_id, user1.user_id, ConnectionId(0), user2.user_id, None)
-            .await
-            .unwrap();
-        db.join_room(room_id, user2.user_id, ConnectionId(1))
+        db.call(
+            room_id,
+            user1.user_id,
+            ConnectionId { owner_id, id: 0 },
+            user2.user_id,
+            None,
+        )
+        .await
+        .unwrap();
+        db.join_room(room_id, user2.user_id, ConnectionId { owner_id, id: 1 })
             .await
             .unwrap();
         assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
 
-        db.share_project(room_id, ConnectionId(1), &[])
+        db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[])
             .await
             .unwrap();
         assert_eq!(db.project_count_excluding_admins().await.unwrap(), 1);
 
-        db.share_project(room_id, ConnectionId(1), &[])
+        db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[])
             .await
             .unwrap();
         assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2);
 
         // Projects shared by admins aren't counted.
-        db.share_project(room_id, ConnectionId(0), &[])
+        db.share_project(room_id, ConnectionId { owner_id, id: 0 }, &[])
             .await
             .unwrap();
         assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2);
 
-        db.leave_room(ConnectionId(1)).await.unwrap();
+        db.leave_room(ConnectionId { owner_id, id: 1 })
+            .await
+            .unwrap();
         assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0);
     }
 );

crates/collab/src/integration_tests.rs 🔗

@@ -1,19 +1,19 @@
 use crate::{
     db::{self, NewUserParams, TestDb, UserId},
     executor::Executor,
-    rpc::{Server, RECONNECT_TIMEOUT},
+    rpc::{Server, CLEANUP_TIMEOUT, RECONNECT_TIMEOUT},
     AppState,
 };
 use anyhow::anyhow;
 use call::{room, ActiveCall, ParticipantLocation, Room};
 use client::{
-    self, test::FakeHttpClient, Client, Connection, Credentials, EstablishConnectionError, PeerId,
-    User, UserStore, RECEIVE_TIMEOUT,
+    self, proto::PeerId, test::FakeHttpClient, Client, Connection, Credentials,
+    EstablishConnectionError, User, UserStore, RECEIVE_TIMEOUT,
 };
 use collections::{BTreeMap, HashMap, HashSet};
 use editor::{
-    self, ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Editor, Redo, Rename, ToOffset,
-    ToggleCodeActions, Undo,
+    self, ConfirmCodeAction, ConfirmCompletion, ConfirmRename, Editor, ExcerptRange, MultiBuffer,
+    Redo, Rename, ToOffset, ToggleCodeActions, Undo,
 };
 use fs::{FakeFs, Fs as _, HomeDir, LineEnding};
 use futures::{channel::oneshot, StreamExt as _};
@@ -22,7 +22,7 @@ use gpui::{
     TestAppContext, ViewHandle,
 };
 use language::{
-    range_to_lsp, tree_sitter_rust, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language,
+    range_to_lsp, tree_sitter_rust, Anchor, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language,
     LanguageConfig, LanguageRegistry, OffsetRangeExt, Point, PointUtf16, Rope,
 };
 use live_kit_client::MacOSDisplay;
@@ -608,7 +608,7 @@ async fn test_server_restarts(
     );
 
     // The server is torn down.
-    server.teardown();
+    server.reset().await;
 
     // Users A and B reconnect to the call. User C has troubles reconnecting, so it leaves the room.
     client_c.override_establish_connection(|_, cx| cx.spawn(|_| future::pending()));
@@ -686,7 +686,7 @@ async fn test_server_restarts(
 
     // The server finishes restarting, cleaning up stale connections.
     server.start().await.unwrap();
-    deterministic.advance_clock(RECONNECT_TIMEOUT);
+    deterministic.advance_clock(CLEANUP_TIMEOUT);
     assert_eq!(
         room_participants(&room_a, cx_a),
         RoomParticipants {
@@ -778,7 +778,7 @@ async fn test_server_restarts(
     );
 
     // The server is torn down.
-    server.teardown();
+    server.reset().await;
 
     // Users A and B have troubles reconnecting, so they leave the room.
     client_a.override_establish_connection(|_, cx| cx.spawn(|_| future::pending()));
@@ -806,7 +806,7 @@ async fn test_server_restarts(
     // The server finishes restarting, cleaning up stale connections and canceling the
     // call to user D because the room has become empty.
     server.start().await.unwrap();
-    deterministic.advance_clock(RECONNECT_TIMEOUT);
+    deterministic.advance_clock(CLEANUP_TIMEOUT);
     assert!(incoming_call_d.next().await.unwrap().is_none());
 }
 
@@ -1058,17 +1058,22 @@ async fn test_share_project(
 
     let editor_b = cx_b.add_view(&window_b, |cx| Editor::for_buffer(buffer_b, None, cx));
 
-    // TODO
-    // // Create a selection set as client B and see that selection set as client A.
-    // buffer_a
-    //     .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 1)
-    //     .await;
+    // Client A sees client B's selection
+    deterministic.run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| {
+        buffer
+            .snapshot()
+            .remote_selections_in_range(Anchor::MIN..Anchor::MAX)
+            .count()
+            == 1
+    });
 
     // Edit the buffer as client B and see that edit as client A.
     editor_b.update(cx_b, |editor, cx| editor.handle_input("ok, ", cx));
-    buffer_a
-        .condition(cx_a, |buffer, _| buffer.text() == "ok, b-contents")
-        .await;
+    deterministic.run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| {
+        assert_eq!(buffer.text(), "ok, b-contents")
+    });
 
     // Client B can invite client C on a project shared by client A.
     active_call_b
@@ -1091,12 +1096,16 @@ async fn test_share_project(
         .build_remote_project(initial_project.id, cx_c)
         .await;
 
-    // TODO
-    // // Remove the selection set as client B, see those selections disappear as client A.
+    // Client B closes the editor, and client A sees client B's selections removed.
     cx_b.update(move |_| drop(editor_b));
-    // buffer_a
-    //     .condition(&cx_a, |buffer, _| buffer.selection_sets().count() == 0)
-    //     .await;
+    deterministic.run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| {
+        buffer
+            .snapshot()
+            .remote_selections_in_range(Anchor::MIN..Anchor::MAX)
+            .count()
+            == 0
+    });
 }
 
 #[gpui::test(iterations = 10)]
@@ -1250,13 +1259,9 @@ async fn test_host_disconnect(
     server.forbid_connections();
     server.disconnect_client(client_a.peer_id().unwrap());
     deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
-    project_a
-        .condition(cx_a, |project, _| project.collaborators().is_empty())
-        .await;
+    project_a.read_with(cx_a, |project, _| project.collaborators().is_empty());
     project_a.read_with(cx_a, |project, _| assert!(!project.is_shared()));
-    project_b
-        .condition(cx_b, |project, _| project.is_read_only())
-        .await;
+    project_b.read_with(cx_b, |project, _| project.is_read_only());
     assert!(worktree_a.read_with(cx_a, |tree, _| !tree.as_local().unwrap().is_shared()));
 
     // Ensure client B's edited state is reset and that the whole window is blurred.
@@ -1641,9 +1646,8 @@ async fn test_propagate_saves_and_fs_changes(
         .await
         .unwrap();
 
-    buffer_a
-        .condition(cx_a, |buf, _| buf.text() == "i-am-c, i-am-b, ")
-        .await;
+    deterministic.run_until_parked();
+    buffer_a.read_with(cx_a, |buf, _| assert_eq!(buf.text(), "i-am-c, i-am-b, "));
     buffer_a.update(cx_a, |buf, cx| {
         buf.edit([(buf.len()..buf.len(), "i-am-a")], None, cx)
     });
@@ -2297,9 +2301,8 @@ async fn test_buffer_conflict_after_save(
     });
 
     buffer_b.update(cx_b, |buf, cx| buf.save(cx)).await.unwrap();
-    buffer_b
-        .condition(cx_b, |buffer_b, _| !buffer_b.is_dirty())
-        .await;
+    cx_a.foreground().forbid_parking();
+    buffer_b.read_with(cx_b, |buffer_b, _| assert!(!buffer_b.is_dirty()));
     buffer_b.read_with(cx_b, |buf, _| {
         assert!(!buf.has_conflict());
     });
@@ -2359,12 +2362,9 @@ async fn test_buffer_reloading(
         .save("/dir/a.txt".as_ref(), &new_contents, LineEnding::Windows)
         .await
         .unwrap();
-    buffer_b
-        .condition(cx_b, |buf, _| {
-            buf.text() == new_contents.to_string() && !buf.is_dirty()
-        })
-        .await;
+    cx_a.foreground().run_until_parked();
     buffer_b.read_with(cx_b, |buf, _| {
+        assert_eq!(buf.text(), new_contents.to_string());
         assert!(!buf.is_dirty());
         assert!(!buf.has_conflict());
         assert_eq!(buf.line_ending(), LineEnding::Windows);
@@ -2416,7 +2416,8 @@ async fn test_editing_while_guest_opens_buffer(
 
     let text = buffer_a.read_with(cx_a, |buf, _| buf.text());
     let buffer_b = buffer_b.await.unwrap();
-    buffer_b.condition(cx_b, |buf, _| buf.text() == text).await;
+    cx_a.foreground().run_until_parked();
+    buffer_b.read_with(cx_b, |buf, _| assert_eq!(buf.text(), text));
 }
 
 #[gpui::test(iterations = 10)]
@@ -2446,9 +2447,8 @@ async fn test_leaving_worktree_while_opening_buffer(
     let project_b = client_b.build_remote_project(project_id, cx_b).await;
 
     // See that a guest has joined as client A.
-    project_a
-        .condition(cx_a, |p, _| p.collaborators().len() == 1)
-        .await;
+    cx_a.foreground().run_until_parked();
+    project_a.read_with(cx_a, |p, _| assert_eq!(p.collaborators().len(), 1));
 
     // Begin opening a buffer as client B, but leave the project before the open completes.
     let buffer_b = cx_b
@@ -2458,9 +2458,8 @@ async fn test_leaving_worktree_while_opening_buffer(
     drop(buffer_b);
 
     // See that the guest has left.
-    project_a
-        .condition(cx_a, |p, _| p.collaborators().is_empty())
-        .await;
+    cx_a.foreground().run_until_parked();
+    project_a.read_with(cx_a, |p, _| assert!(p.collaborators().is_empty()));
 }
 
 #[gpui::test(iterations = 10)]
@@ -2979,9 +2978,10 @@ async fn test_collaborating_with_completion(
     });
 
     let fake_language_server = fake_language_servers.next().await.unwrap();
-    buffer_b
-        .condition(cx_b, |buffer, _| !buffer.completion_triggers().is_empty())
-        .await;
+    cx_a.foreground().run_until_parked();
+    buffer_b.read_with(cx_b, |buffer, _| {
+        assert!(!buffer.completion_triggers().is_empty())
+    });
 
     // Type a completion trigger character as the guest.
     editor_b.update(cx_b, |editor, cx| {
@@ -3043,14 +3043,13 @@ async fn test_collaborating_with_completion(
         .update(cx_a, |p, cx| p.open_buffer((worktree_id, "main.rs"), cx))
         .await
         .unwrap();
-    buffer_a
-        .condition(cx_a, |buffer, _| buffer.text() == "fn main() { a. }")
-        .await;
+    cx_a.foreground().run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| {
+        assert_eq!(buffer.text(), "fn main() { a. }")
+    });
 
     // Confirm a completion on the guest.
-    editor_b
-        .condition(cx_b, |editor, _| editor.context_menu_visible())
-        .await;
+    editor_b.read_with(cx_b, |editor, _| assert!(editor.context_menu_visible()));
     editor_b.update(cx_b, |editor, cx| {
         editor.confirm_completion(&ConfirmCompletion { item_ix: Some(0) }, cx);
         assert_eq!(editor.text(cx), "fn main() { a.first_method() }");
@@ -3079,16 +3078,19 @@ async fn test_collaborating_with_completion(
     );
 
     // The additional edit is applied.
-    buffer_a
-        .condition(cx_a, |buffer, _| {
-            buffer.text() == "use d::SomeTrait;\nfn main() { a.first_method() }"
-        })
-        .await;
-    buffer_b
-        .condition(cx_b, |buffer, _| {
-            buffer.text() == "use d::SomeTrait;\nfn main() { a.first_method() }"
-        })
-        .await;
+    cx_a.foreground().run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| {
+        assert_eq!(
+            buffer.text(),
+            "use d::SomeTrait;\nfn main() { a.first_method() }"
+        );
+    });
+    buffer_b.read_with(cx_b, |buffer, _| {
+        assert_eq!(
+            buffer.text(),
+            "use d::SomeTrait;\nfn main() { a.first_method() }"
+        );
+    });
 }
 
 #[gpui::test(iterations = 10)]
@@ -3134,9 +3136,8 @@ async fn test_reloading_buffer_manually(
         assert!(buffer.is_dirty());
         assert!(!buffer.has_conflict());
     });
-    buffer_a
-        .condition(cx_a, |buffer, _| buffer.text() == "let six = 6;")
-        .await;
+    cx_a.foreground().run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| assert_eq!(buffer.text(), "let six = 6;"));
 
     client_a
         .fs
@@ -3147,12 +3148,9 @@ async fn test_reloading_buffer_manually(
         )
         .await
         .unwrap();
-    buffer_a
-        .condition(cx_a, |buffer, _| buffer.has_conflict())
-        .await;
-    buffer_b
-        .condition(cx_b, |buffer, _| buffer.has_conflict())
-        .await;
+    cx_a.foreground().run_until_parked();
+    buffer_a.read_with(cx_a, |buffer, _| assert!(buffer.has_conflict()));
+    buffer_b.read_with(cx_b, |buffer, _| assert!(buffer.has_conflict()));
 
     project_b
         .update(cx_b, |project, cx| {
@@ -4178,9 +4176,8 @@ async fn test_collaborating_with_code_actions(
             cx,
         );
     });
-    editor_b
-        .condition(cx_b, |editor, _| editor.context_menu_visible())
-        .await;
+    cx_a.foreground().run_until_parked();
+    editor_b.read_with(cx_b, |editor, _| assert!(editor.context_menu_visible()));
 
     fake_language_server.remove_request_handler::<lsp::request::CodeActionRequest>();
 
@@ -5162,9 +5159,9 @@ async fn test_following(
         .insert_tree(
             "/a",
             json!({
-                "1.txt": "one",
-                "2.txt": "two",
-                "3.txt": "three",
+                "1.txt": "one\none\none",
+                "2.txt": "two\ntwo\ntwo",
+                "3.txt": "three\nthree\nthree",
             }),
         )
         .await;
@@ -5263,11 +5260,60 @@ async fn test_following(
     workspace_a.update(cx_a, |workspace, cx| {
         workspace.activate_item(&editor_a1, cx)
     });
-    workspace_b
-        .condition(cx_b, |workspace, cx| {
-            workspace.active_item(cx).unwrap().id() == editor_b1.id()
-        })
-        .await;
+    deterministic.run_until_parked();
+    workspace_b.read_with(cx_b, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id());
+    });
+
+    // When client A opens a multibuffer, client B does so as well.
+    let multibuffer_a = cx_a.add_model(|cx| {
+        let buffer_a1 = project_a.update(cx, |project, cx| {
+            project
+                .get_open_buffer(&(worktree_id, "1.txt").into(), cx)
+                .unwrap()
+        });
+        let buffer_a2 = project_a.update(cx, |project, cx| {
+            project
+                .get_open_buffer(&(worktree_id, "2.txt").into(), cx)
+                .unwrap()
+        });
+        let mut result = MultiBuffer::new(0);
+        result.push_excerpts(
+            buffer_a1,
+            [ExcerptRange {
+                context: 0..3,
+                primary: None,
+            }],
+            cx,
+        );
+        result.push_excerpts(
+            buffer_a2,
+            [ExcerptRange {
+                context: 4..7,
+                primary: None,
+            }],
+            cx,
+        );
+        result
+    });
+    let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| {
+        let editor =
+            cx.add_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx));
+        workspace.add_item(Box::new(editor.clone()), cx);
+        editor
+    });
+    deterministic.run_until_parked();
+    let multibuffer_editor_b = workspace_b.read_with(cx_b, |workspace, cx| {
+        workspace
+            .active_item(cx)
+            .unwrap()
+            .downcast::<Editor>()
+            .unwrap()
+    });
+    assert_eq!(
+        multibuffer_editor_a.read_with(cx_a, |editor, cx| editor.text(cx)),
+        multibuffer_editor_b.read_with(cx_b, |editor, cx| editor.text(cx)),
+    );
 
     // When client A navigates back and forth, client B does so as well.
     workspace_a
@@ -5275,47 +5321,52 @@ async fn test_following(
             workspace::Pane::go_back(workspace, None, cx)
         })
         .await;
-    workspace_b
-        .condition(cx_b, |workspace, cx| {
-            workspace.active_item(cx).unwrap().id() == editor_b2.id()
+    deterministic.run_until_parked();
+    workspace_b.read_with(cx_b, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id());
+    });
+
+    workspace_a
+        .update(cx_a, |workspace, cx| {
+            workspace::Pane::go_back(workspace, None, cx)
         })
         .await;
+    deterministic.run_until_parked();
+    workspace_b.read_with(cx_b, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b2.id());
+    });
 
     workspace_a
         .update(cx_a, |workspace, cx| {
             workspace::Pane::go_forward(workspace, None, cx)
         })
         .await;
-    workspace_b
-        .condition(cx_b, |workspace, cx| {
-            workspace.active_item(cx).unwrap().id() == editor_b1.id()
-        })
-        .await;
+    deterministic.run_until_parked();
+    workspace_b.read_with(cx_b, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id());
+    });
 
     // Changes to client A's editor are reflected on client B.
     editor_a1.update(cx_a, |editor, cx| {
         editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2]));
     });
-    editor_b1
-        .condition(cx_b, |editor, cx| {
-            editor.selections.ranges(cx) == vec![1..1, 2..2]
-        })
-        .await;
+    deterministic.run_until_parked();
+    editor_b1.read_with(cx_b, |editor, cx| {
+        assert_eq!(editor.selections.ranges(cx), &[1..1, 2..2]);
+    });
 
     editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx));
-    editor_b1
-        .condition(cx_b, |editor, cx| editor.text(cx) == "TWO")
-        .await;
+    deterministic.run_until_parked();
+    editor_b1.read_with(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO"));
 
     editor_a1.update(cx_a, |editor, cx| {
         editor.change_selections(None, cx, |s| s.select_ranges([3..3]));
         editor.set_scroll_position(vec2f(0., 100.), cx);
     });
-    editor_b1
-        .condition(cx_b, |editor, cx| {
-            editor.selections.ranges(cx) == vec![3..3]
-        })
-        .await;
+    deterministic.run_until_parked();
+    editor_b1.read_with(cx_b, |editor, cx| {
+        assert_eq!(editor.selections.ranges(cx), &[3..3]);
+    });
 
     // After unfollowing, client B stops receiving updates from client A.
     workspace_b.update(cx_b, |workspace, cx| {
@@ -5384,13 +5435,21 @@ async fn test_following(
         .await
         .unwrap();
     deterministic.run_until_parked();
-    assert_eq!(
-        workspace_a.read_with(cx_a, |workspace, cx| workspace
-            .active_item(cx)
-            .unwrap()
-            .id()),
-        editor_a1.id()
-    );
+    workspace_a.read_with(cx_a, |workspace, cx| {
+        assert_eq!(workspace.active_item(cx).unwrap().id(), editor_a1.id())
+    });
+
+    // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer.
+    workspace_b.update(cx_b, |workspace, cx| {
+        workspace.activate_item(&multibuffer_editor_b, cx)
+    });
+    deterministic.run_until_parked();
+    workspace_a.read_with(cx_a, |workspace, cx| {
+        assert_eq!(
+            workspace.active_item(cx).unwrap().id(),
+            multibuffer_editor_a.id()
+        )
+    });
 
     // Client B activates an external window again, and the previously-opened screen-sharing item
     // gets activated.
@@ -6066,7 +6125,7 @@ async fn test_random_collaboration(
                     .user_connection_ids(removed_guest_id)
                     .collect::<Vec<_>>();
                 assert_eq!(user_connection_ids.len(), 1);
-                let removed_peer_id = PeerId(user_connection_ids[0].0);
+                let removed_peer_id = user_connection_ids[0].into();
                 let guest = clients.remove(guest_ix);
                 op_start_signals.remove(guest_ix);
                 server.forbid_connections();
@@ -6115,17 +6174,25 @@ async fn test_random_collaboration(
                     .user_connection_ids(user_id)
                     .collect::<Vec<_>>();
                 assert_eq!(user_connection_ids.len(), 1);
-                let peer_id = PeerId(user_connection_ids[0].0);
+                let peer_id = user_connection_ids[0].into();
                 server.disconnect_client(peer_id);
                 deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
                 operations += 1;
             }
             30..=34 => {
                 log::info!("Simulating server restart");
-                server.teardown();
-                deterministic.advance_clock(RECEIVE_TIMEOUT + RECONNECT_TIMEOUT);
+                server.reset().await;
+                deterministic.advance_clock(RECEIVE_TIMEOUT);
                 server.start().await.unwrap();
-                deterministic.advance_clock(RECONNECT_TIMEOUT);
+                deterministic.advance_clock(CLEANUP_TIMEOUT);
+                let environment = &server.app_state.config.zed_environment;
+                let stale_room_ids = server
+                    .app_state
+                    .db
+                    .stale_room_ids(environment, server.id())
+                    .await
+                    .unwrap();
+                assert_eq!(stale_room_ids, vec![]);
             }
             _ if !op_start_signals.is_empty() => {
                 while operations < max_operations && rng.lock().gen_bool(0.7) {
@@ -6320,13 +6387,19 @@ impl TestServer {
         )
         .unwrap();
         let app_state = Self::build_app_state(&test_db, &live_kit_server).await;
+        let epoch = app_state
+            .db
+            .create_server(&app_state.config.zed_environment)
+            .await
+            .unwrap();
         let server = Server::new(
+            epoch,
             app_state.clone(),
             Executor::Deterministic(deterministic.build_background()),
         );
         server.start().await.unwrap();
         // Advance clock to ensure the server's cleanup task is finished.
-        deterministic.advance_clock(RECONNECT_TIMEOUT);
+        deterministic.advance_clock(CLEANUP_TIMEOUT);
         Self {
             app_state,
             server,
@@ -6337,9 +6410,15 @@ impl TestServer {
         }
     }
 
-    fn teardown(&self) {
-        self.server.teardown();
+    async fn reset(&self) {
         self.app_state.db.reset();
+        let epoch = self
+            .app_state
+            .db
+            .create_server(&self.app_state.config.zed_environment)
+            .await
+            .unwrap();
+        self.server.reset(epoch);
     }
 
     async fn create_client(&mut self, cx: &mut TestAppContext, name: &str) -> TestClient {
@@ -6429,7 +6508,7 @@ impl TestServer {
                         let connection_id = connection_id_rx.await.unwrap();
                         connection_killers
                             .lock()
-                            .insert(PeerId(connection_id.0), killed);
+                            .insert(connection_id.into(), killed);
                         Ok(client_conn)
                     }
                 })
@@ -6445,7 +6524,7 @@ impl TestServer {
             fs: fs.clone(),
             build_window_options: Default::default,
             initialize_workspace: |_, _, _| unimplemented!(),
-            default_item_factory: |_, _| unimplemented!(),
+            dock_default_item_factory: |_, _| unimplemented!(),
         });
 
         Project::init(&client);
@@ -7251,7 +7330,7 @@ impl TestClient {
 
 impl Drop for TestClient {
     fn drop(&mut self) {
-        self.client.tear_down();
+        self.client.teardown();
     }
 }
 

crates/collab/src/lib.rs 🔗

@@ -97,6 +97,7 @@ pub struct Config {
     pub live_kit_secret: Option<String>,
     pub rust_log: Option<String>,
     pub log_json: Option<bool>,
+    pub zed_environment: String,
 }
 
 #[derive(Default, Deserialize)]

crates/collab/src/main.rs 🔗

@@ -7,6 +7,7 @@ use std::{
     net::{SocketAddr, TcpListener},
     path::Path,
 };
+use tokio::signal::unix::SignalKind;
 use tracing_log::LogTracer;
 use tracing_subscriber::{filter::EnvFilter, fmt::format::JsonFields, Layer};
 use util::ResultExt;
@@ -56,7 +57,11 @@ async fn main() -> Result<()> {
             let listener = TcpListener::bind(&format!("0.0.0.0:{}", state.config.http_port))
                 .expect("failed to bind TCP listener");
 
-            let rpc_server = collab::rpc::Server::new(state.clone(), Executor::Production);
+            let epoch = state
+                .db
+                .create_server(&state.config.zed_environment)
+                .await?;
+            let rpc_server = collab::rpc::Server::new(epoch, state.clone(), Executor::Production);
             rpc_server.start().await?;
 
             let app = collab::api::routes(rpc_server.clone(), state.clone())
@@ -65,6 +70,18 @@ async fn main() -> Result<()> {
 
             axum::Server::from_tcp(listener)?
                 .serve(app.into_make_service_with_connect_info::<SocketAddr>())
+                .with_graceful_shutdown(async move {
+                    let mut sigterm = tokio::signal::unix::signal(SignalKind::terminate())
+                        .expect("failed to listen for interrupt signal");
+                    let mut sigint = tokio::signal::unix::signal(SignalKind::interrupt())
+                        .expect("failed to listen for interrupt signal");
+                    let sigterm = sigterm.recv();
+                    let sigint = sigint.recv();
+                    futures::pin_mut!(sigterm, sigint);
+                    futures::future::select(sigterm, sigint).await;
+                    tracing::info!("Received interrupt signal");
+                    rpc_server.teardown();
+                })
                 .await?;
         }
         _ => {

crates/collab/src/rpc.rs 🔗

@@ -2,7 +2,7 @@ mod connection_pool;
 
 use crate::{
     auth,
-    db::{self, Database, ProjectId, RoomId, User, UserId},
+    db::{self, Database, ProjectId, RoomId, ServerId, User, UserId},
     executor::Executor,
     AppState, Result,
 };
@@ -57,7 +57,8 @@ use tokio::sync::watch;
 use tower::ServiceBuilder;
 use tracing::{info_span, instrument, Instrument};
 
-pub const RECONNECT_TIMEOUT: Duration = rpc::RECEIVE_TIMEOUT;
+pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(5);
+pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
 
 lazy_static! {
     static ref METRIC_CONNECTIONS: IntGauge =
@@ -137,6 +138,7 @@ impl Deref for DbHandle {
 }
 
 pub struct Server {
+    id: parking_lot::Mutex<ServerId>,
     peer: Arc<Peer>,
     pub(crate) connection_pool: Arc<parking_lot::Mutex<ConnectionPool>>,
     app_state: Arc<AppState>,
@@ -167,9 +169,10 @@ where
 }
 
 impl Server {
-    pub fn new(app_state: Arc<AppState>, executor: Executor) -> Arc<Self> {
+    pub fn new(id: ServerId, app_state: Arc<AppState>, executor: Executor) -> Arc<Self> {
         let mut server = Self {
-            peer: Peer::new(),
+            id: parking_lot::Mutex::new(id),
+            peer: Peer::new(id.0 as u32),
             app_state,
             executor,
             connection_pool: Default::default(),
@@ -238,98 +241,146 @@ impl Server {
     }
 
     pub async fn start(&self) -> Result<()> {
-        self.app_state.db.delete_stale_projects().await?;
-        let db = self.app_state.db.clone();
+        let server_id = *self.id.lock();
+        let app_state = self.app_state.clone();
         let peer = self.peer.clone();
-        let timeout = self.executor.sleep(RECONNECT_TIMEOUT);
+        let timeout = self.executor.sleep(CLEANUP_TIMEOUT);
         let pool = self.connection_pool.clone();
         let live_kit_client = self.app_state.live_kit_client.clone();
-        self.executor.spawn_detached(async move {
-            timeout.await;
-            if let Some(room_ids) = db.outdated_room_ids().await.trace_err() {
-                for room_id in room_ids {
-                    let mut contacts_to_update = HashSet::default();
-                    let mut canceled_calls_to_user_ids = Vec::new();
-                    let mut live_kit_room = String::new();
-                    let mut delete_live_kit_room = false;
-
-                    if let Ok(mut refreshed_room) = db.refresh_room(room_id).await {
-                        room_updated(&refreshed_room.room, &peer);
-                        contacts_to_update
-                            .extend(refreshed_room.stale_participant_user_ids.iter().copied());
-                        contacts_to_update
-                            .extend(refreshed_room.canceled_calls_to_user_ids.iter().copied());
-                        canceled_calls_to_user_ids =
-                            mem::take(&mut refreshed_room.canceled_calls_to_user_ids);
-                        live_kit_room = mem::take(&mut refreshed_room.room.live_kit_room);
-                        delete_live_kit_room = refreshed_room.room.participants.is_empty();
-                    }
 
-                    {
-                        let pool = pool.lock();
-                        for canceled_user_id in canceled_calls_to_user_ids {
-                            for connection_id in pool.user_connection_ids(canceled_user_id) {
-                                peer.send(
-                                    connection_id,
-                                    proto::CallCanceled {
-                                        room_id: room_id.to_proto(),
-                                    },
-                                )
-                                .trace_err();
-                            }
+        let span = info_span!("start server");
+        let span_enter = span.enter();
+
+        tracing::info!("begin deleting stale projects");
+        app_state
+            .db
+            .delete_stale_projects(&app_state.config.zed_environment, server_id)
+            .await?;
+        tracing::info!("finish deleting stale projects");
+
+        drop(span_enter);
+        self.executor.spawn_detached(
+            async move {
+                tracing::info!("waiting for cleanup timeout");
+                timeout.await;
+                tracing::info!("cleanup timeout expired, retrieving stale rooms");
+                if let Some(room_ids) = app_state
+                    .db
+                    .stale_room_ids(&app_state.config.zed_environment, server_id)
+                    .await
+                    .trace_err()
+                {
+                    tracing::info!(stale_room_count = room_ids.len(), "retrieved stale rooms");
+                    for room_id in room_ids {
+                        let mut contacts_to_update = HashSet::default();
+                        let mut canceled_calls_to_user_ids = Vec::new();
+                        let mut live_kit_room = String::new();
+                        let mut delete_live_kit_room = false;
+
+                        if let Ok(mut refreshed_room) =
+                            app_state.db.refresh_room(room_id, server_id).await
+                        {
+                            tracing::info!(
+                                room_id = room_id.0,
+                                new_participant_count = refreshed_room.room.participants.len(),
+                                "refreshed room"
+                            );
+                            room_updated(&refreshed_room.room, &peer);
+                            contacts_to_update
+                                .extend(refreshed_room.stale_participant_user_ids.iter().copied());
+                            contacts_to_update
+                                .extend(refreshed_room.canceled_calls_to_user_ids.iter().copied());
+                            canceled_calls_to_user_ids =
+                                mem::take(&mut refreshed_room.canceled_calls_to_user_ids);
+                            live_kit_room = mem::take(&mut refreshed_room.room.live_kit_room);
+                            delete_live_kit_room = refreshed_room.room.participants.is_empty();
                         }
-                    }
 
-                    for user_id in contacts_to_update {
-                        let busy = db.is_user_busy(user_id).await.trace_err();
-                        let contacts = db.get_contacts(user_id).await.trace_err();
-                        if let Some((busy, contacts)) = busy.zip(contacts) {
+                        {
                             let pool = pool.lock();
-                            let updated_contact = contact_for_user(user_id, false, busy, &pool);
-                            for contact in contacts {
-                                if let db::Contact::Accepted {
-                                    user_id: contact_user_id,
-                                    ..
-                                } = contact
-                                {
-                                    for contact_conn_id in pool.user_connection_ids(contact_user_id)
+                            for canceled_user_id in canceled_calls_to_user_ids {
+                                for connection_id in pool.user_connection_ids(canceled_user_id) {
+                                    peer.send(
+                                        connection_id,
+                                        proto::CallCanceled {
+                                            room_id: room_id.to_proto(),
+                                        },
+                                    )
+                                    .trace_err();
+                                }
+                            }
+                        }
+
+                        for user_id in contacts_to_update {
+                            let busy = app_state.db.is_user_busy(user_id).await.trace_err();
+                            let contacts = app_state.db.get_contacts(user_id).await.trace_err();
+                            if let Some((busy, contacts)) = busy.zip(contacts) {
+                                let pool = pool.lock();
+                                let updated_contact = contact_for_user(user_id, false, busy, &pool);
+                                for contact in contacts {
+                                    if let db::Contact::Accepted {
+                                        user_id: contact_user_id,
+                                        ..
+                                    } = contact
                                     {
-                                        peer.send(
-                                            contact_conn_id,
-                                            proto::UpdateContacts {
-                                                contacts: vec![updated_contact.clone()],
-                                                remove_contacts: Default::default(),
-                                                incoming_requests: Default::default(),
-                                                remove_incoming_requests: Default::default(),
-                                                outgoing_requests: Default::default(),
-                                                remove_outgoing_requests: Default::default(),
-                                            },
-                                        )
-                                        .trace_err();
+                                        for contact_conn_id in
+                                            pool.user_connection_ids(contact_user_id)
+                                        {
+                                            peer.send(
+                                                contact_conn_id,
+                                                proto::UpdateContacts {
+                                                    contacts: vec![updated_contact.clone()],
+                                                    remove_contacts: Default::default(),
+                                                    incoming_requests: Default::default(),
+                                                    remove_incoming_requests: Default::default(),
+                                                    outgoing_requests: Default::default(),
+                                                    remove_outgoing_requests: Default::default(),
+                                                },
+                                            )
+                                            .trace_err();
+                                        }
                                     }
                                 }
                             }
                         }
-                    }
 
-                    if let Some(live_kit) = live_kit_client.as_ref() {
-                        if delete_live_kit_room {
-                            live_kit.delete_room(live_kit_room).await.trace_err();
+                        if let Some(live_kit) = live_kit_client.as_ref() {
+                            if delete_live_kit_room {
+                                live_kit.delete_room(live_kit_room).await.trace_err();
+                            }
                         }
                     }
                 }
+
+                app_state
+                    .db
+                    .delete_stale_servers(server_id, &app_state.config.zed_environment)
+                    .await
+                    .trace_err();
             }
-        });
+            .instrument(span),
+        );
         Ok(())
     }
 
-    #[cfg(test)]
     pub fn teardown(&self) {
-        self.peer.reset();
+        self.peer.teardown();
         self.connection_pool.lock().reset();
         let _ = self.teardown.send(());
     }
 
+    #[cfg(test)]
+    pub fn reset(&self, id: ServerId) {
+        self.teardown();
+        *self.id.lock() = id;
+        self.peer.reset(id.0 as u32);
+    }
+
+    #[cfg(test)]
+    pub fn id(&self) -> ServerId {
+        *self.id.lock()
+    }
+
     fn add_handler<F, Fut, M>(&mut self, handler: F) -> &mut Self
     where
         F: 'static + Send + Sync + Fn(TypedEnvelope<M>, Session) -> Fut,
@@ -438,7 +489,7 @@ impl Server {
                 });
 
             tracing::info!(%user_id, %login, %connection_id, %address, "connection opened");
-            this.peer.send(connection_id, proto::Hello { peer_id: connection_id.0 })?;
+            this.peer.send(connection_id, proto::Hello { peer_id: Some(connection_id.into()) })?;
             tracing::info!(%user_id, %login, %connection_id, %address, "sent hello message");
 
             if let Some(send_connection_id) = send_connection_id.take() {
@@ -769,7 +820,7 @@ async fn sign_out(
                 .is_user_online(session.user_id)
             {
                 let db = session.db().await;
-                if let Some(room) = db.decline_call(None, session.user_id).await.trace_err() {
+                if let Some(room) = db.decline_call(None, session.user_id).await.trace_err().flatten() {
                     room_updated(&room, &session.peer);
                 }
             }
@@ -973,7 +1024,7 @@ async fn cancel_call(
         let room = session
             .db()
             .await
-            .cancel_call(Some(room_id), session.connection_id, called_user_id)
+            .cancel_call(room_id, session.connection_id, called_user_id)
             .await?;
         room_updated(&room, &session.peer);
     }
@@ -1006,7 +1057,8 @@ async fn decline_call(message: proto::DeclineCall, session: Session) -> Result<(
             .db()
             .await
             .decline_call(Some(room_id), session.user_id)
-            .await?;
+            .await?
+            .ok_or_else(|| anyhow!("failed to decline call"))?;
         room_updated(&room, &session.peer);
     }
 
@@ -1108,12 +1160,18 @@ async fn join_project(
     let collaborators = project
         .collaborators
         .iter()
-        .filter(|collaborator| collaborator.connection_id != session.connection_id.0 as i32)
-        .map(|collaborator| proto::Collaborator {
-            peer_id: collaborator.connection_id as u32,
-            replica_id: collaborator.replica_id.0 as u32,
-            user_id: collaborator.user_id.to_proto(),
+        .map(|collaborator| {
+            let peer_id = proto::PeerId {
+                owner_id: collaborator.connection_server_id.0 as u32,
+                id: collaborator.connection_id as u32,
+            };
+            proto::Collaborator {
+                peer_id: Some(peer_id),
+                replica_id: collaborator.replica_id.0 as u32,
+                user_id: collaborator.user_id.to_proto(),
+            }
         })
+        .filter(|collaborator| collaborator.peer_id != Some(session.connection_id.into()))
         .collect::<Vec<_>>();
     let worktrees = project
         .worktrees
@@ -1130,11 +1188,11 @@ async fn join_project(
         session
             .peer
             .send(
-                ConnectionId(collaborator.peer_id),
+                collaborator.peer_id.unwrap().into(),
                 proto::AddProjectCollaborator {
                     project_id: project_id.to_proto(),
                     collaborator: Some(proto::Collaborator {
-                        peer_id: session.connection_id.0,
+                        peer_id: Some(session.connection_id.into()),
                         replica_id: replica_id.0 as u32,
                         user_id: guest_user_id.to_proto(),
                     }),
@@ -1355,13 +1413,14 @@ where
             .await
             .project_collaborators(project_id, session.connection_id)
             .await?;
-        ConnectionId(
-            collaborators
-                .iter()
-                .find(|collaborator| collaborator.is_host)
-                .ok_or_else(|| anyhow!("host not found"))?
-                .connection_id as u32,
-        )
+        let host = collaborators
+            .iter()
+            .find(|collaborator| collaborator.is_host)
+            .ok_or_else(|| anyhow!("host not found"))?;
+        ConnectionId {
+            owner_id: host.connection_server_id.0 as u32,
+            id: host.connection_id as u32,
+        }
     };
 
     let payload = session
@@ -1389,7 +1448,10 @@ async fn save_buffer(
             .iter()
             .find(|collaborator| collaborator.is_host)
             .ok_or_else(|| anyhow!("host not found"))?;
-        ConnectionId(host.connection_id as u32)
+        ConnectionId {
+            owner_id: host.connection_server_id.0 as u32,
+            id: host.connection_id as u32,
+        }
     };
     let response_payload = session
         .peer
@@ -1401,11 +1463,17 @@ async fn save_buffer(
         .await
         .project_collaborators(project_id, session.connection_id)
         .await?;
-    collaborators
-        .retain(|collaborator| collaborator.connection_id != session.connection_id.0 as i32);
-    let project_connection_ids = collaborators
-        .iter()
-        .map(|collaborator| ConnectionId(collaborator.connection_id as u32));
+    collaborators.retain(|collaborator| {
+        let collaborator_connection = ConnectionId {
+            owner_id: collaborator.connection_server_id.0 as u32,
+            id: collaborator.connection_id as u32,
+        };
+        collaborator_connection != session.connection_id
+    });
+    let project_connection_ids = collaborators.iter().map(|collaborator| ConnectionId {
+        owner_id: collaborator.connection_server_id.0 as u32,
+        id: collaborator.connection_id as u32,
+    });
     broadcast(host_connection_id, project_connection_ids, |conn_id| {
         session
             .peer
@@ -1419,11 +1487,10 @@ async fn create_buffer_for_peer(
     request: proto::CreateBufferForPeer,
     session: Session,
 ) -> Result<()> {
-    session.peer.forward_send(
-        session.connection_id,
-        ConnectionId(request.peer_id),
-        request,
-    )?;
+    let peer_id = request.peer_id.ok_or_else(|| anyhow!("invalid peer id"))?;
+    session
+        .peer
+        .forward_send(session.connection_id, peer_id.into(), request)?;
     Ok(())
 }
 
@@ -1516,7 +1583,10 @@ async fn follow(
     session: Session,
 ) -> Result<()> {
     let project_id = ProjectId::from_proto(request.project_id);
-    let leader_id = ConnectionId(request.leader_id);
+    let leader_id = request
+        .leader_id
+        .ok_or_else(|| anyhow!("invalid leader id"))?
+        .into();
     let follower_id = session.connection_id;
     {
         let project_connection_ids = session
@@ -1536,14 +1606,17 @@ async fn follow(
         .await?;
     response_payload
         .views
-        .retain(|view| view.leader_id != Some(follower_id.0));
+        .retain(|view| view.leader_id != Some(follower_id.into()));
     response.send(response_payload)?;
     Ok(())
 }
 
 async fn unfollow(request: proto::Unfollow, session: Session) -> Result<()> {
     let project_id = ProjectId::from_proto(request.project_id);
-    let leader_id = ConnectionId(request.leader_id);
+    let leader_id = request
+        .leader_id
+        .ok_or_else(|| anyhow!("invalid leader id"))?
+        .into();
     let project_connection_ids = session
         .db()
         .await
@@ -1572,12 +1645,16 @@ async fn update_followers(request: proto::UpdateFollowers, session: Session) ->
         proto::update_followers::Variant::UpdateView(payload) => payload.leader_id,
         proto::update_followers::Variant::UpdateActiveView(payload) => payload.leader_id,
     });
-    for follower_id in &request.follower_ids {
-        let follower_id = ConnectionId(*follower_id);
-        if project_connection_ids.contains(&follower_id) && Some(follower_id.0) != leader_id {
-            session
-                .peer
-                .forward_send(session.connection_id, follower_id, request.clone())?;
+    for follower_peer_id in request.follower_ids.iter().copied() {
+        let follower_connection_id = follower_peer_id.into();
+        if project_connection_ids.contains(&follower_connection_id)
+            && Some(follower_peer_id) != leader_id
+        {
+            session.peer.forward_send(
+                session.connection_id,
+                follower_connection_id,
+                request.clone(),
+            )?;
         }
     }
     Ok(())
@@ -1892,13 +1969,19 @@ fn contact_for_user(
 
 fn room_updated(room: &proto::Room, peer: &Peer) {
     for participant in &room.participants {
-        peer.send(
-            ConnectionId(participant.peer_id),
-            proto::RoomUpdated {
-                room: Some(room.clone()),
-            },
-        )
-        .trace_err();
+        if let Some(peer_id) = participant
+            .peer_id
+            .ok_or_else(|| anyhow!("invalid participant peer id"))
+            .trace_err()
+        {
+            peer.send(
+                peer_id.into(),
+                proto::RoomUpdated {
+                    room: Some(room.clone()),
+                },
+            )
+            .trace_err();
+        }
     }
 }
 
@@ -1943,8 +2026,7 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
     let canceled_calls_to_user_ids;
     let live_kit_room;
     let delete_live_kit_room;
-    {
-        let mut left_room = session.db().await.leave_room(session.connection_id).await?;
+    if let Some(mut left_room) = session.db().await.leave_room(session.connection_id).await? {
         contacts_to_update.insert(session.user_id);
 
         for project in left_room.left_projects.values() {
@@ -1956,6 +2038,8 @@ async fn leave_room_for_session(session: &Session) -> Result<()> {
         canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids);
         live_kit_room = mem::take(&mut left_room.room.live_kit_room);
         delete_live_kit_room = left_room.room.participants.is_empty();
+    } else {
+        return Ok(());
     }
 
     {
@@ -2013,7 +2097,7 @@ fn project_left(project: &db::LeftProject, session: &Session) {
                     *connection_id,
                     proto::RemoveProjectCollaborator {
                         project_id: project.id.to_proto(),
-                        peer_id: session.connection_id.0,
+                        peer_id: Some(session.connection_id.into()),
                     },
                 )
                 .trace_err();

crates/collab_ui/src/collab_titlebar_item.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{contact_notification::ContactNotification, contacts_popover};
 use call::{ActiveCall, ParticipantLocation};
-use client::{Authenticate, ContactEventKind, PeerId, User, UserStore};
+use client::{proto::PeerId, Authenticate, ContactEventKind, User, UserStore};
 use clock::ReplicaId;
 use contacts_popover::ContactsPopover;
 use gpui::{
@@ -474,7 +474,7 @@ impl CollabTitlebarItem {
                         cx.dispatch_action(ToggleFollow(peer_id))
                     })
                     .with_tooltip::<ToggleFollow, _>(
-                        peer_id.0 as usize,
+                        peer_id.as_u64() as usize,
                         if is_followed {
                             format!("Unfollow {}", peer_github_login)
                         } else {
@@ -487,22 +487,24 @@ impl CollabTitlebarItem {
                     .boxed()
             } else if let ParticipantLocation::SharedProject { project_id } = location {
                 let user_id = user.id;
-                MouseEventHandler::<JoinProject>::new(peer_id.0 as usize, cx, move |_, _| content)
-                    .with_cursor_style(CursorStyle::PointingHand)
-                    .on_click(MouseButton::Left, move |_, cx| {
-                        cx.dispatch_action(JoinProject {
-                            project_id,
-                            follow_user_id: user_id,
-                        })
+                MouseEventHandler::<JoinProject>::new(peer_id.as_u64() as usize, cx, move |_, _| {
+                    content
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(MouseButton::Left, move |_, cx| {
+                    cx.dispatch_action(JoinProject {
+                        project_id,
+                        follow_user_id: user_id,
                     })
-                    .with_tooltip::<JoinProject, _>(
-                        peer_id.0 as usize,
-                        format!("Follow {} into external project", peer_github_login),
-                        Some(Box::new(FollowNextCollaborator)),
-                        theme.tooltip.clone(),
-                        cx,
-                    )
-                    .boxed()
+                })
+                .with_tooltip::<JoinProject, _>(
+                    peer_id.as_u64() as usize,
+                    format!("Follow {} into external project", peer_github_login),
+                    Some(Box::new(FollowNextCollaborator)),
+                    theme.tooltip.clone(),
+                    cx,
+                )
+                .boxed()
             } else {
                 content
             }

crates/collab_ui/src/collab_ui.rs 🔗

@@ -54,7 +54,7 @@ pub fn init(app_state: Arc<AppState>, cx: &mut MutableAppContext) {
                         Default::default(),
                         0,
                         project,
-                        app_state.default_item_factory,
+                        app_state.dock_default_item_factory,
                         cx,
                     );
                     (app_state.initialize_workspace)(&mut workspace, &app_state, cx);

crates/collab_ui/src/contact_list.rs 🔗

@@ -2,7 +2,7 @@ use std::{mem, sync::Arc};
 
 use crate::contacts_popover;
 use call::ActiveCall;
-use client::{Contact, PeerId, User, UserStore};
+use client::{proto::PeerId, Contact, User, UserStore};
 use editor::{Cancel, Editor};
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
@@ -465,7 +465,7 @@ impl ContactList {
                     room.remote_participants()
                         .iter()
                         .map(|(peer_id, participant)| StringMatchCandidate {
-                            id: peer_id.0 as usize,
+                            id: peer_id.as_u64() as usize,
                             string: participant.user.github_login.clone(),
                             char_bag: participant.user.github_login.chars().collect(),
                         }),
@@ -479,7 +479,7 @@ impl ContactList {
                 executor.clone(),
             ));
             for mat in matches {
-                let peer_id = PeerId(mat.candidate_id as u32);
+                let peer_id = PeerId::from_u64(mat.candidate_id as u64);
                 let participant = &room.remote_participants()[&peer_id];
                 participant_entries.push(ContactEntry::CallParticipant {
                     user: participant.user.clone(),
@@ -881,75 +881,80 @@ impl ContactList {
         let baseline_offset =
             row.name.text.baseline_offset(font_cache) + (theme.row_height - line_height) / 2.;
 
-        MouseEventHandler::<OpenSharedScreen>::new(peer_id.0 as usize, cx, |mouse_state, _| {
-            let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
-            let row = theme.project_row.style_for(mouse_state, is_selected);
-
-            Flex::row()
-                .with_child(
-                    Stack::new()
-                        .with_child(
-                            Canvas::new(move |bounds, _, cx| {
-                                let start_x = bounds.min_x() + (bounds.width() / 2.)
-                                    - (tree_branch.width / 2.);
-                                let end_x = bounds.max_x();
-                                let start_y = bounds.min_y();
-                                let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.);
+        MouseEventHandler::<OpenSharedScreen>::new(
+            peer_id.as_u64() as usize,
+            cx,
+            |mouse_state, _| {
+                let tree_branch = *tree_branch.style_for(mouse_state, is_selected);
+                let row = theme.project_row.style_for(mouse_state, is_selected);
 
-                                cx.scene.push_quad(gpui::Quad {
-                                    bounds: RectF::from_points(
-                                        vec2f(start_x, start_y),
-                                        vec2f(
-                                            start_x + tree_branch.width,
-                                            if is_last { end_y } else { bounds.max_y() },
+                Flex::row()
+                    .with_child(
+                        Stack::new()
+                            .with_child(
+                                Canvas::new(move |bounds, _, cx| {
+                                    let start_x = bounds.min_x() + (bounds.width() / 2.)
+                                        - (tree_branch.width / 2.);
+                                    let end_x = bounds.max_x();
+                                    let start_y = bounds.min_y();
+                                    let end_y =
+                                        bounds.min_y() + baseline_offset - (cap_height / 2.);
+
+                                    cx.scene.push_quad(gpui::Quad {
+                                        bounds: RectF::from_points(
+                                            vec2f(start_x, start_y),
+                                            vec2f(
+                                                start_x + tree_branch.width,
+                                                if is_last { end_y } else { bounds.max_y() },
+                                            ),
                                         ),
-                                    ),
-                                    background: Some(tree_branch.color),
-                                    border: gpui::Border::default(),
-                                    corner_radius: 0.,
-                                });
-                                cx.scene.push_quad(gpui::Quad {
-                                    bounds: RectF::from_points(
-                                        vec2f(start_x, end_y),
-                                        vec2f(end_x, end_y + tree_branch.width),
-                                    ),
-                                    background: Some(tree_branch.color),
-                                    border: gpui::Border::default(),
-                                    corner_radius: 0.,
-                                });
-                            })
+                                        background: Some(tree_branch.color),
+                                        border: gpui::Border::default(),
+                                        corner_radius: 0.,
+                                    });
+                                    cx.scene.push_quad(gpui::Quad {
+                                        bounds: RectF::from_points(
+                                            vec2f(start_x, end_y),
+                                            vec2f(end_x, end_y + tree_branch.width),
+                                        ),
+                                        background: Some(tree_branch.color),
+                                        border: gpui::Border::default(),
+                                        corner_radius: 0.,
+                                    });
+                                })
+                                .boxed(),
+                            )
+                            .constrained()
+                            .with_width(host_avatar_height)
                             .boxed(),
-                        )
-                        .constrained()
-                        .with_width(host_avatar_height)
-                        .boxed(),
-                )
-                .with_child(
-                    Svg::new("icons/disable_screen_sharing_12.svg")
-                        .with_color(row.icon.color)
-                        .constrained()
-                        .with_width(row.icon.width)
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_style(row.icon.container)
-                        .boxed(),
-                )
-                .with_child(
-                    Label::new("Screen".into(), row.name.text.clone())
-                        .aligned()
-                        .left()
-                        .contained()
-                        .with_style(row.name.container)
-                        .flex(1., false)
-                        .boxed(),
-                )
-                .constrained()
-                .with_height(theme.row_height)
-                .contained()
-                .with_style(row.container)
-                .boxed()
-        })
+                    )
+                    .with_child(
+                        Svg::new("icons/disable_screen_sharing_12.svg")
+                            .with_color(row.icon.color)
+                            .constrained()
+                            .with_width(row.icon.width)
+                            .aligned()
+                            .left()
+                            .contained()
+                            .with_style(row.icon.container)
+                            .boxed(),
+                    )
+                    .with_child(
+                        Label::new("Screen".into(), row.name.text.clone())
+                            .aligned()
+                            .left()
+                            .contained()
+                            .with_style(row.name.container)
+                            .flex(1., false)
+                            .boxed(),
+                    )
+                    .constrained()
+                    .with_height(theme.row_height)
+                    .contained()
+                    .with_style(row.container)
+                    .boxed()
+            },
+        )
         .with_cursor_style(CursorStyle::PointingHand)
         .on_click(MouseButton::Left, move |_, cx| {
             cx.dispatch_action(OpenSharedScreen { peer_id });

crates/db/src/query.rs 🔗

@@ -199,10 +199,10 @@ macro_rules! query {
             use $crate::anyhow::Context;
 
 
-            self.write(|connection| {
+            self.write(move |connection| {
                 let sql_stmt = $crate::sqlez_macros::sql!($($sql)+);
 
-                connection.select_row_bound::<($($arg_type),+), $return_type>(indoc! { $sql })?(($($arg),+))
+                connection.select_row_bound::<($($arg_type),+), $return_type>(sql_stmt)?(($($arg),+))
                     .context(::std::format!(
                         "Error in {}, select_row_bound failed to execute or parse for: {}",
                         ::std::stringify!($id),

crates/diagnostics/src/diagnostics.rs 🔗

@@ -164,7 +164,7 @@ impl ProjectDiagnosticsEditor {
             editor.set_vertical_scroll_margin(5, cx);
             editor
         });
-        cx.subscribe(&editor, |_, _, event, cx| cx.emit(*event))
+        cx.subscribe(&editor, |_, _, event, cx| cx.emit(event.clone()))
             .detach();
 
         let project = project_handle.read(cx);

crates/editor/src/editor.rs 🔗

@@ -84,7 +84,7 @@ use std::{
 pub use sum_tree::Bias;
 use theme::{DiagnosticStyle, Theme};
 use util::{post_inc, ResultExt, TryFutureExt};
-use workspace::{ItemNavHistory, Workspace, WorkspaceId};
+use workspace::{ItemNavHistory, ViewId, Workspace, WorkspaceId};
 
 use crate::git::diff_hunk_to_display;
 
@@ -467,6 +467,7 @@ pub struct Editor {
     keymap_context_layers: BTreeMap<TypeId, gpui::keymap::Context>,
     input_enabled: bool,
     leader_replica_id: Option<u16>,
+    remote_id: Option<ViewId>,
     hover_state: HoverState,
     link_go_to_definition_state: LinkGoToDefinitionState,
     _subscriptions: Vec<Subscription>,
@@ -1108,6 +1109,7 @@ impl Editor {
             keymap_context_layers: Default::default(),
             input_enabled: true,
             leader_replica_id: None,
+            remote_id: None,
             hover_state: Default::default(),
             link_go_to_definition_state: Default::default(),
             _subscriptions: vec![
@@ -2421,7 +2423,7 @@ impl Editor {
                         let all_edits_within_excerpt = buffer.read_with(&cx, |buffer, _| {
                             let excerpt_range = excerpt_range.to_offset(buffer);
                             buffer
-                                .edited_ranges_for_transaction(transaction)
+                                .edited_ranges_for_transaction::<usize>(transaction)
                                 .all(|range| {
                                     excerpt_range.start <= range.start
                                         && excerpt_range.end >= range.end
@@ -5883,25 +5885,36 @@ impl Editor {
     fn on_buffer_event(
         &mut self,
         _: ModelHandle<MultiBuffer>,
-        event: &language::Event,
+        event: &multi_buffer::Event,
         cx: &mut ViewContext<Self>,
     ) {
         match event {
-            language::Event::Edited => {
+            multi_buffer::Event::Edited => {
                 self.refresh_active_diagnostics(cx);
                 self.refresh_code_actions(cx);
                 cx.emit(Event::BufferEdited);
             }
-            language::Event::Reparsed => cx.emit(Event::Reparsed),
-            language::Event::DirtyChanged => cx.emit(Event::DirtyChanged),
-            language::Event::Saved => cx.emit(Event::Saved),
-            language::Event::FileHandleChanged => cx.emit(Event::TitleChanged),
-            language::Event::Reloaded => cx.emit(Event::TitleChanged),
-            language::Event::Closed => cx.emit(Event::Closed),
-            language::Event::DiagnosticsUpdated => {
+            multi_buffer::Event::ExcerptsAdded {
+                buffer,
+                predecessor,
+                excerpts,
+            } => cx.emit(Event::ExcerptsAdded {
+                buffer: buffer.clone(),
+                predecessor: *predecessor,
+                excerpts: excerpts.clone(),
+            }),
+            multi_buffer::Event::ExcerptsRemoved { ids } => {
+                cx.emit(Event::ExcerptsRemoved { ids: ids.clone() })
+            }
+            multi_buffer::Event::Reparsed => cx.emit(Event::Reparsed),
+            multi_buffer::Event::DirtyChanged => cx.emit(Event::DirtyChanged),
+            multi_buffer::Event::Saved => cx.emit(Event::Saved),
+            multi_buffer::Event::FileHandleChanged => cx.emit(Event::TitleChanged),
+            multi_buffer::Event::Reloaded => cx.emit(Event::TitleChanged),
+            multi_buffer::Event::Closed => cx.emit(Event::Closed),
+            multi_buffer::Event::DiagnosticsUpdated => {
                 self.refresh_active_diagnostics(cx);
             }
-            _ => {}
         }
     }
 
@@ -6084,8 +6097,16 @@ impl Deref for EditorSnapshot {
     }
 }
 
-#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Event {
+    ExcerptsAdded {
+        buffer: ModelHandle<Buffer>,
+        predecessor: ExcerptId,
+        excerpts: Vec<(ExcerptId, ExcerptRange<language::Anchor>)>,
+    },
+    ExcerptsRemoved {
+        ids: Vec<ExcerptId>,
+    },
     BufferEdited,
     Edited,
     Reparsed,
@@ -6093,8 +6114,12 @@ pub enum Event {
     DirtyChanged,
     Saved,
     TitleChanged,
-    SelectionsChanged { local: bool },
-    ScrollPositionChanged { local: bool },
+    SelectionsChanged {
+        local: bool,
+    },
+    ScrollPositionChanged {
+        local: bool,
+    },
     Closed,
 }
 

crates/editor/src/editor_tests.rs 🔗

@@ -1,8 +1,7 @@
-use std::{cell::RefCell, rc::Rc, time::Instant};
-
 use drag_and_drop::DragAndDrop;
 use futures::StreamExt;
 use indoc::indoc;
+use std::{cell::RefCell, rc::Rc, time::Instant};
 use unindent::Unindent;
 
 use super::*;
@@ -24,7 +23,7 @@ use util::{
 };
 use workspace::{
     item::{FollowableItem, ItemHandle},
-    NavigationEntry, Pane,
+    NavigationEntry, Pane, ViewId,
 };
 
 #[gpui::test]
@@ -41,7 +40,7 @@ fn test_edit_events(cx: &mut MutableAppContext) {
                     event,
                     Event::Edited | Event::BufferEdited | Event::DirtyChanged
                 ) {
-                    events.borrow_mut().push(("editor1", *event));
+                    events.borrow_mut().push(("editor1", event.clone()));
                 }
             })
             .detach();
@@ -56,7 +55,7 @@ fn test_edit_events(cx: &mut MutableAppContext) {
                     event,
                     Event::Edited | Event::BufferEdited | Event::DirtyChanged
                 ) {
-                    events.borrow_mut().push(("editor2", *event));
+                    events.borrow_mut().push(("editor2", event.clone()));
                 }
             })
             .detach();
@@ -4969,19 +4968,27 @@ fn test_highlighted_ranges(cx: &mut gpui::MutableAppContext) {
 }
 
 #[gpui::test]
-fn test_following(cx: &mut gpui::MutableAppContext) {
-    let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx);
-
-    cx.set_global(Settings::test(cx));
+async fn test_following(cx: &mut gpui::TestAppContext) {
+    Settings::test_async(cx);
+    let fs = FakeFs::new(cx.background());
+    let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
 
-    let (_, leader) = cx.add_window(Default::default(), |cx| build_editor(buffer.clone(), cx));
-    let (_, follower) = cx.add_window(
-        WindowOptions {
-            bounds: WindowBounds::Fixed(RectF::from_points(vec2f(0., 0.), vec2f(10., 80.))),
-            ..Default::default()
-        },
-        |cx| build_editor(buffer.clone(), cx),
-    );
+    let buffer = project.update(cx, |project, cx| {
+        let buffer = project
+            .create_buffer(&sample_text(16, 8, 'a'), None, cx)
+            .unwrap();
+        cx.add_model(|cx| MultiBuffer::singleton(buffer, cx))
+    });
+    let (_, leader) = cx.add_window(|cx| build_editor(buffer.clone(), cx));
+    let (_, follower) = cx.update(|cx| {
+        cx.add_window(
+            WindowOptions {
+                bounds: WindowBounds::Fixed(RectF::from_points(vec2f(0., 0.), vec2f(10., 80.))),
+                ..Default::default()
+            },
+            |cx| build_editor(buffer.clone(), cx),
+        )
+    });
 
     let is_still_following = Rc::new(RefCell::new(true));
     let pending_update = Rc::new(RefCell::new(None));
@@ -5009,44 +5016,50 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
     leader.update(cx, |leader, cx| {
         leader.change_selections(None, cx, |s| s.select_ranges([1..1]));
     });
-    follower.update(cx, |follower, cx| {
-        follower
-            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
-            .unwrap();
+    follower
+        .update(cx, |follower, cx| {
+            follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx)
+        })
+        .await
+        .unwrap();
+    follower.read_with(cx, |follower, cx| {
+        assert_eq!(follower.selections.ranges(cx), vec![1..1]);
     });
-    assert_eq!(follower.read(cx).selections.ranges(cx), vec![1..1]);
     assert_eq!(*is_still_following.borrow(), true);
 
     // Update the scroll position only
     leader.update(cx, |leader, cx| {
         leader.set_scroll_position(vec2f(1.5, 3.5), cx);
     });
-    follower.update(cx, |follower, cx| {
-        follower
-            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
-            .unwrap();
-    });
+    follower
+        .update(cx, |follower, cx| {
+            follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx)
+        })
+        .await
+        .unwrap();
     assert_eq!(
         follower.update(cx, |follower, cx| follower.scroll_position(cx)),
         vec2f(1.5, 3.5)
     );
     assert_eq!(*is_still_following.borrow(), true);
 
-    // Update the selections and scroll position
+    // Update the selections and scroll position. The follower's scroll position is updated
+    // via autoscroll, not via the leader's exact scroll position.
     leader.update(cx, |leader, cx| {
         leader.change_selections(None, cx, |s| s.select_ranges([0..0]));
         leader.request_autoscroll(Autoscroll::newest(), cx);
         leader.set_scroll_position(vec2f(1.5, 3.5), cx);
     });
+    follower
+        .update(cx, |follower, cx| {
+            follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx)
+        })
+        .await
+        .unwrap();
     follower.update(cx, |follower, cx| {
-        let initial_scroll_position = follower.scroll_position(cx);
-        follower
-            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
-            .unwrap();
-        assert_eq!(follower.scroll_position(cx), initial_scroll_position);
-        assert!(follower.scroll_manager.has_autoscroll_request());
+        assert_eq!(follower.scroll_position(cx), vec2f(1.5, 0.0));
+        assert_eq!(follower.selections.ranges(cx), vec![0..0]);
     });
-    assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0]);
     assert_eq!(*is_still_following.borrow(), true);
 
     // Creating a pending selection that precedes another selection
@@ -5054,24 +5067,30 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
         leader.change_selections(None, cx, |s| s.select_ranges([1..1]));
         leader.begin_selection(DisplayPoint::new(0, 0), true, 1, cx);
     });
-    follower.update(cx, |follower, cx| {
-        follower
-            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
-            .unwrap();
+    follower
+        .update(cx, |follower, cx| {
+            follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx)
+        })
+        .await
+        .unwrap();
+    follower.read_with(cx, |follower, cx| {
+        assert_eq!(follower.selections.ranges(cx), vec![0..0, 1..1]);
     });
-    assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..0, 1..1]);
     assert_eq!(*is_still_following.borrow(), true);
 
     // Extend the pending selection so that it surrounds another selection
     leader.update(cx, |leader, cx| {
         leader.extend_selection(DisplayPoint::new(0, 2), 1, cx);
     });
-    follower.update(cx, |follower, cx| {
-        follower
-            .apply_update_proto(pending_update.borrow_mut().take().unwrap(), cx)
-            .unwrap();
+    follower
+        .update(cx, |follower, cx| {
+            follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx)
+        })
+        .await
+        .unwrap();
+    follower.read_with(cx, |follower, cx| {
+        assert_eq!(follower.selections.ranges(cx), vec![0..2]);
     });
-    assert_eq!(follower.read(cx).selections.ranges(cx), vec![0..2]);
 
     // Scrolling locally breaks the follow
     follower.update(cx, |follower, cx| {
@@ -5087,6 +5106,165 @@ fn test_following(cx: &mut gpui::MutableAppContext) {
     assert_eq!(*is_still_following.borrow(), false);
 }
 
+#[gpui::test]
+async fn test_following_with_multiple_excerpts(cx: &mut gpui::TestAppContext) {
+    Settings::test_async(cx);
+    let fs = FakeFs::new(cx.background());
+    let project = Project::test(fs, ["/file.rs".as_ref()], cx).await;
+    let (_, pane) = cx.add_window(|cx| Pane::new(None, cx));
+
+    let leader = pane.update(cx, |_, cx| {
+        let multibuffer = cx.add_model(|_| MultiBuffer::new(0));
+        cx.add_view(|cx| build_editor(multibuffer.clone(), cx))
+    });
+
+    // Start following the editor when it has no excerpts.
+    let mut state_message = leader.update(cx, |leader, cx| leader.to_state_proto(cx));
+    let follower_1 = cx
+        .update(|cx| {
+            Editor::from_state_proto(
+                pane.clone(),
+                project.clone(),
+                ViewId {
+                    creator: Default::default(),
+                    id: 0,
+                },
+                &mut state_message,
+                cx,
+            )
+        })
+        .unwrap()
+        .await
+        .unwrap();
+
+    let update_message = Rc::new(RefCell::new(None));
+    follower_1.update(cx, {
+        let update = update_message.clone();
+        |_, cx| {
+            cx.subscribe(&leader, move |_, leader, event, cx| {
+                leader
+                    .read(cx)
+                    .add_event_to_update_proto(event, &mut *update.borrow_mut(), cx);
+            })
+            .detach();
+        }
+    });
+
+    let (buffer_1, buffer_2) = project.update(cx, |project, cx| {
+        (
+            project
+                .create_buffer("abc\ndef\nghi\njkl\n", None, cx)
+                .unwrap(),
+            project
+                .create_buffer("mno\npqr\nstu\nvwx\n", None, cx)
+                .unwrap(),
+        )
+    });
+
+    // Insert some excerpts.
+    leader.update(cx, |leader, cx| {
+        leader.buffer.update(cx, |multibuffer, cx| {
+            let excerpt_ids = multibuffer.push_excerpts(
+                buffer_1.clone(),
+                [
+                    ExcerptRange {
+                        context: 1..6,
+                        primary: None,
+                    },
+                    ExcerptRange {
+                        context: 12..15,
+                        primary: None,
+                    },
+                    ExcerptRange {
+                        context: 0..3,
+                        primary: None,
+                    },
+                ],
+                cx,
+            );
+            multibuffer.insert_excerpts_after(
+                excerpt_ids[0],
+                buffer_2.clone(),
+                [
+                    ExcerptRange {
+                        context: 8..12,
+                        primary: None,
+                    },
+                    ExcerptRange {
+                        context: 0..6,
+                        primary: None,
+                    },
+                ],
+                cx,
+            );
+        });
+    });
+
+    // Apply the update of adding the excerpts.
+    follower_1
+        .update(cx, |follower, cx| {
+            follower.apply_update_proto(&project, update_message.borrow().clone().unwrap(), cx)
+        })
+        .await
+        .unwrap();
+    assert_eq!(
+        follower_1.read_with(cx, Editor::text),
+        leader.read_with(cx, Editor::text)
+    );
+    update_message.borrow_mut().take();
+
+    // Start following separately after it already has excerpts.
+    let mut state_message = leader.update(cx, |leader, cx| leader.to_state_proto(cx));
+    let follower_2 = cx
+        .update(|cx| {
+            Editor::from_state_proto(
+                pane.clone(),
+                project.clone(),
+                ViewId {
+                    creator: Default::default(),
+                    id: 0,
+                },
+                &mut state_message,
+                cx,
+            )
+        })
+        .unwrap()
+        .await
+        .unwrap();
+    assert_eq!(
+        follower_2.read_with(cx, Editor::text),
+        leader.read_with(cx, Editor::text)
+    );
+
+    // Remove some excerpts.
+    leader.update(cx, |leader, cx| {
+        leader.buffer.update(cx, |multibuffer, cx| {
+            let excerpt_ids = multibuffer.excerpt_ids();
+            multibuffer.remove_excerpts([excerpt_ids[1], excerpt_ids[2]], cx);
+            multibuffer.remove_excerpts([excerpt_ids[0]], cx);
+        });
+    });
+
+    // Apply the update of removing the excerpts.
+    follower_1
+        .update(cx, |follower, cx| {
+            follower.apply_update_proto(&project, update_message.borrow().clone().unwrap(), cx)
+        })
+        .await
+        .unwrap();
+    follower_2
+        .update(cx, |follower, cx| {
+            follower.apply_update_proto(&project, update_message.borrow().clone().unwrap(), cx)
+        })
+        .await
+        .unwrap();
+    update_message.borrow_mut().take();
+    assert_eq!(
+        follower_1.read_with(cx, Editor::text),
+        leader.read_with(cx, Editor::text)
+    );
+}
+
 #[test]
 fn test_combine_syntax_and_fuzzy_match_highlights() {
     let string = "abcdefghijklmnop";

crates/editor/src/items.rs 🔗

@@ -1,9 +1,18 @@
+use crate::{
+    display_map::ToDisplayPoint, link_go_to_definition::hide_link_definition,
+    movement::surrounding_word, persistence::DB, scroll::ScrollAnchor, Anchor, Autoscroll, Editor,
+    Event, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, NavigationData, ToPoint as _,
+    FORMAT_TIMEOUT,
+};
 use anyhow::{anyhow, Context, Result};
+use collections::HashSet;
+use futures::future::try_join_all;
 use futures::FutureExt;
 use gpui::{
     elements::*, geometry::vector::vec2f, AppContext, Entity, ModelHandle, MutableAppContext,
     RenderContext, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle,
 };
+use language::proto::serialize_anchor as serialize_text_anchor;
 use language::{Bias, Buffer, File as _, OffsetRangeExt, Point, SelectionGoal};
 use project::{File, FormatTrigger, Project, ProjectEntryId, ProjectPath};
 use rpc::proto::{self, update_view};
@@ -13,97 +22,136 @@ use std::{
     borrow::Cow,
     cmp::{self, Ordering},
     fmt::Write,
+    iter,
     ops::Range,
     path::{Path, PathBuf},
 };
 use text::Selection;
 use util::{ResultExt, TryFutureExt};
+use workspace::item::FollowableItemHandle;
 use workspace::{
     item::{FollowableItem, Item, ItemEvent, ItemHandle, ProjectItem},
     searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
-    ItemId, ItemNavHistory, Pane, StatusItemView, ToolbarItemLocation, Workspace, WorkspaceId,
-};
-
-use crate::{
-    display_map::ToDisplayPoint, link_go_to_definition::hide_link_definition,
-    movement::surrounding_word, persistence::DB, scroll::ScrollAnchor, Anchor, Autoscroll, Editor,
-    Event, ExcerptId, MultiBuffer, MultiBufferSnapshot, NavigationData, ToPoint as _,
-    FORMAT_TIMEOUT,
+    ItemId, ItemNavHistory, Pane, StatusItemView, ToolbarItemLocation, ViewId, Workspace,
+    WorkspaceId,
 };
 
 pub const MAX_TAB_TITLE_LEN: usize = 24;
 
 impl FollowableItem for Editor {
+    fn remote_id(&self) -> Option<ViewId> {
+        self.remote_id
+    }
+
     fn from_state_proto(
         pane: ViewHandle<workspace::Pane>,
         project: ModelHandle<Project>,
+        remote_id: ViewId,
         state: &mut Option<proto::view::Variant>,
         cx: &mut MutableAppContext,
     ) -> Option<Task<Result<ViewHandle<Self>>>> {
-        let state = if matches!(state, Some(proto::view::Variant::Editor(_))) {
-            if let Some(proto::view::Variant::Editor(state)) = state.take() {
-                state
-            } else {
-                unreachable!()
-            }
-        } else {
-            return None;
-        };
-
-        let buffer = project.update(cx, |project, cx| {
-            project.open_buffer_by_id(state.buffer_id, cx)
+        let Some(proto::view::Variant::Editor(_)) = state else { return None };
+        let Some(proto::view::Variant::Editor(state)) = state.take() else { unreachable!() };
+
+        let client = project.read(cx).client();
+        let replica_id = project.read(cx).replica_id();
+        let buffer_ids = state
+            .excerpts
+            .iter()
+            .map(|excerpt| excerpt.buffer_id)
+            .collect::<HashSet<_>>();
+        let buffers = project.update(cx, |project, cx| {
+            buffer_ids
+                .iter()
+                .map(|id| project.open_buffer_by_id(*id, cx))
+                .collect::<Vec<_>>()
         });
+
         Some(cx.spawn(|mut cx| async move {
-            let buffer = buffer.await?;
-            let editor = pane
-                .read_with(&cx, |pane, cx| {
-                    pane.items_of_type::<Self>().find(|editor| {
-                        editor.read(cx).buffer.read(cx).as_singleton().as_ref() == Some(&buffer)
-                    })
+            let mut buffers = futures::future::try_join_all(buffers).await?;
+            let editor = pane.read_with(&cx, |pane, cx| {
+                let mut editors = pane.items_of_type::<Self>();
+                editors.find(|editor| {
+                    editor.remote_id(&client, cx) == Some(remote_id)
+                        || state.singleton
+                            && buffers.len() == 1
+                            && editor.read(cx).buffer.read(cx).as_singleton().as_ref()
+                                == Some(&buffers[0])
                 })
-                .unwrap_or_else(|| {
-                    pane.update(&mut cx, |_, cx| {
-                        cx.add_view(|cx| Editor::for_buffer(buffer, Some(project), cx))
-                    })
-                });
+            });
+
+            let editor = editor.unwrap_or_else(|| {
+                pane.update(&mut cx, |_, cx| {
+                    let multibuffer = cx.add_model(|cx| {
+                        let mut multibuffer;
+                        if state.singleton && buffers.len() == 1 {
+                            multibuffer = MultiBuffer::singleton(buffers.pop().unwrap(), cx)
+                        } else {
+                            multibuffer = MultiBuffer::new(replica_id);
+                            let mut excerpts = state.excerpts.into_iter().peekable();
+                            while let Some(excerpt) = excerpts.peek() {
+                                let buffer_id = excerpt.buffer_id;
+                                let buffer_excerpts = iter::from_fn(|| {
+                                    let excerpt = excerpts.peek()?;
+                                    (excerpt.buffer_id == buffer_id)
+                                        .then(|| excerpts.next().unwrap())
+                                });
+                                let buffer =
+                                    buffers.iter().find(|b| b.read(cx).remote_id() == buffer_id);
+                                if let Some(buffer) = buffer {
+                                    multibuffer.push_excerpts(
+                                        buffer.clone(),
+                                        buffer_excerpts.filter_map(deserialize_excerpt_range),
+                                        cx,
+                                    );
+                                }
+                            }
+                        };
+
+                        if let Some(title) = &state.title {
+                            multibuffer = multibuffer.with_title(title.clone())
+                        }
+
+                        multibuffer
+                    });
+
+                    cx.add_view(|cx| Editor::for_multibuffer(multibuffer, Some(project), cx))
+                })
+            });
+
             editor.update(&mut cx, |editor, cx| {
-                let excerpt_id;
-                let buffer_id;
-                {
-                    let buffer = editor.buffer.read(cx).read(cx);
-                    let singleton = buffer.as_singleton().unwrap();
-                    excerpt_id = singleton.0.clone();
-                    buffer_id = singleton.1;
-                }
+                editor.remote_id = Some(remote_id);
+                let buffer = editor.buffer.read(cx).read(cx);
                 let selections = state
                     .selections
                     .into_iter()
                     .map(|selection| {
-                        deserialize_selection(&excerpt_id, buffer_id, selection)
+                        deserialize_selection(&buffer, selection)
                             .ok_or_else(|| anyhow!("invalid selection"))
                     })
                     .collect::<Result<Vec<_>>>()?;
+                let scroll_top_anchor = state
+                    .scroll_top_anchor
+                    .and_then(|anchor| deserialize_anchor(&buffer, anchor));
+                drop(buffer);
+
                 if !selections.is_empty() {
                     editor.set_selections_from_remote(selections, cx);
                 }
 
-                if let Some(anchor) = state.scroll_top_anchor {
+                if let Some(scroll_top_anchor) = scroll_top_anchor {
                     editor.set_scroll_anchor_remote(
                         ScrollAnchor {
-                            top_anchor: Anchor {
-                                buffer_id: Some(state.buffer_id as usize),
-                                excerpt_id,
-                                text_anchor: language::proto::deserialize_anchor(anchor)
-                                    .ok_or_else(|| anyhow!("invalid scroll top"))?,
-                            },
+                            top_anchor: scroll_top_anchor,
                             offset: vec2f(state.scroll_x, state.scroll_y),
                         },
                         cx,
                     );
                 }
 
-                Ok::<_, anyhow::Error>(())
+                anyhow::Ok(())
             })?;
+
             Ok(editor)
         }))
     }
@@ -134,13 +182,32 @@ impl FollowableItem for Editor {
     }
 
     fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant> {
-        let buffer_id = self.buffer.read(cx).as_singleton()?.read(cx).remote_id();
+        let buffer = self.buffer.read(cx);
         let scroll_anchor = self.scroll_manager.anchor();
+        let excerpts = buffer
+            .read(cx)
+            .excerpts()
+            .map(|(id, buffer, range)| proto::Excerpt {
+                id: id.to_proto(),
+                buffer_id: buffer.remote_id(),
+                context_start: Some(serialize_text_anchor(&range.context.start)),
+                context_end: Some(serialize_text_anchor(&range.context.end)),
+                primary_start: range
+                    .primary
+                    .as_ref()
+                    .map(|range| serialize_text_anchor(&range.start)),
+                primary_end: range
+                    .primary
+                    .as_ref()
+                    .map(|range| serialize_text_anchor(&range.end)),
+            })
+            .collect();
+
         Some(proto::view::Variant::Editor(proto::view::Editor {
-            buffer_id,
-            scroll_top_anchor: Some(language::proto::serialize_anchor(
-                &scroll_anchor.top_anchor.text_anchor,
-            )),
+            singleton: buffer.is_singleton(),
+            title: (!buffer.is_singleton()).then(|| buffer.title(cx).into()),
+            excerpts,
+            scroll_top_anchor: Some(serialize_anchor(&scroll_anchor.top_anchor)),
             scroll_x: scroll_anchor.offset.x(),
             scroll_y: scroll_anchor.offset.y(),
             selections: self
@@ -156,18 +223,43 @@ impl FollowableItem for Editor {
         &self,
         event: &Self::Event,
         update: &mut Option<proto::update_view::Variant>,
-        _: &AppContext,
+        cx: &AppContext,
     ) -> bool {
         let update =
             update.get_or_insert_with(|| proto::update_view::Variant::Editor(Default::default()));
 
         match update {
             proto::update_view::Variant::Editor(update) => match event {
+                Event::ExcerptsAdded {
+                    buffer,
+                    predecessor,
+                    excerpts,
+                } => {
+                    let buffer_id = buffer.read(cx).remote_id();
+                    let mut excerpts = excerpts.iter();
+                    if let Some((id, range)) = excerpts.next() {
+                        update.inserted_excerpts.push(proto::ExcerptInsertion {
+                            previous_excerpt_id: Some(predecessor.to_proto()),
+                            excerpt: serialize_excerpt(buffer_id, id, range),
+                        });
+                        update.inserted_excerpts.extend(excerpts.map(|(id, range)| {
+                            proto::ExcerptInsertion {
+                                previous_excerpt_id: None,
+                                excerpt: serialize_excerpt(buffer_id, id, range),
+                            }
+                        }))
+                    }
+                    true
+                }
+                Event::ExcerptsRemoved { ids } => {
+                    update
+                        .deleted_excerpts
+                        .extend(ids.iter().map(ExcerptId::to_proto));
+                    true
+                }
                 Event::ScrollPositionChanged { .. } => {
                     let scroll_anchor = self.scroll_manager.anchor();
-                    update.scroll_top_anchor = Some(language::proto::serialize_anchor(
-                        &scroll_anchor.top_anchor.text_anchor,
-                    ));
+                    update.scroll_top_anchor = Some(serialize_anchor(&scroll_anchor.top_anchor));
                     update.scroll_x = scroll_anchor.offset.x();
                     update.scroll_y = scroll_anchor.offset.y();
                     true
@@ -189,45 +281,98 @@ impl FollowableItem for Editor {
 
     fn apply_update_proto(
         &mut self,
+        project: &ModelHandle<Project>,
         message: update_view::Variant,
         cx: &mut ViewContext<Self>,
-    ) -> Result<()> {
-        match message {
-            update_view::Variant::Editor(message) => {
-                let buffer = self.buffer.read(cx);
-                let buffer = buffer.read(cx);
-                let (excerpt_id, buffer_id, _) = buffer.as_singleton().unwrap();
-                let excerpt_id = excerpt_id.clone();
-                drop(buffer);
+    ) -> Task<Result<()>> {
+        let update_view::Variant::Editor(message) = message;
+        let multibuffer = self.buffer.read(cx);
+        let multibuffer = multibuffer.read(cx);
+
+        let buffer_ids = message
+            .inserted_excerpts
+            .iter()
+            .filter_map(|insertion| Some(insertion.excerpt.as_ref()?.buffer_id))
+            .collect::<HashSet<_>>();
+
+        let mut removals = message
+            .deleted_excerpts
+            .into_iter()
+            .map(ExcerptId::from_proto)
+            .collect::<Vec<_>>();
+        removals.sort_by(|a, b| a.cmp(&b, &multibuffer));
 
-                let selections = message
-                    .selections
-                    .into_iter()
-                    .filter_map(|selection| {
-                        deserialize_selection(&excerpt_id, buffer_id, selection)
-                    })
-                    .collect::<Vec<_>>();
+        let selections = message
+            .selections
+            .into_iter()
+            .filter_map(|selection| deserialize_selection(&multibuffer, selection))
+            .collect::<Vec<_>>();
+        let scroll_top_anchor = message
+            .scroll_top_anchor
+            .and_then(|anchor| deserialize_anchor(&multibuffer, anchor));
+        drop(multibuffer);
+
+        let buffers = project.update(cx, |project, cx| {
+            buffer_ids
+                .into_iter()
+                .map(|id| project.open_buffer_by_id(id, cx))
+                .collect::<Vec<_>>()
+        });
+
+        let project = project.clone();
+        cx.spawn(|this, mut cx| async move {
+            let _buffers = try_join_all(buffers).await?;
+            this.update(&mut cx, |this, cx| {
+                this.buffer.update(cx, |multibuffer, cx| {
+                    let mut insertions = message.inserted_excerpts.into_iter().peekable();
+                    while let Some(insertion) = insertions.next() {
+                        let Some(excerpt) = insertion.excerpt else { continue };
+                        let Some(previous_excerpt_id) = insertion.previous_excerpt_id else { continue };
+                        let buffer_id = excerpt.buffer_id;
+                        let Some(buffer) = project.read(cx).buffer_for_id(buffer_id, cx) else { continue };
+
+                        let adjacent_excerpts = iter::from_fn(|| {
+                            let insertion = insertions.peek()?;
+                            if insertion.previous_excerpt_id.is_none()
+                                && insertion.excerpt.as_ref()?.buffer_id == buffer_id
+                            {
+                                insertions.next()?.excerpt
+                            } else {
+                                None
+                            }
+                        });
+
+                        multibuffer.insert_excerpts_with_ids_after(
+                            ExcerptId::from_proto(previous_excerpt_id),
+                            buffer,
+                            [excerpt]
+                                .into_iter()
+                                .chain(adjacent_excerpts)
+                                .filter_map(|excerpt| {
+                                    Some((
+                                        ExcerptId::from_proto(excerpt.id),
+                                        deserialize_excerpt_range(excerpt)?,
+                                    ))
+                                }),
+                            cx,
+                        );
+                    }
+
+                    multibuffer.remove_excerpts(removals, cx);
+                });
 
                 if !selections.is_empty() {
-                    self.set_selections_from_remote(selections, cx);
-                    self.request_autoscroll_remotely(Autoscroll::newest(), cx);
-                } else if let Some(anchor) = message.scroll_top_anchor {
-                    self.set_scroll_anchor_remote(
-                        ScrollAnchor {
-                            top_anchor: Anchor {
-                                buffer_id: Some(buffer_id),
-                                excerpt_id,
-                                text_anchor: language::proto::deserialize_anchor(anchor)
-                                    .ok_or_else(|| anyhow!("invalid scroll top"))?,
-                            },
-                            offset: vec2f(message.scroll_x, message.scroll_y),
-                        },
-                        cx,
-                    );
+                    this.set_selections_from_remote(selections, cx);
+                    this.request_autoscroll_remotely(Autoscroll::newest(), cx);
+                } else if let Some(anchor) = scroll_top_anchor {
+                    this.set_scroll_anchor_remote(ScrollAnchor {
+                        top_anchor: anchor,
+                        offset: vec2f(message.scroll_x, message.scroll_y)
+                    }, cx);
                 }
-            }
-        }
-        Ok(())
+            });
+            Ok(())
+        })
     }
 
     fn should_unfollow_on_event(event: &Self::Event, _: &AppContext) -> bool {
@@ -240,41 +385,82 @@ impl FollowableItem for Editor {
     }
 }
 
+fn serialize_excerpt(
+    buffer_id: u64,
+    id: &ExcerptId,
+    range: &ExcerptRange<language::Anchor>,
+) -> Option<proto::Excerpt> {
+    Some(proto::Excerpt {
+        id: id.to_proto(),
+        buffer_id,
+        context_start: Some(serialize_text_anchor(&range.context.start)),
+        context_end: Some(serialize_text_anchor(&range.context.end)),
+        primary_start: range
+            .primary
+            .as_ref()
+            .map(|r| serialize_text_anchor(&r.start)),
+        primary_end: range
+            .primary
+            .as_ref()
+            .map(|r| serialize_text_anchor(&r.end)),
+    })
+}
+
 fn serialize_selection(selection: &Selection<Anchor>) -> proto::Selection {
     proto::Selection {
         id: selection.id as u64,
-        start: Some(language::proto::serialize_anchor(
-            &selection.start.text_anchor,
-        )),
-        end: Some(language::proto::serialize_anchor(
-            &selection.end.text_anchor,
-        )),
+        start: Some(serialize_anchor(&selection.start)),
+        end: Some(serialize_anchor(&selection.end)),
         reversed: selection.reversed,
     }
 }
 
+fn serialize_anchor(anchor: &Anchor) -> proto::EditorAnchor {
+    proto::EditorAnchor {
+        excerpt_id: anchor.excerpt_id.to_proto(),
+        anchor: Some(serialize_text_anchor(&anchor.text_anchor)),
+    }
+}
+
+fn deserialize_excerpt_range(excerpt: proto::Excerpt) -> Option<ExcerptRange<language::Anchor>> {
+    let context = {
+        let start = language::proto::deserialize_anchor(excerpt.context_start?)?;
+        let end = language::proto::deserialize_anchor(excerpt.context_end?)?;
+        start..end
+    };
+    let primary = excerpt
+        .primary_start
+        .zip(excerpt.primary_end)
+        .and_then(|(start, end)| {
+            let start = language::proto::deserialize_anchor(start)?;
+            let end = language::proto::deserialize_anchor(end)?;
+            Some(start..end)
+        });
+    Some(ExcerptRange { context, primary })
+}
+
 fn deserialize_selection(
-    excerpt_id: &ExcerptId,
-    buffer_id: usize,
+    buffer: &MultiBufferSnapshot,
     selection: proto::Selection,
 ) -> Option<Selection<Anchor>> {
     Some(Selection {
         id: selection.id as usize,
-        start: Anchor {
-            buffer_id: Some(buffer_id),
-            excerpt_id: excerpt_id.clone(),
-            text_anchor: language::proto::deserialize_anchor(selection.start?)?,
-        },
-        end: Anchor {
-            buffer_id: Some(buffer_id),
-            excerpt_id: excerpt_id.clone(),
-            text_anchor: language::proto::deserialize_anchor(selection.end?)?,
-        },
+        start: deserialize_anchor(buffer, selection.start?)?,
+        end: deserialize_anchor(buffer, selection.end?)?,
         reversed: selection.reversed,
         goal: SelectionGoal::None,
     })
 }
 
+fn deserialize_anchor(buffer: &MultiBufferSnapshot, anchor: proto::EditorAnchor) -> Option<Anchor> {
+    let excerpt_id = ExcerptId::from_proto(anchor.excerpt_id);
+    Some(Anchor {
+        excerpt_id,
+        text_anchor: language::proto::deserialize_anchor(anchor.anchor?)?,
+        buffer_id: buffer.buffer_id_for_excerpt(excerpt_id),
+    })
+}
+
 impl Item for Editor {
     fn navigate(&mut self, data: Box<dyn std::any::Any>, cx: &mut ViewContext<Self>) -> bool {
         if let Ok(data) = data.downcast::<NavigationData>() {

crates/editor/src/multi_buffer.rs 🔗

@@ -9,9 +9,9 @@ use gpui::{AppContext, Entity, ModelContext, ModelHandle, Task};
 pub use language::Completion;
 use language::{
     char_kind, AutoindentMode, Buffer, BufferChunks, BufferSnapshot, CharKind, Chunk, CursorShape,
-    DiagnosticEntry, Event, File, IndentSize, Language, OffsetRangeExt, OffsetUtf16, Outline,
-    OutlineItem, Point, PointUtf16, Selection, TextDimension, ToOffset as _, ToOffsetUtf16 as _,
-    ToPoint as _, ToPointUtf16 as _, TransactionId, Unclipped,
+    DiagnosticEntry, File, IndentSize, Language, OffsetRangeExt, OffsetUtf16, Outline, OutlineItem,
+    Point, PointUtf16, Selection, TextDimension, ToOffset as _, ToOffsetUtf16 as _, ToPoint as _,
+    ToPointUtf16 as _, TransactionId, Unclipped,
 };
 use smallvec::SmallVec;
 use std::{
@@ -50,6 +50,26 @@ pub struct MultiBuffer {
     title: Option<String>,
 }
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum Event {
+    ExcerptsAdded {
+        buffer: ModelHandle<Buffer>,
+        predecessor: ExcerptId,
+        excerpts: Vec<(ExcerptId, ExcerptRange<language::Anchor>)>,
+    },
+    ExcerptsRemoved {
+        ids: Vec<ExcerptId>,
+    },
+    Edited,
+    Reloaded,
+    Reparsed,
+    Saved,
+    FileHandleChanged,
+    Closed,
+    DirtyChanged,
+    DiagnosticsUpdated,
+}
+
 #[derive(Clone)]
 struct History {
     next_transaction_id: TransactionId,
@@ -833,6 +853,30 @@ impl MultiBuffer {
     ) -> Vec<ExcerptId>
     where
         O: text::ToOffset,
+    {
+        let mut ids = Vec::new();
+        let mut next_excerpt_id = self.next_excerpt_id;
+        self.insert_excerpts_with_ids_after(
+            prev_excerpt_id,
+            buffer,
+            ranges.into_iter().map(|range| {
+                let id = ExcerptId(post_inc(&mut next_excerpt_id));
+                ids.push(id);
+                (id, range)
+            }),
+            cx,
+        );
+        ids
+    }
+
+    pub fn insert_excerpts_with_ids_after<O>(
+        &mut self,
+        prev_excerpt_id: ExcerptId,
+        buffer: ModelHandle<Buffer>,
+        ranges: impl IntoIterator<Item = (ExcerptId, ExcerptRange<O>)>,
+        cx: &mut ModelContext<Self>,
+    ) where
+        O: text::ToOffset,
     {
         assert_eq!(self.history.transaction_depth, 0);
         let mut ranges = ranges.into_iter().peekable();
@@ -858,7 +902,7 @@ impl MultiBuffer {
                 cx.observe(&buffer, |_, _, cx| cx.notify()),
                 cx.subscribe(&buffer, Self::on_buffer_event),
             ],
-            buffer,
+            buffer: buffer.clone(),
         });
 
         let mut snapshot = self.snapshot.borrow_mut();
@@ -883,8 +927,8 @@ impl MultiBuffer {
             Locator::max()
         };
 
-        let mut ids = Vec::new();
-        while let Some(range) = ranges.next() {
+        let mut excerpts = Vec::new();
+        while let Some((id, range)) = ranges.next() {
             let locator = Locator::between(&prev_locator, &next_locator);
             if let Err(ix) = buffer_state.excerpts.binary_search(&locator) {
                 buffer_state.excerpts.insert(ix, locator.clone());
@@ -897,7 +941,10 @@ impl MultiBuffer {
                         ..buffer_snapshot.anchor_after(&primary.end)
                 }),
             };
-            let id = ExcerptId(post_inc(&mut self.next_excerpt_id));
+            if id.0 >= self.next_excerpt_id {
+                self.next_excerpt_id = id.0 + 1;
+            }
+            excerpts.push((id, range.clone()));
             let excerpt = Excerpt::new(
                 id,
                 locator.clone(),
@@ -909,7 +956,6 @@ impl MultiBuffer {
             new_excerpts.push(excerpt, &());
             prev_locator = locator.clone();
             new_excerpt_ids.push(ExcerptIdMapping { id, locator }, &());
-            ids.push(id);
         }
 
         let edit_end = new_excerpts.summary().text.len;
@@ -929,12 +975,17 @@ impl MultiBuffer {
             new: edit_start..edit_end,
         }]);
         cx.emit(Event::Edited);
+        cx.emit(Event::ExcerptsAdded {
+            buffer,
+            predecessor: prev_excerpt_id,
+            excerpts,
+        });
         cx.notify();
-        ids
     }
 
     pub fn clear(&mut self, cx: &mut ModelContext<Self>) {
         self.sync(cx);
+        let ids = self.excerpt_ids();
         self.buffers.borrow_mut().clear();
         let mut snapshot = self.snapshot.borrow_mut();
         let prev_len = snapshot.len();
@@ -948,6 +999,7 @@ impl MultiBuffer {
             new: 0..0,
         }]);
         cx.emit(Event::Edited);
+        cx.emit(Event::ExcerptsRemoved { ids });
         cx.notify();
     }
 
@@ -1071,12 +1123,14 @@ impl MultiBuffer {
         cx: &mut ModelContext<Self>,
     ) {
         self.sync(cx);
+        let ids = excerpt_ids.into_iter().collect::<Vec<_>>();
+
         let mut buffers = self.buffers.borrow_mut();
         let mut snapshot = self.snapshot.borrow_mut();
         let mut new_excerpts = SumTree::new();
         let mut cursor = snapshot.excerpts.cursor::<(Option<&Locator>, usize)>();
         let mut edits = Vec::new();
-        let mut excerpt_ids = excerpt_ids.into_iter().peekable();
+        let mut excerpt_ids = ids.iter().copied().peekable();
 
         while let Some(excerpt_id) = excerpt_ids.next() {
             // Seek to the next excerpt to remove, preserving any preceding excerpts.
@@ -1143,6 +1197,7 @@ impl MultiBuffer {
 
         self.subscriptions.publish_mut(edits);
         cx.emit(Event::Edited);
+        cx.emit(Event::ExcerptsRemoved { ids });
         cx.notify();
     }
 
@@ -1165,10 +1220,22 @@ impl MultiBuffer {
     fn on_buffer_event(
         &mut self,
         _: ModelHandle<Buffer>,
-        event: &Event,
+        event: &language::Event,
         cx: &mut ModelContext<Self>,
     ) {
-        cx.emit(event.clone());
+        cx.emit(match event {
+            language::Event::Edited => Event::Edited,
+            language::Event::DirtyChanged => Event::DirtyChanged,
+            language::Event::Saved => Event::Saved,
+            language::Event::FileHandleChanged => Event::FileHandleChanged,
+            language::Event::Reloaded => Event::Reloaded,
+            language::Event::Reparsed => Event::Reparsed,
+            language::Event::DiagnosticsUpdated => Event::DiagnosticsUpdated,
+            language::Event::Closed => Event::Closed,
+
+            //
+            language::Event::Operation(_) => return,
+        });
     }
 
     pub fn all_buffers(&self) -> HashSet<ModelHandle<Buffer>> {
@@ -1604,7 +1671,7 @@ impl MultiBuffer {
 }
 
 impl Entity for MultiBuffer {
-    type Event = language::Event;
+    type Event = Event;
 }
 
 impl MultiBufferSnapshot {
@@ -2450,6 +2517,14 @@ impl MultiBufferSnapshot {
         }
     }
 
+    pub fn excerpts(
+        &self,
+    ) -> impl Iterator<Item = (ExcerptId, &BufferSnapshot, ExcerptRange<text::Anchor>)> {
+        self.excerpts
+            .iter()
+            .map(|excerpt| (excerpt.id, &excerpt.buffer, excerpt.range.clone()))
+    }
+
     pub fn excerpt_boundaries_in_range<R, T>(
         &self,
         range: R,
@@ -2746,6 +2821,10 @@ impl MultiBufferSnapshot {
         }
     }
 
+    pub fn buffer_id_for_excerpt(&self, excerpt_id: ExcerptId) -> Option<usize> {
+        Some(self.excerpt(excerpt_id)?.buffer_id)
+    }
+
     fn excerpt<'a>(&'a self, excerpt_id: ExcerptId) -> Option<&'a Excerpt> {
         let mut cursor = self.excerpts.cursor::<Option<&Locator>>();
         let locator = self.excerpt_locator_for_id(excerpt_id);
@@ -3080,6 +3159,14 @@ impl ExcerptId {
         Self(usize::MAX)
     }
 
+    pub fn to_proto(&self) -> u64 {
+        self.0 as _
+    }
+
+    pub fn from_proto(proto: u64) -> Self {
+        Self(proto as _)
+    }
+
     pub fn cmp(&self, other: &Self, snapshot: &MultiBufferSnapshot) -> cmp::Ordering {
         let a = snapshot.excerpt_locator_for_id(*self);
         let b = snapshot.excerpt_locator_for_id(*other);
@@ -3468,7 +3555,7 @@ mod tests {
     use util::test::sample_text;
 
     #[gpui::test]
-    fn test_singleton_multibuffer(cx: &mut MutableAppContext) {
+    fn test_singleton(cx: &mut MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(6, 6, 'a'), cx));
         let multibuffer = cx.add_model(|cx| MultiBuffer::singleton(buffer.clone(), cx));
 
@@ -3495,7 +3582,7 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_remote_multibuffer(cx: &mut MutableAppContext) {
+    fn test_remote(cx: &mut MutableAppContext) {
         let host_buffer = cx.add_model(|cx| Buffer::new(0, "a", cx));
         let guest_buffer = cx.add_model(|cx| {
             let state = host_buffer.read(cx).to_proto();
@@ -3526,7 +3613,7 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_excerpt_buffer(cx: &mut MutableAppContext) {
+    fn test_excerpt_boundaries_and_clipping(cx: &mut MutableAppContext) {
         let buffer_1 = cx.add_model(|cx| Buffer::new(0, sample_text(6, 6, 'a'), cx));
         let buffer_2 = cx.add_model(|cx| Buffer::new(0, sample_text(6, 6, 'g'), cx));
         let multibuffer = cx.add_model(|_| MultiBuffer::new(0));
@@ -3535,7 +3622,9 @@ mod tests {
         multibuffer.update(cx, |_, cx| {
             let events = events.clone();
             cx.subscribe(&multibuffer, move |_, _, event, _| {
-                events.borrow_mut().push(event.clone())
+                if let Event::Edited = event {
+                    events.borrow_mut().push(event.clone())
+                }
             })
             .detach();
         });
@@ -3748,7 +3837,84 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_excerpts_with_context_lines(cx: &mut MutableAppContext) {
+    fn test_excerpt_events(cx: &mut MutableAppContext) {
+        let buffer_1 = cx.add_model(|cx| Buffer::new(0, sample_text(10, 3, 'a'), cx));
+        let buffer_2 = cx.add_model(|cx| Buffer::new(0, sample_text(10, 3, 'm'), cx));
+
+        let leader_multibuffer = cx.add_model(|_| MultiBuffer::new(0));
+        let follower_multibuffer = cx.add_model(|_| MultiBuffer::new(0));
+
+        follower_multibuffer.update(cx, |_, cx| {
+            cx.subscribe(&leader_multibuffer, |follower, _, event, cx| {
+                match event.clone() {
+                    Event::ExcerptsAdded {
+                        buffer,
+                        predecessor,
+                        excerpts,
+                    } => follower.insert_excerpts_with_ids_after(predecessor, buffer, excerpts, cx),
+                    Event::ExcerptsRemoved { ids } => follower.remove_excerpts(ids, cx),
+                    _ => {}
+                }
+            })
+            .detach();
+        });
+
+        leader_multibuffer.update(cx, |leader, cx| {
+            leader.push_excerpts(
+                buffer_1.clone(),
+                [
+                    ExcerptRange {
+                        context: 0..8,
+                        primary: None,
+                    },
+                    ExcerptRange {
+                        context: 12..16,
+                        primary: None,
+                    },
+                ],
+                cx,
+            );
+            leader.insert_excerpts_after(
+                leader.excerpt_ids()[0],
+                buffer_2.clone(),
+                [
+                    ExcerptRange {
+                        context: 0..5,
+                        primary: None,
+                    },
+                    ExcerptRange {
+                        context: 10..15,
+                        primary: None,
+                    },
+                ],
+                cx,
+            )
+        });
+        assert_eq!(
+            leader_multibuffer.read(cx).snapshot(cx).text(),
+            follower_multibuffer.read(cx).snapshot(cx).text(),
+        );
+
+        leader_multibuffer.update(cx, |leader, cx| {
+            let excerpt_ids = leader.excerpt_ids();
+            leader.remove_excerpts([excerpt_ids[1], excerpt_ids[3]], cx);
+        });
+        assert_eq!(
+            leader_multibuffer.read(cx).snapshot(cx).text(),
+            follower_multibuffer.read(cx).snapshot(cx).text(),
+        );
+
+        leader_multibuffer.update(cx, |leader, cx| {
+            leader.clear(cx);
+        });
+        assert_eq!(
+            leader_multibuffer.read(cx).snapshot(cx).text(),
+            follower_multibuffer.read(cx).snapshot(cx).text(),
+        );
+    }
+
+    #[gpui::test]
+    fn test_push_excerpts_with_context_lines(cx: &mut MutableAppContext) {
         let buffer = cx.add_model(|cx| Buffer::new(0, sample_text(20, 3, 'a'), cx));
         let multibuffer = cx.add_model(|_| MultiBuffer::new(0));
         let anchor_ranges = multibuffer.update(cx, |multibuffer, cx| {
@@ -3784,7 +3950,7 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_empty_excerpt_buffer(cx: &mut MutableAppContext) {
+    fn test_empty_multibuffer(cx: &mut MutableAppContext) {
         let multibuffer = cx.add_model(|_| MultiBuffer::new(0));
 
         let snapshot = multibuffer.read(cx).snapshot(cx);
@@ -3872,9 +4038,7 @@ mod tests {
     }
 
     #[gpui::test]
-    fn test_multibuffer_resolving_anchors_after_replacing_their_excerpts(
-        cx: &mut MutableAppContext,
-    ) {
+    fn test_resolving_anchors_after_replacing_their_excerpts(cx: &mut MutableAppContext) {
         let buffer_1 = cx.add_model(|cx| Buffer::new(0, "abcd", cx));
         let buffer_2 = cx.add_model(|cx| Buffer::new(0, "ABCDEFGHIJKLMNOP", cx));
         let multibuffer = cx.add_model(|_| MultiBuffer::new(0));

crates/language/src/proto.rs 🔗

@@ -9,7 +9,7 @@ use rpc::proto;
 use std::{ops::Range, sync::Arc};
 use text::*;
 
-pub use proto::{BufferState, Operation, SelectionSet};
+pub use proto::{BufferState, Operation};
 
 pub fn deserialize_line_ending(message: proto::LineEnding) -> fs::LineEnding {
     match message {
@@ -122,8 +122,14 @@ pub fn serialize_selections(selections: &Arc<[Selection<Anchor>]>) -> Vec<proto:
 pub fn serialize_selection(selection: &Selection<Anchor>) -> proto::Selection {
     proto::Selection {
         id: selection.id as u64,
-        start: Some(serialize_anchor(&selection.start)),
-        end: Some(serialize_anchor(&selection.end)),
+        start: Some(proto::EditorAnchor {
+            anchor: Some(serialize_anchor(&selection.start)),
+            excerpt_id: 0,
+        }),
+        end: Some(proto::EditorAnchor {
+            anchor: Some(serialize_anchor(&selection.end)),
+            excerpt_id: 0,
+        }),
         reversed: selection.reversed,
     }
 }
@@ -229,8 +235,8 @@ pub fn deserialize_operation(message: proto::Operation) -> Result<crate::Operati
                     .filter_map(|selection| {
                         Some(Selection {
                             id: selection.id as usize,
-                            start: deserialize_anchor(selection.start?)?,
-                            end: deserialize_anchor(selection.end?)?,
+                            start: deserialize_anchor(selection.start?.anchor?)?,
+                            end: deserialize_anchor(selection.end?.anchor?)?,
                             reversed: selection.reversed,
                             goal: SelectionGoal::None,
                         })
@@ -321,8 +327,8 @@ pub fn deserialize_selections(selections: Vec<proto::Selection>) -> Arc<[Selecti
 pub fn deserialize_selection(selection: proto::Selection) -> Option<Selection<Anchor>> {
     Some(Selection {
         id: selection.id as usize,
-        start: deserialize_anchor(selection.start?)?,
-        end: deserialize_anchor(selection.end?)?,
+        start: deserialize_anchor(selection.start?.anchor?)?,
+        end: deserialize_anchor(selection.end?.anchor?)?,
         reversed: selection.reversed,
         goal: SelectionGoal::None,
     })

crates/project/Cargo.toml 🔗

@@ -32,6 +32,7 @@ lsp = { path = "../lsp" }
 rpc = { path = "../rpc" }
 settings = { path = "../settings" }
 sum_tree = { path = "../sum_tree" }
+terminal = { path = "../terminal" }
 util = { path = "../util" }
 aho-corasick = "0.7"
 anyhow = "1.0.57"

crates/project/src/lsp_command.rs 🔗

@@ -3,7 +3,7 @@ use crate::{
 };
 use anyhow::{anyhow, Result};
 use async_trait::async_trait;
-use client::{proto, PeerId};
+use client::proto::{self, PeerId};
 use gpui::{AppContext, AsyncAppContext, ModelHandle};
 use language::{
     point_from_lsp, point_to_lsp,

crates/project/src/project.rs 🔗

@@ -7,7 +7,7 @@ pub mod worktree;
 mod project_tests;
 
 use anyhow::{anyhow, Context, Result};
-use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
+use client::{proto, Client, TypedEnvelope, UserStore};
 use clock::ReplicaId;
 use collections::{hash_map, BTreeMap, HashMap, HashSet};
 use futures::{
@@ -15,7 +15,6 @@ use futures::{
     future::Shared,
     AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt,
 };
-
 use gpui::{
     AnyModelHandle, AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle,
     MutableAppContext, Task, UpgradeModelHandle, WeakModelHandle,
@@ -62,6 +61,7 @@ use std::{
     },
     time::Instant,
 };
+use terminal::{Terminal, TerminalBuilder};
 use thiserror::Error;
 use util::{defer, post_inc, ResultExt, TryFutureExt as _};
 
@@ -102,11 +102,11 @@ pub struct Project {
     user_store: ModelHandle<UserStore>,
     fs: Arc<dyn Fs>,
     client_state: Option<ProjectClientState>,
-    collaborators: HashMap<PeerId, Collaborator>,
+    collaborators: HashMap<proto::PeerId, Collaborator>,
     client_subscriptions: Vec<client::Subscription>,
     _subscriptions: Vec<gpui::Subscription>,
     opened_buffer: (watch::Sender<()>, watch::Receiver<()>),
-    shared_buffers: HashMap<PeerId, HashSet<u64>>,
+    shared_buffers: HashMap<proto::PeerId, HashSet<u64>>,
     #[allow(clippy::type_complexity)]
     loading_buffers: HashMap<
         ProjectPath,
@@ -163,7 +163,7 @@ enum ProjectClientState {
 
 #[derive(Clone, Debug)]
 pub struct Collaborator {
-    pub peer_id: PeerId,
+    pub peer_id: proto::PeerId,
     pub replica_id: ReplicaId,
 }
 
@@ -184,7 +184,7 @@ pub enum Event {
     },
     RemoteIdChanged(Option<u64>),
     DisconnectedFromHost,
-    CollaboratorLeft(PeerId),
+    CollaboratorLeft(proto::PeerId),
 }
 
 pub enum LanguageServerState {
@@ -554,7 +554,7 @@ impl Project {
             .await?;
         let mut collaborators = HashMap::default();
         for message in response.collaborators {
-            let collaborator = Collaborator::from_proto(message);
+            let collaborator = Collaborator::from_proto(message)?;
             collaborators.insert(collaborator.peer_id, collaborator);
         }
 
@@ -753,7 +753,7 @@ impl Project {
         }
     }
 
-    pub fn collaborators(&self) -> &HashMap<PeerId, Collaborator> {
+    pub fn collaborators(&self) -> &HashMap<proto::PeerId, Collaborator> {
         &self.collaborators
     }
 
@@ -1193,6 +1193,34 @@ impl Project {
         !self.is_local()
     }
 
+    pub fn create_terminal(
+        &mut self,
+        working_directory: Option<PathBuf>,
+        window_id: usize,
+        cx: &mut ModelContext<Self>,
+    ) -> Result<ModelHandle<Terminal>> {
+        if self.is_remote() {
+            return Err(anyhow!(
+                "creating terminals as a guest is not supported yet"
+            ));
+        } else {
+            let settings = cx.global::<Settings>();
+            let shell = settings.terminal_shell();
+            let envs = settings.terminal_env();
+            let scroll = settings.terminal_scroll();
+
+            TerminalBuilder::new(
+                working_directory.clone(),
+                shell,
+                envs,
+                settings.terminal_overrides.blinking.clone(),
+                scroll,
+                window_id,
+            )
+            .map(|builder| cx.add_model(|cx| builder.subscribe(cx)))
+        }
+    }
+
     pub fn create_buffer(
         &mut self,
         text: &str,
@@ -4576,7 +4604,7 @@ impl Project {
             .take()
             .ok_or_else(|| anyhow!("empty collaborator"))?;
 
-        let collaborator = Collaborator::from_proto(collaborator);
+        let collaborator = Collaborator::from_proto(collaborator)?;
         this.update(&mut cx, |this, cx| {
             this.collaborators
                 .insert(collaborator.peer_id, collaborator);
@@ -4593,7 +4621,10 @@ impl Project {
         mut cx: AsyncAppContext,
     ) -> Result<()> {
         this.update(&mut cx, |this, cx| {
-            let peer_id = PeerId(envelope.payload.peer_id);
+            let peer_id = envelope
+                .payload
+                .peer_id
+                .ok_or_else(|| anyhow!("invalid peer id"))?;
             let replica_id = this
                 .collaborators
                 .remove(&peer_id)
@@ -5460,7 +5491,7 @@ impl Project {
     fn serialize_project_transaction_for_peer(
         &mut self,
         project_transaction: ProjectTransaction,
-        peer_id: PeerId,
+        peer_id: proto::PeerId,
         cx: &AppContext,
     ) -> proto::ProjectTransaction {
         let mut serialized_transaction = proto::ProjectTransaction {
@@ -5516,7 +5547,7 @@ impl Project {
     fn create_buffer_for_peer(
         &mut self,
         buffer: &ModelHandle<Buffer>,
-        peer_id: PeerId,
+        peer_id: proto::PeerId,
         cx: &AppContext,
     ) -> u64 {
         let buffer_id = buffer.read(cx).remote_id();
@@ -5534,7 +5565,7 @@ impl Project {
 
                             client.send(proto::CreateBufferForPeer {
                                 project_id,
-                                peer_id: peer_id.0,
+                                peer_id: Some(peer_id),
                                 variant: Some(proto::create_buffer_for_peer::Variant::State(state)),
                             })?;
 
@@ -5551,7 +5582,7 @@ impl Project {
                                 let is_last = operations.is_empty();
                                 client.send(proto::CreateBufferForPeer {
                                     project_id,
-                                    peer_id: peer_id.0,
+                                    peer_id: Some(peer_id),
                                     variant: Some(proto::create_buffer_for_peer::Variant::Chunk(
                                         proto::BufferChunk {
                                             buffer_id,
@@ -6007,11 +6038,11 @@ impl Entity for Project {
 }
 
 impl Collaborator {
-    fn from_proto(message: proto::Collaborator) -> Self {
-        Self {
-            peer_id: PeerId(message.peer_id),
+    fn from_proto(message: proto::Collaborator) -> Result<Self> {
+        Ok(Self {
+            peer_id: message.peer_id.ok_or_else(|| anyhow!("invalid peer id"))?,
             replica_id: message.replica_id as ReplicaId,
-        }
+        })
     }
 }
 

crates/rpc/proto/zed.proto 🔗

@@ -1,10 +1,15 @@
 syntax = "proto3";
 package zed.messages;
 
+message PeerId {
+    uint32 owner_id = 1;
+    uint32 id = 2;
+}
+
 message Envelope {
     uint32 id = 1;
     optional uint32 responding_to = 2;
-    optional uint32 original_sender_id = 3;
+    optional PeerId original_sender_id = 3;
     oneof payload {
         Hello hello = 4;
         Ack ack = 5;
@@ -125,7 +130,7 @@ message Envelope {
 // Messages
 
 message Hello {
-    uint32 peer_id = 1;
+    PeerId peer_id = 1;
 }
 
 message Ping {}
@@ -167,7 +172,7 @@ message Room {
 
 message Participant {
     uint64 user_id = 1;
-    uint32 peer_id = 2;
+    PeerId peer_id = 2;
     repeated ParticipantProject projects = 3;
     ParticipantLocation location = 4;
 }
@@ -319,7 +324,7 @@ message AddProjectCollaborator {
 
 message RemoveProjectCollaborator {
     uint64 project_id = 1;
-    uint32 peer_id = 2;
+    PeerId peer_id = 2;
 }
 
 message GetDefinition {
@@ -438,7 +443,7 @@ message OpenBufferResponse {
 
 message CreateBufferForPeer {
     uint64 project_id = 1;
-    uint32 peer_id = 2;
+    PeerId peer_id = 2;
     oneof variant {
         BufferState state = 3;
         BufferChunk chunk = 4;
@@ -794,17 +799,17 @@ message UpdateDiagnostics {
 
 message Follow {
     uint64 project_id = 1;
-    uint32 leader_id = 2;
+    PeerId leader_id = 2;
 }
 
 message FollowResponse {
-    optional uint64 active_view_id = 1;
+    optional ViewId active_view_id = 1;
     repeated View views = 2;
 }
 
 message UpdateFollowers {
     uint64 project_id = 1;
-    repeated uint32 follower_ids = 2;
+    repeated PeerId follower_ids = 2;
     oneof variant {
         UpdateActiveView update_active_view = 3;
         View create_view = 4;
@@ -814,7 +819,7 @@ message UpdateFollowers {
 
 message Unfollow {
     uint64 project_id = 1;
-    uint32 leader_id = 2;
+    PeerId leader_id = 2;
 }
 
 message GetPrivateUserInfo {}
@@ -826,46 +831,55 @@ message GetPrivateUserInfoResponse {
 
 // Entities
 
+message ViewId {
+    PeerId creator = 1;
+    uint64 id = 2;
+}
+
 message UpdateActiveView {
-    optional uint64 id = 1;
-    optional uint32 leader_id = 2;
+    optional ViewId id = 1;
+    optional PeerId leader_id = 2;
 }
 
 message UpdateView {
-    uint64 id = 1;
-    optional uint32 leader_id = 2;
+    ViewId id = 1;
+    optional PeerId leader_id = 2;
 
     oneof variant {
         Editor editor = 3;
     }
 
     message Editor {
-        repeated Selection selections = 1;
-        Anchor scroll_top_anchor = 2;
-        float scroll_x = 3;
-        float scroll_y = 4;
+        repeated ExcerptInsertion inserted_excerpts = 1;
+        repeated uint64 deleted_excerpts = 2;
+        repeated Selection selections = 3;
+        EditorAnchor scroll_top_anchor = 4;
+        float scroll_x = 5;
+        float scroll_y = 6;
     }
 }
 
 message View {
-    uint64 id = 1;
-    optional uint32 leader_id = 2;
+    ViewId id = 1;
+    optional PeerId leader_id = 2;
 
     oneof variant {
         Editor editor = 3;
     }
 
     message Editor {
-        uint64 buffer_id = 1;
-        repeated Selection selections = 2;
-        Anchor scroll_top_anchor = 3;
-        float scroll_x = 4;
-        float scroll_y = 5;
+        bool singleton = 1;
+        optional string title = 2;
+        repeated Excerpt excerpts = 3;
+        repeated Selection selections = 4;
+        EditorAnchor scroll_top_anchor = 5;
+        float scroll_x = 6;
+        float scroll_y = 7;
     }
 }
 
 message Collaborator {
-    uint32 peer_id = 1;
+    PeerId peer_id = 1;
     uint32 replica_id = 2;
     uint64 user_id = 3;
 }
@@ -913,21 +927,18 @@ enum LineEnding {
     Windows = 1;
 }
 
-message SelectionSet {
-    uint32 replica_id = 1;
-    repeated Selection selections = 2;
-    uint32 lamport_timestamp = 3;
-    bool line_mode = 4;
-    CursorShape cursor_shape = 5;
-}
-
 message Selection {
     uint64 id = 1;
-    Anchor start = 2;
-    Anchor end = 3;
+    EditorAnchor start = 2;
+    EditorAnchor end = 3;
     bool reversed = 4;
 }
 
+message EditorAnchor {
+    uint64 excerpt_id = 1;
+    Anchor anchor = 2;
+}
+
 enum CursorShape {
     CursorBar = 0;
     CursorBlock = 1;
@@ -935,6 +946,20 @@ enum CursorShape {
     CursorHollow = 3;
 }
 
+message ExcerptInsertion {
+    Excerpt excerpt = 1;
+    optional uint64 previous_excerpt_id = 2;
+}
+
+message Excerpt {
+    uint64 id = 1;
+    uint64 buffer_id = 2;
+    Anchor context_start = 3;
+    Anchor context_end = 4;
+    Anchor primary_start = 5;
+    Anchor primary_end = 6;
+}
+
 message Anchor {
     uint32 replica_id = 1;
     uint32 local_timestamp = 2;

crates/rpc/src/macros.rs 🔗

@@ -6,7 +6,10 @@ macro_rules! messages {
                 $(Some(envelope::Payload::$name(payload)) => {
                     Some(Box::new(TypedEnvelope {
                         sender_id,
-                        original_sender_id: envelope.original_sender_id.map(PeerId),
+                        original_sender_id: envelope.original_sender_id.map(|original_sender| PeerId {
+                            owner_id: original_sender.owner_id,
+                            id: original_sender.id
+                        }),
                         message_id: envelope.id,
                         payload,
                     }))
@@ -24,7 +27,7 @@ macro_rules! messages {
                     self,
                     id: u32,
                     responding_to: Option<u32>,
-                    original_sender_id: Option<u32>,
+                    original_sender_id: Option<PeerId>,
                 ) -> Envelope {
                     Envelope {
                         id,

crates/rpc/src/peer.rs 🔗

@@ -1,5 +1,5 @@
 use super::{
-    proto::{self, AnyTypedEnvelope, EnvelopedMessage, MessageStream, RequestMessage},
+    proto::{self, AnyTypedEnvelope, EnvelopedMessage, MessageStream, PeerId, RequestMessage},
     Connection,
 };
 use anyhow::{anyhow, Context, Result};
@@ -11,9 +11,8 @@ use futures::{
 };
 use parking_lot::{Mutex, RwLock};
 use serde::{ser::SerializeStruct, Serialize};
-use std::sync::atomic::Ordering::SeqCst;
+use std::{fmt, sync::atomic::Ordering::SeqCst};
 use std::{
-    fmt,
     future::Future,
     marker::PhantomData,
     sync::{
@@ -25,20 +24,32 @@ use std::{
 use tracing::instrument;
 
 #[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Serialize)]
-pub struct ConnectionId(pub u32);
+pub struct ConnectionId {
+    pub owner_id: u32,
+    pub id: u32,
+}
 
-impl fmt::Display for ConnectionId {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        self.0.fmt(f)
+impl Into<PeerId> for ConnectionId {
+    fn into(self) -> PeerId {
+        PeerId {
+            owner_id: self.owner_id,
+            id: self.id,
+        }
     }
 }
 
-#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
-pub struct PeerId(pub u32);
+impl From<PeerId> for ConnectionId {
+    fn from(peer_id: PeerId) -> Self {
+        Self {
+            owner_id: peer_id.owner_id,
+            id: peer_id.id,
+        }
+    }
+}
 
-impl fmt::Display for PeerId {
+impl fmt::Display for ConnectionId {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        self.0.fmt(f)
+        write!(f, "{}/{}", self.owner_id, self.id)
     }
 }
 
@@ -85,6 +96,7 @@ impl<T: RequestMessage> TypedEnvelope<T> {
 }
 
 pub struct Peer {
+    epoch: AtomicU32,
     pub connections: RwLock<HashMap<ConnectionId, ConnectionState>>,
     next_connection_id: AtomicU32,
 }
@@ -105,13 +117,18 @@ const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
 pub const RECEIVE_TIMEOUT: Duration = Duration::from_secs(5);
 
 impl Peer {
-    pub fn new() -> Arc<Self> {
+    pub fn new(epoch: u32) -> Arc<Self> {
         Arc::new(Self {
+            epoch: AtomicU32::new(epoch),
             connections: Default::default(),
             next_connection_id: Default::default(),
         })
     }
 
+    pub fn epoch(&self) -> u32 {
+        self.epoch.load(SeqCst)
+    }
+
     #[instrument(skip_all)]
     pub fn add_connection<F, Fut, Out>(
         self: &Arc<Self>,
@@ -138,7 +155,10 @@ impl Peer {
         let (mut incoming_tx, incoming_rx) = mpsc::channel(INCOMING_BUFFER_SIZE);
         let (outgoing_tx, mut outgoing_rx) = mpsc::unbounded();
 
-        let connection_id = ConnectionId(self.next_connection_id.fetch_add(1, SeqCst));
+        let connection_id = ConnectionId {
+            owner_id: self.epoch.load(SeqCst),
+            id: self.next_connection_id.fetch_add(1, SeqCst),
+        };
         let connection_state = ConnectionState {
             outgoing_tx,
             next_message_id: Default::default(),
@@ -255,11 +275,7 @@ impl Peer {
                 let message_id = incoming.id;
                 tracing::debug!(?incoming, "incoming message future: start");
                 let _end = util::defer(move || {
-                    tracing::debug!(
-                        %connection_id,
-                        message_id,
-                        "incoming message future: end"
-                    );
+                    tracing::debug!(%connection_id, message_id, "incoming message future: end");
                 });
 
                 if let Some(responding_to) = incoming.responding_to {
@@ -306,11 +322,7 @@ impl Peer {
 
                     None
                 } else {
-                    tracing::debug!(
-                        %connection_id,
-                        message_id,
-                        "incoming message: received"
-                    );
+                    tracing::debug!(%connection_id, message_id, "incoming message: received");
                     proto::build_typed_envelope(connection_id, incoming).or_else(|| {
                         tracing::error!(
                             %connection_id,
@@ -343,7 +355,13 @@ impl Peer {
         self.connections.write().remove(&connection_id);
     }
 
-    pub fn reset(&self) {
+    pub fn reset(&self, epoch: u32) {
+        self.teardown();
+        self.next_connection_id.store(0, SeqCst);
+        self.epoch.store(epoch, SeqCst);
+    }
+
+    pub fn teardown(&self) {
         self.connections.write().clear();
     }
 
@@ -384,7 +402,7 @@ impl Peer {
                 .unbounded_send(proto::Message::Envelope(request.into_envelope(
                     message_id,
                     None,
-                    original_sender_id.map(|id| id.0),
+                    original_sender_id.map(Into::into),
                 )))
                 .map_err(|_| anyhow!("connection was closed"))?;
             Ok(())
@@ -433,7 +451,7 @@ impl Peer {
             .unbounded_send(proto::Message::Envelope(message.into_envelope(
                 message_id,
                 None,
-                Some(sender_id.0),
+                Some(sender_id.into()),
             )))?;
         Ok(())
     }
@@ -515,9 +533,9 @@ mod tests {
         let executor = cx.foreground();
 
         // create 2 clients connected to 1 server
-        let server = Peer::new();
-        let client1 = Peer::new();
-        let client2 = Peer::new();
+        let server = Peer::new(0);
+        let client1 = Peer::new(0);
+        let client2 = Peer::new(0);
 
         let (client1_to_server_conn, server_to_client_1_conn, _kill) =
             Connection::in_memory(cx.background());
@@ -609,8 +627,8 @@ mod tests {
     #[gpui::test(iterations = 50)]
     async fn test_order_of_response_and_incoming(cx: &mut TestAppContext) {
         let executor = cx.foreground();
-        let server = Peer::new();
-        let client = Peer::new();
+        let server = Peer::new(0);
+        let client = Peer::new(0);
 
         let (client_to_server_conn, server_to_client_conn, _kill) =
             Connection::in_memory(cx.background());
@@ -707,8 +725,8 @@ mod tests {
     #[gpui::test(iterations = 50)]
     async fn test_dropping_request_before_completion(cx: &mut TestAppContext) {
         let executor = cx.foreground();
-        let server = Peer::new();
-        let client = Peer::new();
+        let server = Peer::new(0);
+        let client = Peer::new(0);
 
         let (client_to_server_conn, server_to_client_conn, _kill) =
             Connection::in_memory(cx.background());
@@ -822,7 +840,7 @@ mod tests {
 
         let (client_conn, mut server_conn, _kill) = Connection::in_memory(cx.background());
 
-        let client = Peer::new();
+        let client = Peer::new(0);
         let (connection_id, io_handler, mut incoming) =
             client.add_test_connection(client_conn, cx.background());
 
@@ -857,7 +875,7 @@ mod tests {
         let executor = cx.foreground();
         let (client_conn, mut server_conn, _kill) = Connection::in_memory(cx.background());
 
-        let client = Peer::new();
+        let client = Peer::new(0);
         let (connection_id, io_handler, mut incoming) =
             client.add_test_connection(client_conn, cx.background());
         executor.spawn(io_handler).detach();

crates/rpc/src/proto.rs 🔗

@@ -1,14 +1,16 @@
-use super::{entity_messages, messages, request_messages, ConnectionId, PeerId, TypedEnvelope};
+use super::{entity_messages, messages, request_messages, ConnectionId, TypedEnvelope};
 use anyhow::{anyhow, Result};
 use async_tungstenite::tungstenite::Message as WebSocketMessage;
 use futures::{SinkExt as _, StreamExt as _};
 use prost::Message as _;
 use serde::Serialize;
 use std::any::{Any, TypeId};
-use std::{cmp, iter, mem};
+use std::fmt;
+use std::str::FromStr;
 use std::{
+    cmp,
     fmt::Debug,
-    io,
+    io, iter, mem,
     time::{Duration, SystemTime, UNIX_EPOCH},
 };
 
@@ -21,7 +23,7 @@ pub trait EnvelopedMessage: Clone + Debug + Serialize + Sized + Send + Sync + 's
         self,
         id: u32,
         responding_to: Option<u32>,
-        original_sender_id: Option<u32>,
+        original_sender_id: Option<PeerId>,
     ) -> Envelope;
     fn from_envelope(envelope: Envelope) -> Option<Self>;
 }
@@ -74,6 +76,66 @@ impl<T: EnvelopedMessage> AnyTypedEnvelope for TypedEnvelope<T> {
     }
 }
 
+impl PeerId {
+    pub fn from_u64(peer_id: u64) -> Self {
+        let owner_id = (peer_id >> 32) as u32;
+        let id = peer_id as u32;
+        Self { owner_id, id }
+    }
+
+    pub fn as_u64(self) -> u64 {
+        ((self.owner_id as u64) << 32) | (self.id as u64)
+    }
+}
+
+impl Copy for PeerId {}
+
+impl Eq for PeerId {}
+
+impl Ord for PeerId {
+    fn cmp(&self, other: &Self) -> cmp::Ordering {
+        self.owner_id
+            .cmp(&other.owner_id)
+            .then_with(|| self.id.cmp(&other.id))
+    }
+}
+
+impl PartialOrd for PeerId {
+    fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
+        Some(self.cmp(other))
+    }
+}
+
+impl std::hash::Hash for PeerId {
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        self.owner_id.hash(state);
+        self.id.hash(state);
+    }
+}
+
+impl fmt::Display for PeerId {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        write!(f, "{}/{}", self.owner_id, self.id)
+    }
+}
+
+impl FromStr for PeerId {
+    type Err = anyhow::Error;
+
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
+        let mut components = s.split('/');
+        let owner_id = components
+            .next()
+            .ok_or_else(|| anyhow!("invalid peer id {:?}", s))?
+            .parse()?;
+        let id = components
+            .next()
+            .ok_or_else(|| anyhow!("invalid peer id {:?}", s))?
+            .parse()?;
+        Ok(PeerId { owner_id, id })
+    }
+}
+
 messages!(
     (Ack, Foreground),
     (AddProjectCollaborator, Foreground),
@@ -477,4 +539,28 @@ mod tests {
         stream.read().await.unwrap();
         assert!(stream.encoding_buffer.capacity() <= MAX_BUFFER_LEN);
     }
+
+    #[gpui::test]
+    fn test_converting_peer_id_from_and_to_u64() {
+        let peer_id = PeerId {
+            owner_id: 10,
+            id: 3,
+        };
+        assert_eq!(PeerId::from_u64(peer_id.as_u64()), peer_id);
+        let peer_id = PeerId {
+            owner_id: u32::MAX,
+            id: 3,
+        };
+        assert_eq!(PeerId::from_u64(peer_id.as_u64()), peer_id);
+        let peer_id = PeerId {
+            owner_id: 10,
+            id: u32::MAX,
+        };
+        assert_eq!(PeerId::from_u64(peer_id.as_u64()), peer_id);
+        let peer_id = PeerId {
+            owner_id: u32::MAX,
+            id: u32::MAX,
+        };
+        assert_eq!(PeerId::from_u64(peer_id.as_u64()), peer_id);
+    }
 }

crates/rpc/src/rpc.rs 🔗

@@ -6,4 +6,4 @@ pub use conn::Connection;
 pub use peer::*;
 mod macros;
 
-pub const PROTOCOL_VERSION: u32 = 41;
+pub const PROTOCOL_VERSION: u32 = 42;

crates/search/src/project_search.rs 🔗

@@ -402,7 +402,7 @@ impl ProjectSearchView {
         });
         // Subcribe to query_editor in order to reraise editor events for workspace item activation purposes
         cx.subscribe(&query_editor, |_, _, event, cx| {
-            cx.emit(ViewEvent::EditorEvent(*event))
+            cx.emit(ViewEvent::EditorEvent(event.clone()))
         })
         .detach();
 
@@ -419,7 +419,7 @@ impl ProjectSearchView {
                 this.update_match_index(cx);
             }
             // Reraise editor events for workspace item activation purposes
-            cx.emit(ViewEvent::EditorEvent(*event));
+            cx.emit(ViewEvent::EditorEvent(event.clone()));
         })
         .detach();
 

crates/settings/src/settings.rs 🔗

@@ -199,7 +199,7 @@ impl Default for Shell {
     }
 }
 
-#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum AlternateScroll {
     On,
@@ -221,6 +221,12 @@ pub enum WorkingDirectory {
     Always { directory: String },
 }
 
+impl Default for WorkingDirectory {
+    fn default() -> Self {
+        Self::CurrentProjectDirectory
+    }
+}
+
 #[derive(PartialEq, Eq, Debug, Default, Copy, Clone, Hash, Serialize, Deserialize, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum DockAnchor {
@@ -473,6 +479,32 @@ impl Settings {
         })
     }
 
+    fn terminal_setting<F, R: Default + Clone>(&self, f: F) -> R
+    where
+        F: Fn(&TerminalSettings) -> Option<&R>,
+    {
+        f(&self.terminal_overrides)
+            .or_else(|| f(&self.terminal_defaults))
+            .cloned()
+            .unwrap_or_else(|| R::default())
+    }
+
+    pub fn terminal_scroll(&self) -> AlternateScroll {
+        self.terminal_setting(|terminal_setting| terminal_setting.alternate_scroll.as_ref())
+    }
+
+    pub fn terminal_shell(&self) -> Shell {
+        self.terminal_setting(|terminal_setting| terminal_setting.shell.as_ref())
+    }
+
+    pub fn terminal_env(&self) -> HashMap<String, String> {
+        self.terminal_setting(|terminal_setting| terminal_setting.env.as_ref())
+    }
+
+    pub fn terminal_strategy(&self) -> WorkingDirectory {
+        self.terminal_setting(|terminal_setting| terminal_setting.working_directory.as_ref())
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &gpui::AppContext) -> Settings {
         Settings {

crates/terminal/Cargo.toml 🔗

@@ -7,17 +7,13 @@ edition = "2021"
 path = "src/terminal.rs"
 doctest = false
 
+
 [dependencies]
-context_menu = { path = "../context_menu" }
-editor = { path = "../editor" }
-language = { path = "../language" }
 gpui = { path = "../gpui" }
-project = { path = "../project" }
 settings = { path = "../settings" }
+db = { path = "../db" }
 theme = { path = "../theme" }
 util = { path = "../util" }
-workspace = { path = "../workspace" }
-db = { path = "../db" }
 alacritty_terminal = { git = "https://github.com/zed-industries/alacritty", rev = "a51dbe25d67e84d6ed4261e640d3954fbdd9be45" }
 procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false }
 smallvec = { version = "1.6", features = ["union"] }
@@ -34,11 +30,5 @@ thiserror = "1.0"
 lazy_static = "1.4.0"
 serde = { version = "1.0", features = ["derive"] }
 
-
-
 [dev-dependencies]
-gpui = { path = "../gpui", features = ["test-support"] }
-client = { path = "../client", features = ["test-support"]}
-project = { path = "../project", features = ["test-support"]}
-workspace = { path = "../workspace", features = ["test-support"] }
 rand = "0.8.5"

crates/terminal/src/terminal.rs 🔗

@@ -1,8 +1,5 @@
 pub mod mappings;
-mod persistence;
-pub mod terminal_container_view;
-pub mod terminal_element;
-pub mod terminal_view;
+pub use alacritty_terminal;
 
 use alacritty_terminal::{
     ansi::{ClearMode, Handler},
@@ -33,11 +30,9 @@ use mappings::mouse::{
     alt_scroll, grid_point, mouse_button_report, mouse_moved_report, mouse_side, scroll_report,
 };
 
-use persistence::TERMINAL_CONNECTION;
 use procinfo::LocalProcessInfo;
 use settings::{AlternateScroll, Settings, Shell, TerminalBlink};
 use util::ResultExt;
-use workspace::{ItemId, WorkspaceId};
 
 use std::{
     cmp::min,
@@ -57,8 +52,7 @@ use gpui::{
     geometry::vector::{vec2f, Vector2F},
     keymap::Keystroke,
     scene::{MouseDown, MouseDrag, MouseScrollWheel, MouseUp},
-    AppContext, ClipboardItem, Entity, ModelContext, MouseButton, MouseMovedEvent,
-    MutableAppContext, Task,
+    ClipboardItem, Entity, ModelContext, MouseButton, MouseMovedEvent, Task,
 };
 
 use crate::mappings::{
@@ -67,12 +61,6 @@ use crate::mappings::{
 };
 use lazy_static::lazy_static;
 
-///Initialize and register all of our action handlers
-pub fn init(cx: &mut MutableAppContext) {
-    terminal_view::init(cx);
-    terminal_container_view::init(cx);
-}
-
 ///Scrolling is unbearably sluggish by default. Alacritty supports a configurable
 ///Scroll multiplier that is set to 3 by default. This will be removed when I
 ///Implement scroll bars.
@@ -128,10 +116,10 @@ impl EventListener for ZedListener {
 
 #[derive(Clone, Copy, Debug)]
 pub struct TerminalSize {
-    cell_width: f32,
-    line_height: f32,
-    height: f32,
-    width: f32,
+    pub cell_width: f32,
+    pub line_height: f32,
+    pub height: f32,
+    pub width: f32,
 }
 
 impl TerminalSize {
@@ -210,7 +198,7 @@ impl Dimensions for TerminalSize {
 #[derive(Error, Debug)]
 pub struct TerminalError {
     pub directory: Option<PathBuf>,
-    pub shell: Option<Shell>,
+    pub shell: Shell,
     pub source: std::io::Error,
 }
 
@@ -238,24 +226,20 @@ impl TerminalError {
             })
     }
 
-    pub fn shell_to_string(&self) -> Option<String> {
-        self.shell.as_ref().map(|shell| match shell {
+    pub fn shell_to_string(&self) -> String {
+        match &self.shell {
             Shell::System => "<system shell>".to_string(),
             Shell::Program(p) => p.to_string(),
             Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
-        })
+        }
     }
 
     pub fn fmt_shell(&self) -> String {
-        self.shell
-            .clone()
-            .map(|shell| match shell {
-                Shell::System => "<system defined shell>".to_string(),
-
-                Shell::Program(s) => s,
-                Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
-            })
-            .unwrap_or_else(|| "<none specified, using system defined shell>".to_string())
+        match &self.shell {
+            Shell::System => "<system defined shell>".to_string(),
+            Shell::Program(s) => s.to_string(),
+            Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
+        }
     }
 }
 
@@ -280,20 +264,18 @@ pub struct TerminalBuilder {
 impl TerminalBuilder {
     pub fn new(
         working_directory: Option<PathBuf>,
-        shell: Option<Shell>,
-        env: Option<HashMap<String, String>>,
+        shell: Shell,
+        mut env: HashMap<String, String>,
         blink_settings: Option<TerminalBlink>,
-        alternate_scroll: &AlternateScroll,
+        alternate_scroll: AlternateScroll,
         window_id: usize,
-        item_id: ItemId,
-        workspace_id: WorkspaceId,
     ) -> Result<TerminalBuilder> {
         let pty_config = {
-            let alac_shell = shell.clone().and_then(|shell| match shell {
+            let alac_shell = match shell.clone() {
                 Shell::System => None,
                 Shell::Program(program) => Some(Program::Just(program)),
                 Shell::WithArguments { program, args } => Some(Program::WithArgs { program, args }),
-            });
+            };
 
             PtyConfig {
                 shell: alac_shell,
@@ -302,10 +284,9 @@ impl TerminalBuilder {
             }
         };
 
-        let mut env = env.unwrap_or_default();
-
         //TODO: Properly set the current locale,
         env.insert("LC_ALL".to_string(), "en_US.UTF-8".to_string());
+        env.insert("ZED_TERM".to_string(), true.to_string());
 
         let alac_scrolling = Scrolling::default();
         // alac_scrolling.set_history((BACK_BUFFER_SIZE * 2) as u32);
@@ -391,8 +372,6 @@ impl TerminalBuilder {
             last_mouse_position: None,
             next_link_id: 0,
             selection_phase: SelectionPhase::Ended,
-            workspace_id,
-            item_id,
         };
 
         Ok(TerminalBuilder {
@@ -464,9 +443,9 @@ impl TerminalBuilder {
 }
 
 #[derive(Debug, Clone)]
-struct IndexedCell {
-    point: Point,
-    cell: Cell,
+pub struct IndexedCell {
+    pub point: Point,
+    pub cell: Cell,
 }
 
 impl Deref for IndexedCell {
@@ -478,17 +457,18 @@ impl Deref for IndexedCell {
     }
 }
 
+// TODO: Un-pub
 #[derive(Clone)]
 pub struct TerminalContent {
-    cells: Vec<IndexedCell>,
-    mode: TermMode,
-    display_offset: usize,
-    selection_text: Option<String>,
-    selection: Option<SelectionRange>,
-    cursor: RenderableCursor,
-    cursor_char: char,
-    size: TerminalSize,
-    last_hovered_hyperlink: Option<(String, RangeInclusive<Point>, usize)>,
+    pub cells: Vec<IndexedCell>,
+    pub mode: TermMode,
+    pub display_offset: usize,
+    pub selection_text: Option<String>,
+    pub selection: Option<SelectionRange>,
+    pub cursor: RenderableCursor,
+    pub cursor_char: char,
+    pub size: TerminalSize,
+    pub last_hovered_hyperlink: Option<(String, RangeInclusive<Point>, usize)>,
 }
 
 impl Default for TerminalContent {
@@ -525,19 +505,17 @@ pub struct Terminal {
     /// This is only used for terminal hyperlink checking
     last_mouse_position: Option<Vector2F>,
     pub matches: Vec<RangeInclusive<Point>>,
-    last_content: TerminalContent,
+    pub last_content: TerminalContent,
     last_synced: Instant,
     sync_task: Option<Task<()>>,
-    selection_head: Option<Point>,
-    breadcrumb_text: String,
+    pub selection_head: Option<Point>,
+    pub breadcrumb_text: String,
     shell_pid: u32,
     shell_fd: u32,
-    foreground_process_info: Option<LocalProcessInfo>,
+    pub foreground_process_info: Option<LocalProcessInfo>,
     scroll_px: f32,
     next_link_id: usize,
     selection_phase: SelectionPhase,
-    workspace_id: WorkspaceId,
-    item_id: ItemId,
 }
 
 impl Terminal {
@@ -578,20 +556,6 @@ impl Terminal {
 
                 if self.update_process_info() {
                     cx.emit(Event::TitleChanged);
-
-                    if let Some(foreground_info) = &self.foreground_process_info {
-                        let cwd = foreground_info.cwd.clone();
-                        let item_id = self.item_id;
-                        let workspace_id = self.workspace_id;
-                        cx.background()
-                            .spawn(async move {
-                                TERMINAL_CONNECTION
-                                    .save_working_directory(item_id, workspace_id, cwd)
-                                    .await
-                                    .log_err();
-                            })
-                            .detach();
-                    }
                 }
             }
             AlacTermEvent::ColorRequest(idx, fun_ptr) => {
@@ -1194,42 +1158,13 @@ impl Terminal {
         }
     }
 
-    pub fn set_workspace_id(&mut self, id: WorkspaceId, cx: &AppContext) {
-        let old_workspace_id = self.workspace_id;
-        let item_id = self.item_id;
-        cx.background()
-            .spawn(async move {
-                TERMINAL_CONNECTION
-                    .update_workspace_id(id, old_workspace_id, item_id)
-                    .await
-                    .log_err()
-            })
-            .detach();
-
-        self.workspace_id = id;
-    }
-
     pub fn find_matches(
         &mut self,
-        query: project::search::SearchQuery,
+        searcher: RegexSearch,
         cx: &mut ModelContext<Self>,
     ) -> Task<Vec<RangeInclusive<Point>>> {
         let term = self.term.clone();
         cx.background().spawn(async move {
-            let searcher = match query {
-                project::search::SearchQuery::Text { query, .. } => {
-                    RegexSearch::new(query.as_ref())
-                }
-                project::search::SearchQuery::Regex { query, .. } => {
-                    RegexSearch::new(query.as_ref())
-                }
-            };
-
-            if searcher.is_err() {
-                return Vec::new();
-            }
-            let searcher = searcher.unwrap();
-
             let term = term.lock();
 
             all_search_matches(&term, &searcher).collect()
@@ -1326,14 +1261,14 @@ fn open_uri(uri: &str) -> Result<(), std::io::Error> {
 
 #[cfg(test)]
 mod tests {
+    use alacritty_terminal::{
+        index::{Column, Line, Point},
+        term::cell::Cell,
+    };
     use gpui::geometry::vector::vec2f;
-    use rand::{thread_rng, Rng};
-
-    use crate::content_index_for_mouse;
-
-    use self::terminal_test_context::TerminalTestContext;
+    use rand::{rngs::ThreadRng, thread_rng, Rng};
 
-    pub mod terminal_test_context;
+    use crate::{content_index_for_mouse, IndexedCell, TerminalContent, TerminalSize};
 
     #[test]
     fn test_mouse_to_cell() {
@@ -1350,7 +1285,7 @@ mod tests {
                 width: cell_size * (viewport_cells as f32),
             };
 
-            let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng);
+            let (content, cells) = create_terminal_content(size, &mut rng);
 
             for i in 0..(viewport_cells - 1) {
                 let i = i as usize;
@@ -1386,7 +1321,7 @@ mod tests {
             width: 100.,
         };
 
-        let (content, cells) = TerminalTestContext::create_terminal_content(size, &mut rng);
+        let (content, cells) = create_terminal_content(size, &mut rng);
 
         assert_eq!(
             content.cells[content_index_for_mouse(vec2f(-10., -10.), &content)].c,
@@ -1397,4 +1332,37 @@ mod tests {
             cells[9][9]
         );
     }
+
+    fn create_terminal_content(
+        size: TerminalSize,
+        rng: &mut ThreadRng,
+    ) -> (TerminalContent, Vec<Vec<char>>) {
+        let mut ic = Vec::new();
+        let mut cells = Vec::new();
+
+        for row in 0..((size.height() / size.line_height()) as usize) {
+            let mut row_vec = Vec::new();
+            for col in 0..((size.width() / size.cell_width()) as usize) {
+                let cell_char = rng.gen();
+                ic.push(IndexedCell {
+                    point: Point::new(Line(row as i32), Column(col)),
+                    cell: Cell {
+                        c: cell_char,
+                        ..Default::default()
+                    },
+                });
+                row_vec.push(cell_char)
+            }
+            cells.push(row_vec)
+        }
+
+        (
+            TerminalContent {
+                cells: ic,
+                size,
+                ..Default::default()
+            },
+            cells,
+        )
+    }
 }

crates/terminal/src/terminal_container_view.rs 🔗

@@ -1,711 +0,0 @@
-use crate::persistence::TERMINAL_CONNECTION;
-use crate::terminal_view::TerminalView;
-use crate::{Event, TerminalBuilder, TerminalError};
-
-use alacritty_terminal::index::Point;
-use dirs::home_dir;
-use gpui::{
-    actions, elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MutableAppContext, Task,
-    View, ViewContext, ViewHandle, WeakViewHandle,
-};
-use util::{truncate_and_trailoff, ResultExt};
-use workspace::searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle};
-use workspace::{
-    item::{Item, ItemEvent},
-    ToolbarItemLocation, Workspace,
-};
-use workspace::{register_deserializable_item, Pane, WorkspaceId};
-
-use project::{LocalWorktree, Project, ProjectPath};
-use settings::{AlternateScroll, Settings, WorkingDirectory};
-use smallvec::SmallVec;
-use std::ops::RangeInclusive;
-use std::path::{Path, PathBuf};
-
-use crate::terminal_element::TerminalElement;
-
-actions!(terminal, [DeployModal]);
-
-pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(TerminalContainer::deploy);
-
-    register_deserializable_item::<TerminalContainer>(cx);
-}
-
-//Make terminal view an enum, that can give you views for the error and non-error states
-//Take away all the result unwrapping in the current TerminalView by making it 'infallible'
-//Bubble up to deploy(_modal)() calls
-
-pub enum TerminalContainerContent {
-    Connected(ViewHandle<TerminalView>),
-    Error(ViewHandle<ErrorView>),
-}
-
-impl TerminalContainerContent {
-    fn handle(&self) -> AnyViewHandle {
-        match self {
-            Self::Connected(handle) => handle.into(),
-            Self::Error(handle) => handle.into(),
-        }
-    }
-}
-
-pub struct TerminalContainer {
-    pub content: TerminalContainerContent,
-    associated_directory: Option<PathBuf>,
-}
-
-pub struct ErrorView {
-    error: TerminalError,
-}
-
-impl Entity for TerminalContainer {
-    type Event = Event;
-}
-
-impl Entity for ErrorView {
-    type Event = Event;
-}
-
-impl TerminalContainer {
-    ///Create a new Terminal in the current working directory or the user's home directory
-    pub fn deploy(
-        workspace: &mut Workspace,
-        _: &workspace::NewTerminal,
-        cx: &mut ViewContext<Workspace>,
-    ) {
-        let strategy = cx
-            .global::<Settings>()
-            .terminal_overrides
-            .working_directory
-            .clone()
-            .unwrap_or(WorkingDirectory::CurrentProjectDirectory);
-
-        let working_directory = get_working_directory(workspace, cx, strategy);
-        let view = cx.add_view(|cx| {
-            TerminalContainer::new(working_directory, false, workspace.database_id(), cx)
-        });
-        workspace.add_item(Box::new(view), cx);
-    }
-
-    ///Create a new Terminal view. This spawns a task, a thread, and opens the TTY devices    
-    pub fn new(
-        working_directory: Option<PathBuf>,
-        modal: bool,
-        workspace_id: WorkspaceId,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let settings = cx.global::<Settings>();
-        let shell = settings.terminal_overrides.shell.clone();
-        let envs = settings.terminal_overrides.env.clone(); //Should be short and cheap.
-
-        //TODO: move this pattern to settings
-        let scroll = settings
-            .terminal_overrides
-            .alternate_scroll
-            .as_ref()
-            .unwrap_or(
-                settings
-                    .terminal_defaults
-                    .alternate_scroll
-                    .as_ref()
-                    .unwrap_or_else(|| &AlternateScroll::On),
-            );
-
-        let content = match TerminalBuilder::new(
-            working_directory.clone(),
-            shell,
-            envs,
-            settings.terminal_overrides.blinking.clone(),
-            scroll,
-            cx.window_id(),
-            cx.view_id(),
-            workspace_id,
-        ) {
-            Ok(terminal) => {
-                let terminal = cx.add_model(|cx| terminal.subscribe(cx));
-                let view = cx.add_view(|cx| TerminalView::from_terminal(terminal, modal, cx));
-
-                cx.subscribe(&view, |_this, _content, event, cx| cx.emit(*event))
-                    .detach();
-                TerminalContainerContent::Connected(view)
-            }
-            Err(error) => {
-                let view = cx.add_view(|_| ErrorView {
-                    error: error.downcast::<TerminalError>().unwrap(),
-                });
-                TerminalContainerContent::Error(view)
-            }
-        };
-
-        TerminalContainer {
-            content,
-            associated_directory: working_directory,
-        }
-    }
-
-    fn connected(&self) -> Option<ViewHandle<TerminalView>> {
-        match &self.content {
-            TerminalContainerContent::Connected(vh) => Some(vh.clone()),
-            TerminalContainerContent::Error(_) => None,
-        }
-    }
-}
-
-impl View for TerminalContainer {
-    fn ui_name() -> &'static str {
-        "Terminal"
-    }
-
-    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
-        match &self.content {
-            TerminalContainerContent::Connected(connected) => ChildView::new(connected, cx),
-            TerminalContainerContent::Error(error) => ChildView::new(error, cx),
-        }
-        .boxed()
-    }
-
-    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
-        if cx.is_self_focused() {
-            cx.focus(self.content.handle());
-        }
-    }
-}
-
-impl View for ErrorView {
-    fn ui_name() -> &'static str {
-        "Terminal Error"
-    }
-
-    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
-        let settings = cx.global::<Settings>();
-        let style = TerminalElement::make_text_style(cx.font_cache(), settings);
-
-        //TODO:
-        //We want markdown style highlighting so we can format the program and working directory with ``
-        //We want a max-width of 75% with word-wrap
-        //We want to be able to select the text
-        //Want to be able to scroll if the error message is massive somehow (resiliency)
-
-        let program_text = {
-            match self.error.shell_to_string() {
-                Some(shell_txt) => format!("Shell Program: `{}`", shell_txt),
-                None => "No program specified".to_string(),
-            }
-        };
-
-        let directory_text = {
-            match self.error.directory.as_ref() {
-                Some(path) => format!("Working directory: `{}`", path.to_string_lossy()),
-                None => "No working directory specified".to_string(),
-            }
-        };
-
-        let error_text = self.error.source.to_string();
-
-        Flex::column()
-            .with_child(
-                Text::new("Failed to open the terminal.".to_string(), style.clone())
-                    .contained()
-                    .boxed(),
-            )
-            .with_child(Text::new(program_text, style.clone()).contained().boxed())
-            .with_child(Text::new(directory_text, style.clone()).contained().boxed())
-            .with_child(Text::new(error_text, style).contained().boxed())
-            .aligned()
-            .boxed()
-    }
-}
-
-impl Item for TerminalContainer {
-    fn tab_content(
-        &self,
-        _detail: Option<usize>,
-        tab_theme: &theme::Tab,
-        cx: &gpui::AppContext,
-    ) -> ElementBox {
-        let title = match &self.content {
-            TerminalContainerContent::Connected(connected) => connected
-                .read(cx)
-                .handle()
-                .read(cx)
-                .foreground_process_info
-                .as_ref()
-                .map(|fpi| {
-                    format!(
-                        "{} — {}",
-                        truncate_and_trailoff(
-                            &fpi.cwd
-                                .file_name()
-                                .map(|name| name.to_string_lossy().to_string())
-                                .unwrap_or_default(),
-                            25
-                        ),
-                        truncate_and_trailoff(
-                            &{
-                                format!(
-                                    "{}{}",
-                                    fpi.name,
-                                    if fpi.argv.len() >= 1 {
-                                        format!(" {}", (&fpi.argv[1..]).join(" "))
-                                    } else {
-                                        "".to_string()
-                                    }
-                                )
-                            },
-                            25
-                        )
-                    )
-                })
-                .unwrap_or_else(|| "Terminal".to_string()),
-            TerminalContainerContent::Error(_) => "Terminal".to_string(),
-        };
-
-        Flex::row()
-            .with_child(
-                Label::new(title, tab_theme.label.clone())
-                    .aligned()
-                    .contained()
-                    .boxed(),
-            )
-            .boxed()
-    }
-
-    fn clone_on_split(
-        &self,
-        workspace_id: WorkspaceId,
-        cx: &mut ViewContext<Self>,
-    ) -> Option<Self> {
-        //From what I can tell, there's no  way to tell the current working
-        //Directory of the terminal from outside the shell. There might be
-        //solutions to this, but they are non-trivial and require more IPC
-        Some(TerminalContainer::new(
-            self.associated_directory.clone(),
-            false,
-            workspace_id,
-            cx,
-        ))
-    }
-
-    fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
-        None
-    }
-
-    fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
-        SmallVec::new()
-    }
-
-    fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
-        false
-    }
-
-    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
-
-    fn can_save(&self, _cx: &gpui::AppContext) -> bool {
-        false
-    }
-
-    fn save(
-        &mut self,
-        _project: gpui::ModelHandle<Project>,
-        _cx: &mut ViewContext<Self>,
-    ) -> gpui::Task<gpui::anyhow::Result<()>> {
-        unreachable!("save should not have been called");
-    }
-
-    fn save_as(
-        &mut self,
-        _project: gpui::ModelHandle<Project>,
-        _abs_path: std::path::PathBuf,
-        _cx: &mut ViewContext<Self>,
-    ) -> gpui::Task<gpui::anyhow::Result<()>> {
-        unreachable!("save_as should not have been called");
-    }
-
-    fn reload(
-        &mut self,
-        _project: gpui::ModelHandle<Project>,
-        _cx: &mut ViewContext<Self>,
-    ) -> gpui::Task<gpui::anyhow::Result<()>> {
-        gpui::Task::ready(Ok(()))
-    }
-
-    fn is_dirty(&self, cx: &gpui::AppContext) -> bool {
-        if let TerminalContainerContent::Connected(connected) = &self.content {
-            connected.read(cx).has_bell()
-        } else {
-            false
-        }
-    }
-
-    fn has_conflict(&self, _cx: &AppContext) -> bool {
-        false
-    }
-
-    fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
-        Some(Box::new(handle.clone()))
-    }
-
-    fn to_item_events(event: &Self::Event) -> Vec<ItemEvent> {
-        match event {
-            Event::BreadcrumbsChanged => vec![ItemEvent::UpdateBreadcrumbs],
-            Event::TitleChanged | Event::Wakeup => vec![ItemEvent::UpdateTab],
-            Event::CloseTerminal => vec![ItemEvent::CloseItem],
-            _ => vec![],
-        }
-    }
-
-    fn breadcrumb_location(&self) -> ToolbarItemLocation {
-        if self.connected().is_some() {
-            ToolbarItemLocation::PrimaryLeft { flex: None }
-        } else {
-            ToolbarItemLocation::Hidden
-        }
-    }
-
-    fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<ElementBox>> {
-        let connected = self.connected()?;
-
-        Some(vec![Text::new(
-            connected
-                .read(cx)
-                .terminal()
-                .read(cx)
-                .breadcrumb_text
-                .to_string(),
-            theme.breadcrumbs.text.clone(),
-        )
-        .boxed()])
-    }
-
-    fn serialized_item_kind() -> Option<&'static str> {
-        Some("Terminal")
-    }
-
-    fn deserialize(
-        _project: ModelHandle<Project>,
-        _workspace: WeakViewHandle<Workspace>,
-        workspace_id: workspace::WorkspaceId,
-        item_id: workspace::ItemId,
-        cx: &mut ViewContext<Pane>,
-    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
-        let working_directory = TERMINAL_CONNECTION.get_working_directory(item_id, workspace_id);
-        Task::ready(Ok(cx.add_view(|cx| {
-            TerminalContainer::new(
-                working_directory.log_err().flatten(),
-                false,
-                workspace_id,
-                cx,
-            )
-        })))
-    }
-
-    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
-        if let Some(connected) = self.connected() {
-            let id = workspace.database_id();
-            let terminal_handle = connected.read(cx).terminal().clone();
-            terminal_handle.update(cx, |terminal, cx| terminal.set_workspace_id(id, cx))
-        }
-    }
-}
-
-impl SearchableItem for TerminalContainer {
-    type Match = RangeInclusive<Point>;
-
-    fn supported_options() -> SearchOptions {
-        SearchOptions {
-            case: false,
-            word: false,
-            regex: false,
-        }
-    }
-
-    /// Convert events raised by this item into search-relevant events (if applicable)
-    fn to_search_event(event: &Self::Event) -> Option<SearchEvent> {
-        match event {
-            Event::Wakeup => Some(SearchEvent::MatchesInvalidated),
-            Event::SelectionsChanged => Some(SearchEvent::ActiveMatchChanged),
-            _ => None,
-        }
-    }
-
-    /// Clear stored matches
-    fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
-        if let TerminalContainerContent::Connected(connected) = &self.content {
-            let terminal = connected.read(cx).terminal().clone();
-            terminal.update(cx, |term, _| term.matches.clear())
-        }
-    }
-
-    /// Store matches returned from find_matches somewhere for rendering
-    fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
-        if let TerminalContainerContent::Connected(connected) = &self.content {
-            let terminal = connected.read(cx).terminal().clone();
-            terminal.update(cx, |term, _| term.matches = matches)
-        }
-    }
-
-    /// Return the selection content to pre-load into this search
-    fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
-        if let TerminalContainerContent::Connected(connected) = &self.content {
-            let terminal = connected.read(cx).terminal().clone();
-            terminal
-                .read(cx)
-                .last_content
-                .selection_text
-                .clone()
-                .unwrap_or_default()
-        } else {
-            Default::default()
-        }
-    }
-
-    /// Focus match at given index into the Vec of matches
-    fn activate_match(&mut self, index: usize, _: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
-        if let TerminalContainerContent::Connected(connected) = &self.content {
-            let terminal = connected.read(cx).terminal().clone();
-            terminal.update(cx, |term, _| term.activate_match(index));
-            cx.notify();
-        }
-    }
-
-    /// Get all of the matches for this query, should be done on the background
-    fn find_matches(
-        &mut self,
-        query: project::search::SearchQuery,
-        cx: &mut ViewContext<Self>,
-    ) -> Task<Vec<Self::Match>> {
-        if let TerminalContainerContent::Connected(connected) = &self.content {
-            let terminal = connected.read(cx).terminal().clone();
-            terminal.update(cx, |term, cx| term.find_matches(query, cx))
-        } else {
-            Task::ready(Vec::new())
-        }
-    }
-
-    /// Reports back to the search toolbar what the active match should be (the selection)
-    fn active_match_index(
-        &mut self,
-        matches: Vec<Self::Match>,
-        cx: &mut ViewContext<Self>,
-    ) -> Option<usize> {
-        let connected = self.connected();
-        // Selection head might have a value if there's a selection that isn't
-        // associated with a match. Therefore, if there are no matches, we should
-        // report None, no matter the state of the terminal
-        let res = if matches.len() > 0 && connected.is_some() {
-            if let Some(selection_head) = connected
-                .unwrap()
-                .read(cx)
-                .terminal()
-                .read(cx)
-                .selection_head
-            {
-                // If selection head is contained in a match. Return that match
-                if let Some(ix) = matches
-                    .iter()
-                    .enumerate()
-                    .find(|(_, search_match)| {
-                        search_match.contains(&selection_head)
-                            || search_match.start() > &selection_head
-                    })
-                    .map(|(ix, _)| ix)
-                {
-                    Some(ix)
-                } else {
-                    // If no selection after selection head, return the last match
-                    Some(matches.len().saturating_sub(1))
-                }
-            } else {
-                // Matches found but no active selection, return the first last one (closest to cursor)
-                Some(matches.len().saturating_sub(1))
-            }
-        } else {
-            None
-        };
-
-        res
-    }
-}
-
-///Get's the working directory for the given workspace, respecting the user's settings.
-pub fn get_working_directory(
-    workspace: &Workspace,
-    cx: &AppContext,
-    strategy: WorkingDirectory,
-) -> Option<PathBuf> {
-    let res = match strategy {
-        WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
-            .or_else(|| first_project_directory(workspace, cx)),
-        WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
-        WorkingDirectory::AlwaysHome => None,
-        WorkingDirectory::Always { directory } => {
-            shellexpand::full(&directory) //TODO handle this better
-                .ok()
-                .map(|dir| Path::new(&dir.to_string()).to_path_buf())
-                .filter(|dir| dir.is_dir())
-        }
-    };
-    res.or_else(home_dir)
-}
-
-///Get's the first project's home directory, or the home directory
-fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
-    workspace
-        .worktrees(cx)
-        .next()
-        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
-        .and_then(get_path_from_wt)
-}
-
-///Gets the intuitively correct working directory from the given workspace
-///If there is an active entry for this project, returns that entry's worktree root.
-///If there's no active entry but there is a worktree, returns that worktrees root.
-///If either of these roots are files, or if there are any other query failures,
-///  returns the user's home directory
-fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
-    let project = workspace.project().read(cx);
-
-    project
-        .active_entry()
-        .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
-        .or_else(|| workspace.worktrees(cx).next())
-        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
-        .and_then(get_path_from_wt)
-}
-
-fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
-    wt.root_entry()
-        .filter(|re| re.is_dir())
-        .map(|_| wt.abs_path().to_path_buf())
-}
-
-#[cfg(test)]
-mod tests {
-
-    use super::*;
-    use gpui::TestAppContext;
-
-    use std::path::Path;
-
-    use crate::tests::terminal_test_context::TerminalTestContext;
-
-    ///Working directory calculation tests
-
-    ///No Worktrees in project -> home_dir()
-    #[gpui::test]
-    async fn no_worktree(cx: &mut TestAppContext) {
-        //Setup variables
-        let mut cx = TerminalTestContext::new(cx);
-        let (project, workspace) = cx.blank_workspace().await;
-        //Test
-        cx.cx.read(|cx| {
-            let workspace = workspace.read(cx);
-            let active_entry = project.read(cx).active_entry();
-
-            //Make sure enviroment is as expeted
-            assert!(active_entry.is_none());
-            assert!(workspace.worktrees(cx).next().is_none());
-
-            let res = current_project_directory(workspace, cx);
-            assert_eq!(res, None);
-            let res = first_project_directory(workspace, cx);
-            assert_eq!(res, None);
-        });
-    }
-
-    ///No active entry, but a worktree, worktree is a file -> home_dir()
-    #[gpui::test]
-    async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
-        //Setup variables
-
-        let mut cx = TerminalTestContext::new(cx);
-        let (project, workspace) = cx.blank_workspace().await;
-        cx.create_file_wt(project.clone(), "/root.txt").await;
-
-        cx.cx.read(|cx| {
-            let workspace = workspace.read(cx);
-            let active_entry = project.read(cx).active_entry();
-
-            //Make sure enviroment is as expeted
-            assert!(active_entry.is_none());
-            assert!(workspace.worktrees(cx).next().is_some());
-
-            let res = current_project_directory(workspace, cx);
-            assert_eq!(res, None);
-            let res = first_project_directory(workspace, cx);
-            assert_eq!(res, None);
-        });
-    }
-
-    //No active entry, but a worktree, worktree is a folder -> worktree_folder
-    #[gpui::test]
-    async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
-        //Setup variables
-        let mut cx = TerminalTestContext::new(cx);
-        let (project, workspace) = cx.blank_workspace().await;
-        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root/").await;
-
-        //Test
-        cx.cx.update(|cx| {
-            let workspace = workspace.read(cx);
-            let active_entry = project.read(cx).active_entry();
-
-            assert!(active_entry.is_none());
-            assert!(workspace.worktrees(cx).next().is_some());
-
-            let res = current_project_directory(workspace, cx);
-            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
-            let res = first_project_directory(workspace, cx);
-            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
-        });
-    }
-
-    //Active entry with a work tree, worktree is a file -> home_dir()
-    #[gpui::test]
-    async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
-        //Setup variables
-        let mut cx = TerminalTestContext::new(cx);
-        let (project, workspace) = cx.blank_workspace().await;
-        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
-        let (wt2, entry2) = cx.create_file_wt(project.clone(), "/root2.txt").await;
-        cx.insert_active_entry_for(wt2, entry2, project.clone());
-
-        //Test
-        cx.cx.update(|cx| {
-            let workspace = workspace.read(cx);
-            let active_entry = project.read(cx).active_entry();
-
-            assert!(active_entry.is_some());
-
-            let res = current_project_directory(workspace, cx);
-            assert_eq!(res, None);
-            let res = first_project_directory(workspace, cx);
-            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
-        });
-    }
-
-    //Active entry, with a worktree, worktree is a folder -> worktree_folder
-    #[gpui::test]
-    async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
-        //Setup variables
-        let mut cx = TerminalTestContext::new(cx);
-        let (project, workspace) = cx.blank_workspace().await;
-        let (_wt, _entry) = cx.create_folder_wt(project.clone(), "/root1/").await;
-        let (wt2, entry2) = cx.create_folder_wt(project.clone(), "/root2/").await;
-        cx.insert_active_entry_for(wt2, entry2, project.clone());
-
-        //Test
-        cx.cx.update(|cx| {
-            let workspace = workspace.read(cx);
-            let active_entry = project.read(cx).active_entry();
-
-            assert!(active_entry.is_some());
-
-            let res = current_project_directory(workspace, cx);
-            assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
-            let res = first_project_directory(workspace, cx);
-            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
-        });
-    }
-}

crates/terminal/src/terminal_view.rs 🔗

@@ -1,471 +0,0 @@
-use std::{ops::RangeInclusive, time::Duration};
-
-use alacritty_terminal::{index::Point, term::TermMode};
-use context_menu::{ContextMenu, ContextMenuItem};
-use gpui::{
-    actions,
-    elements::{AnchorCorner, ChildView, ParentElement, Stack},
-    geometry::vector::Vector2F,
-    impl_actions, impl_internal_actions,
-    keymap::Keystroke,
-    AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task,
-    View, ViewContext, ViewHandle,
-};
-use serde::Deserialize;
-use settings::{Settings, TerminalBlink};
-use smol::Timer;
-use util::ResultExt;
-use workspace::pane;
-
-use crate::{terminal_element::TerminalElement, Event, Terminal};
-
-const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
-
-///Event to transmit the scroll from the element to the view
-#[derive(Clone, Debug, PartialEq)]
-pub struct ScrollTerminal(pub i32);
-
-#[derive(Clone, PartialEq)]
-pub struct DeployContextMenu {
-    pub position: Vector2F,
-}
-
-#[derive(Clone, Default, Deserialize, PartialEq)]
-pub struct SendText(String);
-
-#[derive(Clone, Default, Deserialize, PartialEq)]
-pub struct SendKeystroke(String);
-
-actions!(
-    terminal,
-    [Clear, Copy, Paste, ShowCharacterPalette, SearchTest]
-);
-
-impl_actions!(terminal, [SendText, SendKeystroke]);
-
-impl_internal_actions!(project_panel, [DeployContextMenu]);
-
-pub fn init(cx: &mut MutableAppContext) {
-    //Useful terminal views
-    cx.add_action(TerminalView::send_text);
-    cx.add_action(TerminalView::send_keystroke);
-    cx.add_action(TerminalView::deploy_context_menu);
-    cx.add_action(TerminalView::copy);
-    cx.add_action(TerminalView::paste);
-    cx.add_action(TerminalView::clear);
-    cx.add_action(TerminalView::show_character_palette);
-}
-
-///A terminal view, maintains the PTY's file handles and communicates with the terminal
-pub struct TerminalView {
-    terminal: ModelHandle<Terminal>,
-    has_new_content: bool,
-    //Currently using iTerm bell, show bell emoji in tab until input is received
-    has_bell: bool,
-    // Only for styling purposes. Doesn't effect behavior
-    modal: bool,
-    context_menu: ViewHandle<ContextMenu>,
-    blink_state: bool,
-    blinking_on: bool,
-    blinking_paused: bool,
-    blink_epoch: usize,
-}
-
-impl Entity for TerminalView {
-    type Event = Event;
-}
-
-impl TerminalView {
-    pub fn from_terminal(
-        terminal: ModelHandle<Terminal>,
-        modal: bool,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        cx.observe(&terminal, |_, _, cx| cx.notify()).detach();
-        cx.subscribe(&terminal, |this, _, event, cx| match event {
-            Event::Wakeup => {
-                if !cx.is_self_focused() {
-                    this.has_new_content = true;
-                    cx.notify();
-                }
-                cx.emit(Event::Wakeup);
-            }
-            Event::Bell => {
-                this.has_bell = true;
-                cx.emit(Event::Wakeup);
-            }
-            Event::BlinkChanged => this.blinking_on = !this.blinking_on,
-            _ => cx.emit(*event),
-        })
-        .detach();
-
-        Self {
-            terminal,
-            has_new_content: true,
-            has_bell: false,
-            modal,
-            context_menu: cx.add_view(ContextMenu::new),
-            blink_state: true,
-            blinking_on: false,
-            blinking_paused: false,
-            blink_epoch: 0,
-        }
-    }
-
-    pub fn handle(&self) -> ModelHandle<Terminal> {
-        self.terminal.clone()
-    }
-
-    pub fn has_new_content(&self) -> bool {
-        self.has_new_content
-    }
-
-    pub fn has_bell(&self) -> bool {
-        self.has_bell
-    }
-
-    pub fn clear_bel(&mut self, cx: &mut ViewContext<TerminalView>) {
-        self.has_bell = false;
-        cx.emit(Event::Wakeup);
-    }
-
-    pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
-        let menu_entries = vec![
-            ContextMenuItem::item("Clear", Clear),
-            ContextMenuItem::item("Close", pane::CloseActiveItem),
-        ];
-
-        self.context_menu.update(cx, |menu, cx| {
-            menu.show(action.position, AnchorCorner::TopLeft, menu_entries, cx)
-        });
-
-        cx.notify();
-    }
-
-    fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext<Self>) {
-        if !self
-            .terminal
-            .read(cx)
-            .last_content
-            .mode
-            .contains(TermMode::ALT_SCREEN)
-        {
-            cx.show_character_palette();
-        } else {
-            self.terminal.update(cx, |term, cx| {
-                term.try_keystroke(
-                    &Keystroke::parse("ctrl-cmd-space").unwrap(),
-                    cx.global::<Settings>()
-                        .terminal_overrides
-                        .option_as_meta
-                        .unwrap_or(false),
-                )
-            });
-        }
-    }
-
-    fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
-        self.terminal.update(cx, |term, _| term.clear());
-        cx.notify();
-    }
-
-    pub fn should_show_cursor(
-        &self,
-        focused: bool,
-        cx: &mut gpui::RenderContext<'_, Self>,
-    ) -> bool {
-        //Don't blink the cursor when not focused, blinking is disabled, or paused
-        if !focused
-            || !self.blinking_on
-            || self.blinking_paused
-            || self
-                .terminal
-                .read(cx)
-                .last_content
-                .mode
-                .contains(TermMode::ALT_SCREEN)
-        {
-            return true;
-        }
-
-        let setting = {
-            let settings = cx.global::<Settings>();
-            settings
-                .terminal_overrides
-                .blinking
-                .clone()
-                .unwrap_or(TerminalBlink::TerminalControlled)
-        };
-
-        match setting {
-            //If the user requested to never blink, don't blink it.
-            TerminalBlink::Off => true,
-            //If the terminal is controlling it, check terminal mode
-            TerminalBlink::TerminalControlled | TerminalBlink::On => self.blink_state,
-        }
-    }
-
-    fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
-        if epoch == self.blink_epoch && !self.blinking_paused {
-            self.blink_state = !self.blink_state;
-            cx.notify();
-
-            let epoch = self.next_blink_epoch();
-            cx.spawn(|this, mut cx| {
-                let this = this.downgrade();
-                async move {
-                    Timer::after(CURSOR_BLINK_INTERVAL).await;
-                    if let Some(this) = this.upgrade(&cx) {
-                        this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx));
-                    }
-                }
-            })
-            .detach();
-        }
-    }
-
-    pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext<Self>) {
-        self.blink_state = true;
-        cx.notify();
-
-        let epoch = self.next_blink_epoch();
-        cx.spawn(|this, mut cx| {
-            let this = this.downgrade();
-            async move {
-                Timer::after(CURSOR_BLINK_INTERVAL).await;
-                if let Some(this) = this.upgrade(&cx) {
-                    this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx))
-                }
-            }
-        })
-        .detach();
-    }
-
-    pub fn find_matches(
-        &mut self,
-        query: project::search::SearchQuery,
-        cx: &mut ViewContext<Self>,
-    ) -> Task<Vec<RangeInclusive<Point>>> {
-        self.terminal
-            .update(cx, |term, cx| term.find_matches(query, cx))
-    }
-
-    pub fn terminal(&self) -> &ModelHandle<Terminal> {
-        &self.terminal
-    }
-
-    fn next_blink_epoch(&mut self) -> usize {
-        self.blink_epoch += 1;
-        self.blink_epoch
-    }
-
-    fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
-        if epoch == self.blink_epoch {
-            self.blinking_paused = false;
-            self.blink_cursors(epoch, cx);
-        }
-    }
-
-    ///Attempt to paste the clipboard into the terminal
-    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
-        self.terminal.update(cx, |term, _| term.copy())
-    }
-
-    ///Attempt to paste the clipboard into the terminal
-    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
-        if let Some(item) = cx.read_from_clipboard() {
-            self.terminal
-                .update(cx, |terminal, _cx| terminal.paste(item.text()));
-        }
-    }
-
-    fn send_text(&mut self, text: &SendText, cx: &mut ViewContext<Self>) {
-        self.clear_bel(cx);
-        self.terminal.update(cx, |term, _| {
-            term.input(text.0.to_string());
-        });
-    }
-
-    fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext<Self>) {
-        if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
-            self.clear_bel(cx);
-            self.terminal.update(cx, |term, cx| {
-                term.try_keystroke(
-                    &keystroke,
-                    cx.global::<Settings>()
-                        .terminal_overrides
-                        .option_as_meta
-                        .unwrap_or(false),
-                );
-            });
-        }
-    }
-}
-
-impl View for TerminalView {
-    fn ui_name() -> &'static str {
-        "Terminal"
-    }
-
-    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
-        let terminal_handle = self.terminal.clone().downgrade();
-
-        let self_id = cx.view_id();
-        let focused = cx
-            .focused_view_id(cx.window_id())
-            .filter(|view_id| *view_id == self_id)
-            .is_some();
-
-        Stack::new()
-            .with_child(
-                TerminalElement::new(
-                    cx.handle(),
-                    terminal_handle,
-                    focused,
-                    self.should_show_cursor(focused, cx),
-                )
-                .contained()
-                .boxed(),
-            )
-            .with_child(ChildView::new(&self.context_menu, cx).boxed())
-            .boxed()
-    }
-
-    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
-        self.has_new_content = false;
-        self.terminal.read(cx).focus_in();
-        self.blink_cursors(self.blink_epoch, cx);
-        cx.notify();
-    }
-
-    fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
-        self.terminal.update(cx, |terminal, _| {
-            terminal.focus_out();
-        });
-        cx.notify();
-    }
-
-    fn key_down(&mut self, event: &gpui::KeyDownEvent, cx: &mut ViewContext<Self>) -> bool {
-        self.clear_bel(cx);
-        self.pause_cursor_blinking(cx);
-
-        self.terminal.update(cx, |term, cx| {
-            term.try_keystroke(
-                &event.keystroke,
-                cx.global::<Settings>()
-                    .terminal_overrides
-                    .option_as_meta
-                    .unwrap_or(false),
-            )
-        })
-    }
-
-    //IME stuff
-    fn selected_text_range(&self, cx: &AppContext) -> Option<std::ops::Range<usize>> {
-        if self
-            .terminal
-            .read(cx)
-            .last_content
-            .mode
-            .contains(TermMode::ALT_SCREEN)
-        {
-            None
-        } else {
-            Some(0..0)
-        }
-    }
-
-    fn replace_text_in_range(
-        &mut self,
-        _: Option<std::ops::Range<usize>>,
-        text: &str,
-        cx: &mut ViewContext<Self>,
-    ) {
-        self.terminal.update(cx, |terminal, _| {
-            terminal.input(text.into());
-        });
-    }
-
-    fn keymap_context(&self, cx: &gpui::AppContext) -> gpui::keymap::Context {
-        let mut context = Self::default_keymap_context();
-        if self.modal {
-            context.set.insert("ModalTerminal".into());
-        }
-        let mode = self.terminal.read(cx).last_content.mode;
-        context.map.insert(
-            "screen".to_string(),
-            (if mode.contains(TermMode::ALT_SCREEN) {
-                "alt"
-            } else {
-                "normal"
-            })
-            .to_string(),
-        );
-
-        if mode.contains(TermMode::APP_CURSOR) {
-            context.set.insert("DECCKM".to_string());
-        }
-        if mode.contains(TermMode::APP_KEYPAD) {
-            context.set.insert("DECPAM".to_string());
-        }
-        //Note the ! here
-        if !mode.contains(TermMode::APP_KEYPAD) {
-            context.set.insert("DECPNM".to_string());
-        }
-        if mode.contains(TermMode::SHOW_CURSOR) {
-            context.set.insert("DECTCEM".to_string());
-        }
-        if mode.contains(TermMode::LINE_WRAP) {
-            context.set.insert("DECAWM".to_string());
-        }
-        if mode.contains(TermMode::ORIGIN) {
-            context.set.insert("DECOM".to_string());
-        }
-        if mode.contains(TermMode::INSERT) {
-            context.set.insert("IRM".to_string());
-        }
-        //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
-        if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
-            context.set.insert("LNM".to_string());
-        }
-        if mode.contains(TermMode::FOCUS_IN_OUT) {
-            context.set.insert("report_focus".to_string());
-        }
-        if mode.contains(TermMode::ALTERNATE_SCROLL) {
-            context.set.insert("alternate_scroll".to_string());
-        }
-        if mode.contains(TermMode::BRACKETED_PASTE) {
-            context.set.insert("bracketed_paste".to_string());
-        }
-        if mode.intersects(TermMode::MOUSE_MODE) {
-            context.set.insert("any_mouse_reporting".to_string());
-        }
-        {
-            let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
-                "click"
-            } else if mode.contains(TermMode::MOUSE_DRAG) {
-                "drag"
-            } else if mode.contains(TermMode::MOUSE_MOTION) {
-                "motion"
-            } else {
-                "off"
-            };
-            context
-                .map
-                .insert("mouse_reporting".to_string(), mouse_reporting.to_string());
-        }
-        {
-            let format = if mode.contains(TermMode::SGR_MOUSE) {
-                "sgr"
-            } else if mode.contains(TermMode::UTF8_MOUSE) {
-                "utf8"
-            } else {
-                "normal"
-            };
-            context
-                .map
-                .insert("mouse_format".to_string(), format.to_string());
-        }
-        context
-    }
-}

crates/terminal/src/tests/terminal_test_context.rs 🔗

@@ -1,143 +0,0 @@
-use std::{path::Path, time::Duration};
-
-use alacritty_terminal::{
-    index::{Column, Line, Point},
-    term::cell::Cell,
-};
-use gpui::{ModelHandle, TestAppContext, ViewHandle};
-
-use project::{Entry, Project, ProjectPath, Worktree};
-use rand::{rngs::ThreadRng, Rng};
-use workspace::{AppState, Workspace};
-
-use crate::{IndexedCell, TerminalContent, TerminalSize};
-
-pub struct TerminalTestContext<'a> {
-    pub cx: &'a mut TestAppContext,
-}
-
-impl<'a> TerminalTestContext<'a> {
-    pub fn new(cx: &'a mut TestAppContext) -> Self {
-        cx.set_condition_duration(Some(Duration::from_secs(5)));
-
-        TerminalTestContext { cx }
-    }
-
-    ///Creates a worktree with 1 file: /root.txt
-    pub async fn blank_workspace(&mut self) -> (ModelHandle<Project>, ViewHandle<Workspace>) {
-        let params = self.cx.update(AppState::test);
-
-        let project = Project::test(params.fs.clone(), [], self.cx).await;
-        let (_, workspace) = self.cx.add_window(|cx| {
-            Workspace::new(
-                Default::default(),
-                0,
-                project.clone(),
-                |_, _| unimplemented!(),
-                cx,
-            )
-        });
-
-        (project, workspace)
-    }
-
-    ///Creates a worktree with 1 folder: /root{suffix}/
-    pub async fn create_folder_wt(
-        &mut self,
-        project: ModelHandle<Project>,
-        path: impl AsRef<Path>,
-    ) -> (ModelHandle<Worktree>, Entry) {
-        self.create_wt(project, true, path).await
-    }
-
-    ///Creates a worktree with 1 file: /root{suffix}.txt
-    pub async fn create_file_wt(
-        &mut self,
-        project: ModelHandle<Project>,
-        path: impl AsRef<Path>,
-    ) -> (ModelHandle<Worktree>, Entry) {
-        self.create_wt(project, false, path).await
-    }
-
-    async fn create_wt(
-        &mut self,
-        project: ModelHandle<Project>,
-        is_dir: bool,
-        path: impl AsRef<Path>,
-    ) -> (ModelHandle<Worktree>, Entry) {
-        let (wt, _) = project
-            .update(self.cx, |project, cx| {
-                project.find_or_create_local_worktree(path, true, cx)
-            })
-            .await
-            .unwrap();
-
-        let entry = self
-            .cx
-            .update(|cx| {
-                wt.update(cx, |wt, cx| {
-                    wt.as_local()
-                        .unwrap()
-                        .create_entry(Path::new(""), is_dir, cx)
-                })
-            })
-            .await
-            .unwrap();
-
-        (wt, entry)
-    }
-
-    pub fn insert_active_entry_for(
-        &mut self,
-        wt: ModelHandle<Worktree>,
-        entry: Entry,
-        project: ModelHandle<Project>,
-    ) {
-        self.cx.update(|cx| {
-            let p = ProjectPath {
-                worktree_id: wt.read(cx).id(),
-                path: entry.path,
-            };
-            project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
-        });
-    }
-
-    pub fn create_terminal_content(
-        size: TerminalSize,
-        rng: &mut ThreadRng,
-    ) -> (TerminalContent, Vec<Vec<char>>) {
-        let mut ic = Vec::new();
-        let mut cells = Vec::new();
-
-        for row in 0..((size.height() / size.line_height()) as usize) {
-            let mut row_vec = Vec::new();
-            for col in 0..((size.width() / size.cell_width()) as usize) {
-                let cell_char = rng.gen();
-                ic.push(IndexedCell {
-                    point: Point::new(Line(row as i32), Column(col)),
-                    cell: Cell {
-                        c: cell_char,
-                        ..Default::default()
-                    },
-                });
-                row_vec.push(cell_char)
-            }
-            cells.push(row_vec)
-        }
-
-        (
-            TerminalContent {
-                cells: ic,
-                size,
-                ..Default::default()
-            },
-            cells,
-        )
-    }
-}
-
-impl<'a> Drop for TerminalTestContext<'a> {
-    fn drop(&mut self) {
-        self.cx.set_condition_duration(None);
-    }
-}

crates/terminal_view/Cargo.toml 🔗

@@ -0,0 +1,44 @@
+[package]
+name = "terminal_view"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+path = "src/terminal_view.rs"
+doctest = false
+
+[dependencies]
+context_menu = { path = "../context_menu" }
+editor = { path = "../editor" }
+language = { path = "../language" }
+gpui = { path = "../gpui" }
+project = { path = "../project" }
+settings = { path = "../settings" }
+theme = { path = "../theme" }
+util = { path = "../util" }
+workspace = { path = "../workspace" }
+db = { path = "../db" }
+procinfo = { git = "https://github.com/zed-industries/wezterm", rev = "5cd757e5f2eb039ed0c6bb6512223e69d5efc64d", default-features = false }
+terminal = { path = "../terminal" }
+smallvec = { version = "1.6", features = ["union"] }
+smol = "1.2.5"
+mio-extras = "2.0.6"
+futures = "0.3"
+ordered-float = "2.1.1"
+itertools = "0.10"
+dirs = "4.0.0"
+shellexpand = "2.1.0"
+libc = "0.2"
+anyhow = "1"
+thiserror = "1.0"
+lazy_static = "1.4.0"
+serde = { version = "1.0", features = ["derive"] }
+
+
+
+[dev-dependencies]
+gpui = { path = "../gpui", features = ["test-support"] }
+client = { path = "../client", features = ["test-support"]}
+project = { path = "../project", features = ["test-support"]}
+workspace = { path = "../workspace", features = ["test-support"] }
+rand = "0.8.5"

crates/terminal/src/persistence.rs → crates/terminal_view/src/persistence.rs 🔗

@@ -1,11 +1,10 @@
 use std::path::PathBuf;
 
 use db::{define_connection, query, sqlez_macros::sql};
-
 use workspace::{ItemId, WorkspaceDb, WorkspaceId};
 
 define_connection! {
-    pub static ref TERMINAL_CONNECTION: TerminalDb<WorkspaceDb> =
+    pub static ref TERMINAL_DB: TerminalDb<WorkspaceDb> =
         &[sql!(
             CREATE TABLE terminals (
                 workspace_id INTEGER,
@@ -13,7 +12,7 @@ define_connection! {
                 working_directory BLOB,
                 PRIMARY KEY(workspace_id, item_id),
                 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
-                    ON DELETE CASCADE
+                ON DELETE CASCADE
             ) STRICT;
         )];
 }
@@ -43,10 +42,10 @@ impl TerminalDb {
     }
 
     query! {
-        pub fn get_working_directory(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
-            SELECT working_directory
-            FROM terminals
+        pub async fn take_working_directory(item_id: ItemId, workspace_id: WorkspaceId) -> Result<Option<PathBuf>> {
+            DELETE FROM terminals
             WHERE item_id = ? AND workspace_id = ?
+            RETURNING working_directory
         }
     }
 }

crates/terminal/src/terminal_element.rs → crates/terminal_view/src/terminal_element.rs 🔗

@@ -1,9 +1,3 @@
-use alacritty_terminal::{
-    ansi::{Color as AnsiColor, Color::Named, CursorShape as AlacCursorShape, NamedColor},
-    grid::Dimensions,
-    index::Point,
-    term::{cell::Flags, TermMode},
-};
 use editor::{Cursor, HighlightedRange, HighlightedRangeLine};
 use gpui::{
     color::Color,
@@ -22,17 +16,23 @@ use itertools::Itertools;
 use language::CursorShape;
 use ordered_float::OrderedFloat;
 use settings::Settings;
+use terminal::{
+    alacritty_terminal::{
+        ansi::{Color as AnsiColor, Color::Named, CursorShape as AlacCursorShape, NamedColor},
+        grid::Dimensions,
+        index::Point,
+        term::{cell::Flags, TermMode},
+    },
+    mappings::colors::convert_color,
+    IndexedCell, Terminal, TerminalContent, TerminalSize,
+};
 use theme::TerminalStyle;
 use util::ResultExt;
 
 use std::{fmt::Debug, ops::RangeInclusive};
 use std::{mem, ops::Range};
 
-use crate::{
-    mappings::colors::convert_color,
-    terminal_view::{DeployContextMenu, TerminalView},
-    IndexedCell, Terminal, TerminalContent, TerminalSize,
-};
+use crate::{DeployContextMenu, TerminalView};
 
 ///The information generated during layout that is nescessary for painting
 pub struct LayoutState {
@@ -299,7 +299,7 @@ impl TerminalElement {
     ///Convert the Alacritty cell styles to GPUI text styles and background color
     fn cell_style(
         indexed: &IndexedCell,
-        fg: AnsiColor,
+        fg: terminal::alacritty_terminal::ansi::Color,
         style: &TerminalStyle,
         text_style: &TextStyle,
         font_cache: &FontCache,

crates/terminal_view/src/terminal_view.rs 🔗

@@ -0,0 +1,1091 @@
+mod persistence;
+pub mod terminal_element;
+
+use std::{
+    ops::RangeInclusive,
+    path::{Path, PathBuf},
+    time::Duration,
+};
+
+use context_menu::{ContextMenu, ContextMenuItem};
+use dirs::home_dir;
+use gpui::{
+    actions,
+    elements::{AnchorCorner, ChildView, Flex, Label, ParentElement, Stack, Text},
+    geometry::vector::Vector2F,
+    impl_actions, impl_internal_actions,
+    keymap::Keystroke,
+    AnyViewHandle, AppContext, Element, ElementBox, Entity, ModelHandle, MutableAppContext, Task,
+    View, ViewContext, ViewHandle, WeakViewHandle,
+};
+use project::{LocalWorktree, Project, ProjectPath};
+use serde::Deserialize;
+use settings::{Settings, TerminalBlink, WorkingDirectory};
+use smallvec::SmallVec;
+use smol::Timer;
+use terminal::{
+    alacritty_terminal::{
+        index::Point,
+        term::{search::RegexSearch, TermMode},
+    },
+    Event, Terminal,
+};
+use util::{truncate_and_trailoff, ResultExt};
+use workspace::{
+    item::{Item, ItemEvent},
+    notifications::NotifyResultExt,
+    pane, register_deserializable_item,
+    searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle},
+    Pane, ToolbarItemLocation, Workspace, WorkspaceId,
+};
+
+use crate::{persistence::TERMINAL_DB, terminal_element::TerminalElement};
+
+const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500);
+
+///Event to transmit the scroll from the element to the view
+#[derive(Clone, Debug, PartialEq)]
+pub struct ScrollTerminal(pub i32);
+
+#[derive(Clone, PartialEq)]
+pub struct DeployContextMenu {
+    pub position: Vector2F,
+}
+
+#[derive(Clone, Default, Deserialize, PartialEq)]
+pub struct SendText(String);
+
+#[derive(Clone, Default, Deserialize, PartialEq)]
+pub struct SendKeystroke(String);
+
+actions!(
+    terminal,
+    [Clear, Copy, Paste, ShowCharacterPalette, SearchTest]
+);
+
+impl_actions!(terminal, [SendText, SendKeystroke]);
+
+impl_internal_actions!(project_panel, [DeployContextMenu]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(TerminalView::deploy);
+
+    register_deserializable_item::<TerminalView>(cx);
+
+    //Useful terminal views
+    cx.add_action(TerminalView::send_text);
+    cx.add_action(TerminalView::send_keystroke);
+    cx.add_action(TerminalView::deploy_context_menu);
+    cx.add_action(TerminalView::copy);
+    cx.add_action(TerminalView::paste);
+    cx.add_action(TerminalView::clear);
+    cx.add_action(TerminalView::show_character_palette);
+}
+
+///A terminal view, maintains the PTY's file handles and communicates with the terminal
+pub struct TerminalView {
+    terminal: ModelHandle<Terminal>,
+    has_new_content: bool,
+    //Currently using iTerm bell, show bell emoji in tab until input is received
+    has_bell: bool,
+    context_menu: ViewHandle<ContextMenu>,
+    blink_state: bool,
+    blinking_on: bool,
+    blinking_paused: bool,
+    blink_epoch: usize,
+    workspace_id: WorkspaceId,
+}
+
+impl Entity for TerminalView {
+    type Event = Event;
+}
+
+impl TerminalView {
+    ///Create a new Terminal in the current working directory or the user's home directory
+    pub fn deploy(
+        workspace: &mut Workspace,
+        _: &workspace::NewTerminal,
+        cx: &mut ViewContext<Workspace>,
+    ) {
+        let strategy = cx.global::<Settings>().terminal_strategy();
+
+        let working_directory = get_working_directory(workspace, cx, strategy);
+
+        let window_id = cx.window_id();
+        let terminal = workspace
+            .project()
+            .update(cx, |project, cx| {
+                project.create_terminal(working_directory, window_id, cx)
+            })
+            .notify_err(workspace, cx);
+
+        if let Some(terminal) = terminal {
+            let view = cx.add_view(|cx| TerminalView::new(terminal, workspace.database_id(), cx));
+            workspace.add_item(Box::new(view), cx)
+        }
+    }
+
+    pub fn new(
+        terminal: ModelHandle<Terminal>,
+        workspace_id: WorkspaceId,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        cx.observe(&terminal, |_, _, cx| cx.notify()).detach();
+        cx.subscribe(&terminal, |this, _, event, cx| match event {
+            Event::Wakeup => {
+                if !cx.is_self_focused() {
+                    this.has_new_content = true;
+                    cx.notify();
+                }
+                cx.emit(Event::Wakeup);
+            }
+            Event::Bell => {
+                this.has_bell = true;
+                cx.emit(Event::Wakeup);
+            }
+            Event::BlinkChanged => this.blinking_on = !this.blinking_on,
+            Event::TitleChanged => {
+                if let Some(foreground_info) = &this.terminal().read(cx).foreground_process_info {
+                    let cwd = foreground_info.cwd.clone();
+
+                    let item_id = cx.view_id();
+                    let workspace_id = this.workspace_id;
+                    cx.background()
+                        .spawn(async move {
+                            TERMINAL_DB
+                                .save_working_directory(item_id, workspace_id, cwd)
+                                .await
+                                .log_err();
+                        })
+                        .detach();
+                }
+            }
+            _ => cx.emit(*event),
+        })
+        .detach();
+
+        Self {
+            terminal,
+            has_new_content: true,
+            has_bell: false,
+            context_menu: cx.add_view(ContextMenu::new),
+            blink_state: true,
+            blinking_on: false,
+            blinking_paused: false,
+            blink_epoch: 0,
+            workspace_id,
+        }
+    }
+
+    pub fn handle(&self) -> ModelHandle<Terminal> {
+        self.terminal.clone()
+    }
+
+    pub fn has_new_content(&self) -> bool {
+        self.has_new_content
+    }
+
+    pub fn has_bell(&self) -> bool {
+        self.has_bell
+    }
+
+    pub fn clear_bel(&mut self, cx: &mut ViewContext<TerminalView>) {
+        self.has_bell = false;
+        cx.emit(Event::Wakeup);
+    }
+
+    pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext<Self>) {
+        let menu_entries = vec![
+            ContextMenuItem::item("Clear", Clear),
+            ContextMenuItem::item("Close", pane::CloseActiveItem),
+        ];
+
+        self.context_menu.update(cx, |menu, cx| {
+            menu.show(action.position, AnchorCorner::TopLeft, menu_entries, cx)
+        });
+
+        cx.notify();
+    }
+
+    fn show_character_palette(&mut self, _: &ShowCharacterPalette, cx: &mut ViewContext<Self>) {
+        if !self
+            .terminal
+            .read(cx)
+            .last_content
+            .mode
+            .contains(TermMode::ALT_SCREEN)
+        {
+            cx.show_character_palette();
+        } else {
+            self.terminal.update(cx, |term, cx| {
+                term.try_keystroke(
+                    &Keystroke::parse("ctrl-cmd-space").unwrap(),
+                    cx.global::<Settings>()
+                        .terminal_overrides
+                        .option_as_meta
+                        .unwrap_or(false),
+                )
+            });
+        }
+    }
+
+    fn clear(&mut self, _: &Clear, cx: &mut ViewContext<Self>) {
+        self.terminal.update(cx, |term, _| term.clear());
+        cx.notify();
+    }
+
+    pub fn should_show_cursor(
+        &self,
+        focused: bool,
+        cx: &mut gpui::RenderContext<'_, Self>,
+    ) -> bool {
+        //Don't blink the cursor when not focused, blinking is disabled, or paused
+        if !focused
+            || !self.blinking_on
+            || self.blinking_paused
+            || self
+                .terminal
+                .read(cx)
+                .last_content
+                .mode
+                .contains(TermMode::ALT_SCREEN)
+        {
+            return true;
+        }
+
+        let setting = {
+            let settings = cx.global::<Settings>();
+            settings
+                .terminal_overrides
+                .blinking
+                .clone()
+                .unwrap_or(TerminalBlink::TerminalControlled)
+        };
+
+        match setting {
+            //If the user requested to never blink, don't blink it.
+            TerminalBlink::Off => true,
+            //If the terminal is controlling it, check terminal mode
+            TerminalBlink::TerminalControlled | TerminalBlink::On => self.blink_state,
+        }
+    }
+
+    fn blink_cursors(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
+        if epoch == self.blink_epoch && !self.blinking_paused {
+            self.blink_state = !self.blink_state;
+            cx.notify();
+
+            let epoch = self.next_blink_epoch();
+            cx.spawn(|this, mut cx| {
+                let this = this.downgrade();
+                async move {
+                    Timer::after(CURSOR_BLINK_INTERVAL).await;
+                    if let Some(this) = this.upgrade(&cx) {
+                        this.update(&mut cx, |this, cx| this.blink_cursors(epoch, cx));
+                    }
+                }
+            })
+            .detach();
+        }
+    }
+
+    pub fn pause_cursor_blinking(&mut self, cx: &mut ViewContext<Self>) {
+        self.blink_state = true;
+        cx.notify();
+
+        let epoch = self.next_blink_epoch();
+        cx.spawn(|this, mut cx| {
+            let this = this.downgrade();
+            async move {
+                Timer::after(CURSOR_BLINK_INTERVAL).await;
+                if let Some(this) = this.upgrade(&cx) {
+                    this.update(&mut cx, |this, cx| this.resume_cursor_blinking(epoch, cx))
+                }
+            }
+        })
+        .detach();
+    }
+
+    pub fn find_matches(
+        &mut self,
+        query: project::search::SearchQuery,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Vec<RangeInclusive<Point>>> {
+        let searcher = regex_search_for_query(query);
+
+        if let Some(searcher) = searcher {
+            self.terminal
+                .update(cx, |term, cx| term.find_matches(searcher, cx))
+        } else {
+            cx.background().spawn(async { Vec::new() })
+        }
+    }
+
+    pub fn terminal(&self) -> &ModelHandle<Terminal> {
+        &self.terminal
+    }
+
+    fn next_blink_epoch(&mut self) -> usize {
+        self.blink_epoch += 1;
+        self.blink_epoch
+    }
+
+    fn resume_cursor_blinking(&mut self, epoch: usize, cx: &mut ViewContext<Self>) {
+        if epoch == self.blink_epoch {
+            self.blinking_paused = false;
+            self.blink_cursors(epoch, cx);
+        }
+    }
+
+    ///Attempt to paste the clipboard into the terminal
+    fn copy(&mut self, _: &Copy, cx: &mut ViewContext<Self>) {
+        self.terminal.update(cx, |term, _| term.copy())
+    }
+
+    ///Attempt to paste the clipboard into the terminal
+    fn paste(&mut self, _: &Paste, cx: &mut ViewContext<Self>) {
+        if let Some(item) = cx.read_from_clipboard() {
+            self.terminal
+                .update(cx, |terminal, _cx| terminal.paste(item.text()));
+        }
+    }
+
+    fn send_text(&mut self, text: &SendText, cx: &mut ViewContext<Self>) {
+        self.clear_bel(cx);
+        self.terminal.update(cx, |term, _| {
+            term.input(text.0.to_string());
+        });
+    }
+
+    fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext<Self>) {
+        if let Some(keystroke) = Keystroke::parse(&text.0).log_err() {
+            self.clear_bel(cx);
+            self.terminal.update(cx, |term, cx| {
+                term.try_keystroke(
+                    &keystroke,
+                    cx.global::<Settings>()
+                        .terminal_overrides
+                        .option_as_meta
+                        .unwrap_or(false),
+                );
+            });
+        }
+    }
+}
+
+pub fn regex_search_for_query(query: project::search::SearchQuery) -> Option<RegexSearch> {
+    let searcher = match query {
+        project::search::SearchQuery::Text { query, .. } => RegexSearch::new(&query),
+        project::search::SearchQuery::Regex { query, .. } => RegexSearch::new(&query),
+    };
+    searcher.ok()
+}
+
+impl View for TerminalView {
+    fn ui_name() -> &'static str {
+        "Terminal"
+    }
+
+    fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> ElementBox {
+        let terminal_handle = self.terminal.clone().downgrade();
+
+        let self_id = cx.view_id();
+        let focused = cx
+            .focused_view_id(cx.window_id())
+            .filter(|view_id| *view_id == self_id)
+            .is_some();
+
+        Stack::new()
+            .with_child(
+                TerminalElement::new(
+                    cx.handle(),
+                    terminal_handle,
+                    focused,
+                    self.should_show_cursor(focused, cx),
+                )
+                .contained()
+                .boxed(),
+            )
+            .with_child(ChildView::new(&self.context_menu, cx).boxed())
+            .boxed()
+    }
+
+    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.has_new_content = false;
+        self.terminal.read(cx).focus_in();
+        self.blink_cursors(self.blink_epoch, cx);
+        cx.notify();
+    }
+
+    fn focus_out(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
+        self.terminal.update(cx, |terminal, _| {
+            terminal.focus_out();
+        });
+        cx.notify();
+    }
+
+    fn key_down(&mut self, event: &gpui::KeyDownEvent, cx: &mut ViewContext<Self>) -> bool {
+        self.clear_bel(cx);
+        self.pause_cursor_blinking(cx);
+
+        self.terminal.update(cx, |term, cx| {
+            term.try_keystroke(
+                &event.keystroke,
+                cx.global::<Settings>()
+                    .terminal_overrides
+                    .option_as_meta
+                    .unwrap_or(false),
+            )
+        })
+    }
+
+    //IME stuff
+    fn selected_text_range(&self, cx: &AppContext) -> Option<std::ops::Range<usize>> {
+        if self
+            .terminal
+            .read(cx)
+            .last_content
+            .mode
+            .contains(TermMode::ALT_SCREEN)
+        {
+            None
+        } else {
+            Some(0..0)
+        }
+    }
+
+    fn replace_text_in_range(
+        &mut self,
+        _: Option<std::ops::Range<usize>>,
+        text: &str,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.terminal.update(cx, |terminal, _| {
+            terminal.input(text.into());
+        });
+    }
+
+    fn keymap_context(&self, cx: &gpui::AppContext) -> gpui::keymap::Context {
+        let mut context = Self::default_keymap_context();
+
+        let mode = self.terminal.read(cx).last_content.mode;
+        context.map.insert(
+            "screen".to_string(),
+            (if mode.contains(TermMode::ALT_SCREEN) {
+                "alt"
+            } else {
+                "normal"
+            })
+            .to_string(),
+        );
+
+        if mode.contains(TermMode::APP_CURSOR) {
+            context.set.insert("DECCKM".to_string());
+        }
+        if mode.contains(TermMode::APP_KEYPAD) {
+            context.set.insert("DECPAM".to_string());
+        }
+        //Note the ! here
+        if !mode.contains(TermMode::APP_KEYPAD) {
+            context.set.insert("DECPNM".to_string());
+        }
+        if mode.contains(TermMode::SHOW_CURSOR) {
+            context.set.insert("DECTCEM".to_string());
+        }
+        if mode.contains(TermMode::LINE_WRAP) {
+            context.set.insert("DECAWM".to_string());
+        }
+        if mode.contains(TermMode::ORIGIN) {
+            context.set.insert("DECOM".to_string());
+        }
+        if mode.contains(TermMode::INSERT) {
+            context.set.insert("IRM".to_string());
+        }
+        //LNM is apparently the name for this. https://vt100.net/docs/vt510-rm/LNM.html
+        if mode.contains(TermMode::LINE_FEED_NEW_LINE) {
+            context.set.insert("LNM".to_string());
+        }
+        if mode.contains(TermMode::FOCUS_IN_OUT) {
+            context.set.insert("report_focus".to_string());
+        }
+        if mode.contains(TermMode::ALTERNATE_SCROLL) {
+            context.set.insert("alternate_scroll".to_string());
+        }
+        if mode.contains(TermMode::BRACKETED_PASTE) {
+            context.set.insert("bracketed_paste".to_string());
+        }
+        if mode.intersects(TermMode::MOUSE_MODE) {
+            context.set.insert("any_mouse_reporting".to_string());
+        }
+        {
+            let mouse_reporting = if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
+                "click"
+            } else if mode.contains(TermMode::MOUSE_DRAG) {
+                "drag"
+            } else if mode.contains(TermMode::MOUSE_MOTION) {
+                "motion"
+            } else {
+                "off"
+            };
+            context
+                .map
+                .insert("mouse_reporting".to_string(), mouse_reporting.to_string());
+        }
+        {
+            let format = if mode.contains(TermMode::SGR_MOUSE) {
+                "sgr"
+            } else if mode.contains(TermMode::UTF8_MOUSE) {
+                "utf8"
+            } else {
+                "normal"
+            };
+            context
+                .map
+                .insert("mouse_format".to_string(), format.to_string());
+        }
+        context
+    }
+}
+
+impl Item for TerminalView {
+    fn tab_content(
+        &self,
+        _detail: Option<usize>,
+        tab_theme: &theme::Tab,
+        cx: &gpui::AppContext,
+    ) -> ElementBox {
+        let title = self
+            .terminal()
+            .read(cx)
+            .foreground_process_info
+            .as_ref()
+            .map(|fpi| {
+                format!(
+                    "{} — {}",
+                    truncate_and_trailoff(
+                        &fpi.cwd
+                            .file_name()
+                            .map(|name| name.to_string_lossy().to_string())
+                            .unwrap_or_default(),
+                        25
+                    ),
+                    truncate_and_trailoff(
+                        &{
+                            format!(
+                                "{}{}",
+                                fpi.name,
+                                if fpi.argv.len() >= 1 {
+                                    format!(" {}", (&fpi.argv[1..]).join(" "))
+                                } else {
+                                    "".to_string()
+                                }
+                            )
+                        },
+                        25
+                    )
+                )
+            })
+            .unwrap_or_else(|| "Terminal".to_string());
+
+        Flex::row()
+            .with_child(
+                Label::new(title, tab_theme.label.clone())
+                    .aligned()
+                    .contained()
+                    .boxed(),
+            )
+            .boxed()
+    }
+
+    fn clone_on_split(
+        &self,
+        _workspace_id: WorkspaceId,
+        _cx: &mut ViewContext<Self>,
+    ) -> Option<Self> {
+        //From what I can tell, there's no  way to tell the current working
+        //Directory of the terminal from outside the shell. There might be
+        //solutions to this, but they are non-trivial and require more IPC
+
+        // Some(TerminalContainer::new(
+        //     Err(anyhow::anyhow!("failed to instantiate terminal")),
+        //     workspace_id,
+        //     cx,
+        // ))
+
+        // TODO
+        None
+    }
+
+    fn project_path(&self, _cx: &gpui::AppContext) -> Option<ProjectPath> {
+        None
+    }
+
+    fn project_entry_ids(&self, _cx: &gpui::AppContext) -> SmallVec<[project::ProjectEntryId; 3]> {
+        SmallVec::new()
+    }
+
+    fn is_singleton(&self, _cx: &gpui::AppContext) -> bool {
+        false
+    }
+
+    fn set_nav_history(&mut self, _: workspace::ItemNavHistory, _: &mut ViewContext<Self>) {}
+
+    fn can_save(&self, _cx: &gpui::AppContext) -> bool {
+        false
+    }
+
+    fn save(
+        &mut self,
+        _project: gpui::ModelHandle<Project>,
+        _cx: &mut ViewContext<Self>,
+    ) -> gpui::Task<gpui::anyhow::Result<()>> {
+        unreachable!("save should not have been called");
+    }
+
+    fn save_as(
+        &mut self,
+        _project: gpui::ModelHandle<Project>,
+        _abs_path: std::path::PathBuf,
+        _cx: &mut ViewContext<Self>,
+    ) -> gpui::Task<gpui::anyhow::Result<()>> {
+        unreachable!("save_as should not have been called");
+    }
+
+    fn reload(
+        &mut self,
+        _project: gpui::ModelHandle<Project>,
+        _cx: &mut ViewContext<Self>,
+    ) -> gpui::Task<gpui::anyhow::Result<()>> {
+        gpui::Task::ready(Ok(()))
+    }
+
+    fn is_dirty(&self, _cx: &gpui::AppContext) -> bool {
+        self.has_bell()
+    }
+
+    fn has_conflict(&self, _cx: &AppContext) -> bool {
+        false
+    }
+
+    fn as_searchable(&self, handle: &ViewHandle<Self>) -> Option<Box<dyn SearchableItemHandle>> {
+        Some(Box::new(handle.clone()))
+    }
+
+    fn to_item_events(event: &Self::Event) -> Vec<ItemEvent> {
+        match event {
+            Event::BreadcrumbsChanged => vec![ItemEvent::UpdateBreadcrumbs],
+            Event::TitleChanged | Event::Wakeup => vec![ItemEvent::UpdateTab],
+            Event::CloseTerminal => vec![ItemEvent::CloseItem],
+            _ => vec![],
+        }
+    }
+
+    fn breadcrumb_location(&self) -> ToolbarItemLocation {
+        ToolbarItemLocation::PrimaryLeft { flex: None }
+    }
+
+    fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option<Vec<ElementBox>> {
+        Some(vec![Text::new(
+            self.terminal().read(cx).breadcrumb_text.to_string(),
+            theme.breadcrumbs.text.clone(),
+        )
+        .boxed()])
+    }
+
+    fn serialized_item_kind() -> Option<&'static str> {
+        Some("Terminal")
+    }
+
+    fn deserialize(
+        project: ModelHandle<Project>,
+        _workspace: WeakViewHandle<Workspace>,
+        workspace_id: workspace::WorkspaceId,
+        item_id: workspace::ItemId,
+        cx: &mut ViewContext<Pane>,
+    ) -> Task<anyhow::Result<ViewHandle<Self>>> {
+        let window_id = cx.window_id();
+        cx.spawn(|pane, mut cx| async move {
+            let cwd = TERMINAL_DB
+                .take_working_directory(item_id, workspace_id)
+                .await
+                .log_err()
+                .flatten();
+
+            cx.update(|cx| {
+                let terminal = project.update(cx, |project, cx| {
+                    project.create_terminal(cwd, window_id, cx)
+                })?;
+
+                Ok(cx.add_view(pane, |cx| TerminalView::new(terminal, workspace_id, cx)))
+            })
+        })
+    }
+
+    fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext<Self>) {
+        cx.background()
+            .spawn(TERMINAL_DB.update_workspace_id(
+                workspace.database_id(),
+                self.workspace_id,
+                cx.view_id(),
+            ))
+            .detach();
+        self.workspace_id = workspace.database_id();
+    }
+}
+
+impl SearchableItem for TerminalView {
+    type Match = RangeInclusive<Point>;
+
+    fn supported_options() -> SearchOptions {
+        SearchOptions {
+            case: false,
+            word: false,
+            regex: false,
+        }
+    }
+
+    /// Convert events raised by this item into search-relevant events (if applicable)
+    fn to_search_event(event: &Self::Event) -> Option<SearchEvent> {
+        match event {
+            Event::Wakeup => Some(SearchEvent::MatchesInvalidated),
+            Event::SelectionsChanged => Some(SearchEvent::ActiveMatchChanged),
+            _ => None,
+        }
+    }
+
+    /// Clear stored matches
+    fn clear_matches(&mut self, cx: &mut ViewContext<Self>) {
+        self.terminal().update(cx, |term, _| term.matches.clear())
+    }
+
+    /// Store matches returned from find_matches somewhere for rendering
+    fn update_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
+        self.terminal().update(cx, |term, _| term.matches = matches)
+    }
+
+    /// Return the selection content to pre-load into this search
+    fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> String {
+        self.terminal()
+            .read(cx)
+            .last_content
+            .selection_text
+            .clone()
+            .unwrap_or_default()
+    }
+
+    /// Focus match at given index into the Vec of matches
+    fn activate_match(&mut self, index: usize, _: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
+        self.terminal()
+            .update(cx, |term, _| term.activate_match(index));
+        cx.notify();
+    }
+
+    /// Get all of the matches for this query, should be done on the background
+    fn find_matches(
+        &mut self,
+        query: project::search::SearchQuery,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Vec<Self::Match>> {
+        if let Some(searcher) = regex_search_for_query(query) {
+            self.terminal()
+                .update(cx, |term, cx| term.find_matches(searcher, cx))
+        } else {
+            Task::ready(vec![])
+        }
+    }
+
+    /// Reports back to the search toolbar what the active match should be (the selection)
+    fn active_match_index(
+        &mut self,
+        matches: Vec<Self::Match>,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<usize> {
+        // Selection head might have a value if there's a selection that isn't
+        // associated with a match. Therefore, if there are no matches, we should
+        // report None, no matter the state of the terminal
+        let res = if matches.len() > 0 {
+            if let Some(selection_head) = self.terminal().read(cx).selection_head {
+                // If selection head is contained in a match. Return that match
+                if let Some(ix) = matches
+                    .iter()
+                    .enumerate()
+                    .find(|(_, search_match)| {
+                        search_match.contains(&selection_head)
+                            || search_match.start() > &selection_head
+                    })
+                    .map(|(ix, _)| ix)
+                {
+                    Some(ix)
+                } else {
+                    // If no selection after selection head, return the last match
+                    Some(matches.len().saturating_sub(1))
+                }
+            } else {
+                // Matches found but no active selection, return the first last one (closest to cursor)
+                Some(matches.len().saturating_sub(1))
+            }
+        } else {
+            None
+        };
+
+        res
+    }
+}
+
+///Get's the working directory for the given workspace, respecting the user's settings.
+pub fn get_working_directory(
+    workspace: &Workspace,
+    cx: &AppContext,
+    strategy: WorkingDirectory,
+) -> Option<PathBuf> {
+    let res = match strategy {
+        WorkingDirectory::CurrentProjectDirectory => current_project_directory(workspace, cx)
+            .or_else(|| first_project_directory(workspace, cx)),
+        WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx),
+        WorkingDirectory::AlwaysHome => None,
+        WorkingDirectory::Always { directory } => {
+            shellexpand::full(&directory) //TODO handle this better
+                .ok()
+                .map(|dir| Path::new(&dir.to_string()).to_path_buf())
+                .filter(|dir| dir.is_dir())
+        }
+    };
+    res.or_else(home_dir)
+}
+
+///Get's the first project's home directory, or the home directory
+fn first_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
+    workspace
+        .worktrees(cx)
+        .next()
+        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
+        .and_then(get_path_from_wt)
+}
+
+///Gets the intuitively correct working directory from the given workspace
+///If there is an active entry for this project, returns that entry's worktree root.
+///If there's no active entry but there is a worktree, returns that worktrees root.
+///If either of these roots are files, or if there are any other query failures,
+///  returns the user's home directory
+fn current_project_directory(workspace: &Workspace, cx: &AppContext) -> Option<PathBuf> {
+    let project = workspace.project().read(cx);
+
+    project
+        .active_entry()
+        .and_then(|entry_id| project.worktree_for_entry(entry_id, cx))
+        .or_else(|| workspace.worktrees(cx).next())
+        .and_then(|worktree_handle| worktree_handle.read(cx).as_local())
+        .and_then(get_path_from_wt)
+}
+
+fn get_path_from_wt(wt: &LocalWorktree) -> Option<PathBuf> {
+    wt.root_entry()
+        .filter(|re| re.is_dir())
+        .map(|_| wt.abs_path().to_path_buf())
+}
+
+#[cfg(test)]
+mod tests {
+
+    use super::*;
+    use gpui::TestAppContext;
+    use project::{Entry, Project, ProjectPath, Worktree};
+    use workspace::AppState;
+
+    use std::path::Path;
+
+    ///Working directory calculation tests
+
+    ///No Worktrees in project -> home_dir()
+    #[gpui::test]
+    async fn no_worktree(cx: &mut TestAppContext) {
+        //Setup variables
+        let (project, workspace) = blank_workspace(cx).await;
+        //Test
+        cx.read(|cx| {
+            let workspace = workspace.read(cx);
+            let active_entry = project.read(cx).active_entry();
+
+            //Make sure enviroment is as expeted
+            assert!(active_entry.is_none());
+            assert!(workspace.worktrees(cx).next().is_none());
+
+            let res = current_project_directory(workspace, cx);
+            assert_eq!(res, None);
+            let res = first_project_directory(workspace, cx);
+            assert_eq!(res, None);
+        });
+    }
+
+    ///No active entry, but a worktree, worktree is a file -> home_dir()
+    #[gpui::test]
+    async fn no_active_entry_worktree_is_file(cx: &mut TestAppContext) {
+        //Setup variables
+
+        let (project, workspace) = blank_workspace(cx).await;
+        create_file_wt(project.clone(), "/root.txt", cx).await;
+
+        cx.read(|cx| {
+            let workspace = workspace.read(cx);
+            let active_entry = project.read(cx).active_entry();
+
+            //Make sure enviroment is as expeted
+            assert!(active_entry.is_none());
+            assert!(workspace.worktrees(cx).next().is_some());
+
+            let res = current_project_directory(workspace, cx);
+            assert_eq!(res, None);
+            let res = first_project_directory(workspace, cx);
+            assert_eq!(res, None);
+        });
+    }
+
+    //No active entry, but a worktree, worktree is a folder -> worktree_folder
+    #[gpui::test]
+    async fn no_active_entry_worktree_is_dir(cx: &mut TestAppContext) {
+        //Setup variables
+        let (project, workspace) = blank_workspace(cx).await;
+        let (_wt, _entry) = create_folder_wt(project.clone(), "/root/", cx).await;
+
+        //Test
+        cx.update(|cx| {
+            let workspace = workspace.read(cx);
+            let active_entry = project.read(cx).active_entry();
+
+            assert!(active_entry.is_none());
+            assert!(workspace.worktrees(cx).next().is_some());
+
+            let res = current_project_directory(workspace, cx);
+            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
+            let res = first_project_directory(workspace, cx);
+            assert_eq!(res, Some((Path::new("/root/")).to_path_buf()));
+        });
+    }
+
+    //Active entry with a work tree, worktree is a file -> home_dir()
+    #[gpui::test]
+    async fn active_entry_worktree_is_file(cx: &mut TestAppContext) {
+        //Setup variables
+
+        let (project, workspace) = blank_workspace(cx).await;
+        let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
+        let (wt2, entry2) = create_file_wt(project.clone(), "/root2.txt", cx).await;
+        insert_active_entry_for(wt2, entry2, project.clone(), cx);
+
+        //Test
+        cx.update(|cx| {
+            let workspace = workspace.read(cx);
+            let active_entry = project.read(cx).active_entry();
+
+            assert!(active_entry.is_some());
+
+            let res = current_project_directory(workspace, cx);
+            assert_eq!(res, None);
+            let res = first_project_directory(workspace, cx);
+            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
+        });
+    }
+
+    //Active entry, with a worktree, worktree is a folder -> worktree_folder
+    #[gpui::test]
+    async fn active_entry_worktree_is_dir(cx: &mut TestAppContext) {
+        //Setup variables
+        let (project, workspace) = blank_workspace(cx).await;
+        let (_wt, _entry) = create_folder_wt(project.clone(), "/root1/", cx).await;
+        let (wt2, entry2) = create_folder_wt(project.clone(), "/root2/", cx).await;
+        insert_active_entry_for(wt2, entry2, project.clone(), cx);
+
+        //Test
+        cx.update(|cx| {
+            let workspace = workspace.read(cx);
+            let active_entry = project.read(cx).active_entry();
+
+            assert!(active_entry.is_some());
+
+            let res = current_project_directory(workspace, cx);
+            assert_eq!(res, Some((Path::new("/root2/")).to_path_buf()));
+            let res = first_project_directory(workspace, cx);
+            assert_eq!(res, Some((Path::new("/root1/")).to_path_buf()));
+        });
+    }
+
+    ///Creates a worktree with 1 file: /root.txt
+    pub async fn blank_workspace(
+        cx: &mut TestAppContext,
+    ) -> (ModelHandle<Project>, ViewHandle<Workspace>) {
+        let params = cx.update(AppState::test);
+
+        let project = Project::test(params.fs.clone(), [], cx).await;
+        let (_, workspace) = cx.add_window(|cx| {
+            Workspace::new(
+                Default::default(),
+                0,
+                project.clone(),
+                |_, _| unimplemented!(),
+                cx,
+            )
+        });
+
+        (project, workspace)
+    }
+
+    ///Creates a worktree with 1 folder: /root{suffix}/
+    async fn create_folder_wt(
+        project: ModelHandle<Project>,
+        path: impl AsRef<Path>,
+        cx: &mut TestAppContext,
+    ) -> (ModelHandle<Worktree>, Entry) {
+        create_wt(project, true, path, cx).await
+    }
+
+    ///Creates a worktree with 1 file: /root{suffix}.txt
+    async fn create_file_wt(
+        project: ModelHandle<Project>,
+        path: impl AsRef<Path>,
+        cx: &mut TestAppContext,
+    ) -> (ModelHandle<Worktree>, Entry) {
+        create_wt(project, false, path, cx).await
+    }
+
+    async fn create_wt(
+        project: ModelHandle<Project>,
+        is_dir: bool,
+        path: impl AsRef<Path>,
+        cx: &mut TestAppContext,
+    ) -> (ModelHandle<Worktree>, Entry) {
+        let (wt, _) = project
+            .update(cx, |project, cx| {
+                project.find_or_create_local_worktree(path, true, cx)
+            })
+            .await
+            .unwrap();
+
+        let entry = cx
+            .update(|cx| {
+                wt.update(cx, |wt, cx| {
+                    wt.as_local()
+                        .unwrap()
+                        .create_entry(Path::new(""), is_dir, cx)
+                })
+            })
+            .await
+            .unwrap();
+
+        (wt, entry)
+    }
+
+    pub fn insert_active_entry_for(
+        wt: ModelHandle<Worktree>,
+        entry: Entry,
+        project: ModelHandle<Project>,
+        cx: &mut TestAppContext,
+    ) {
+        cx.update(|cx| {
+            let p = ProjectPath {
+                worktree_id: wt.read(cx).id(),
+                path: entry.path,
+            };
+            project.update(cx, |project, cx| project.set_active_path(Some(p), cx));
+        });
+    }
+}

crates/text/src/text.rs 🔗

@@ -1496,6 +1496,10 @@ impl BufferSnapshot {
         &self.visible_text
     }
 
+    pub fn remote_id(&self) -> u64 {
+        self.remote_id
+    }
+
     pub fn replica_id(&self) -> ReplicaId {
         self.replica_id
     }

crates/workspace/src/dock.rs 🔗

@@ -126,18 +126,21 @@ impl DockPosition {
     }
 }
 
-pub type DefaultItemFactory =
-    fn(&mut Workspace, &mut ViewContext<Workspace>) -> Box<dyn ItemHandle>;
+pub type DockDefaultItemFactory =
+    fn(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Option<Box<dyn ItemHandle>>;
 
 pub struct Dock {
     position: DockPosition,
     panel_sizes: HashMap<DockAnchor, f32>,
     pane: ViewHandle<Pane>,
-    default_item_factory: DefaultItemFactory,
+    default_item_factory: DockDefaultItemFactory,
 }
 
 impl Dock {
-    pub fn new(default_item_factory: DefaultItemFactory, cx: &mut ViewContext<Workspace>) -> Self {
+    pub fn new(
+        default_item_factory: DockDefaultItemFactory,
+        cx: &mut ViewContext<Workspace>,
+    ) -> Self {
         let position = DockPosition::Hidden(cx.global::<Settings>().default_dock_anchor);
 
         let pane = cx.add_view(|cx| Pane::new(Some(position.anchor()), cx));
@@ -192,9 +195,11 @@ impl Dock {
             // Ensure that the pane has at least one item or construct a default item to put in it
             let pane = workspace.dock.pane.clone();
             if pane.read(cx).items().next().is_none() {
-                let item_to_add = (workspace.dock.default_item_factory)(workspace, cx);
-                // Adding the item focuses the pane by default
-                Pane::add_item(workspace, &pane, item_to_add, true, true, None, cx);
+                if let Some(item_to_add) = (workspace.dock.default_item_factory)(workspace, cx) {
+                    Pane::add_item(workspace, &pane, item_to_add, true, true, None, cx);
+                } else {
+                    workspace.dock.position = workspace.dock.position.hide();
+                }
             } else {
                 cx.focus(pane);
             }
@@ -477,8 +482,8 @@ mod tests {
     pub fn default_item_factory(
         _workspace: &mut Workspace,
         cx: &mut ViewContext<Workspace>,
-    ) -> Box<dyn ItemHandle> {
-        Box::new(cx.add_view(|_| TestItem::new()))
+    ) -> Option<Box<dyn ItemHandle>> {
+        Some(Box::new(cx.add_view(|_| TestItem::new())))
     }
 
     #[gpui::test]

crates/workspace/src/item.rs 🔗

@@ -5,12 +5,15 @@ use std::{
     fmt,
     path::PathBuf,
     rc::Rc,
-    sync::atomic::{AtomicBool, Ordering},
+    sync::{
+        atomic::{AtomicBool, Ordering},
+        Arc,
+    },
     time::Duration,
 };
 
 use anyhow::Result;
-use client::proto;
+use client::{proto, Client};
 use gpui::{
     AnyViewHandle, AppContext, ElementBox, ModelHandle, MutableAppContext, Task, View, ViewContext,
     ViewHandle, WeakViewHandle,
@@ -23,7 +26,8 @@ use util::ResultExt;
 
 use crate::{
     pane, persistence::model::ItemId, searchable::SearchableItemHandle, DelayedDebouncedEditAction,
-    FollowableItemBuilders, ItemNavHistory, Pane, ToolbarItemLocation, Workspace, WorkspaceId,
+    FollowableItemBuilders, ItemNavHistory, Pane, ToolbarItemLocation, ViewId, Workspace,
+    WorkspaceId,
 };
 
 #[derive(Eq, PartialEq, Hash)]
@@ -278,9 +282,11 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
             if let Some(message) = followed_item.to_state_proto(cx) {
                 workspace.update_followers(
                     proto::update_followers::Variant::CreateView(proto::View {
-                        id: followed_item.id() as u64,
+                        id: followed_item
+                            .remote_id(&workspace.client, cx)
+                            .map(|id| id.to_proto()),
                         variant: Some(message),
-                        leader_id: workspace.leader_for_pane(&pane).map(|id| id.0),
+                        leader_id: workspace.leader_for_pane(&pane),
                     }),
                     cx,
                 );
@@ -332,9 +338,11 @@ impl<T: Item> ItemHandle for ViewHandle<T> {
                                     this.update_followers(
                                         proto::update_followers::Variant::UpdateView(
                                             proto::UpdateView {
-                                                id: item.id() as u64,
+                                                id: item
+                                                    .remote_id(&this.client, cx)
+                                                    .map(|id| id.to_proto()),
                                                 variant: pending_update.borrow_mut().take(),
-                                                leader_id: leader_id.map(|id| id.0),
+                                                leader_id,
                                             },
                                         ),
                                         cx,
@@ -584,10 +592,12 @@ pub trait ProjectItem: Item {
 }
 
 pub trait FollowableItem: Item {
+    fn remote_id(&self) -> Option<ViewId>;
     fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant>;
     fn from_state_proto(
         pane: ViewHandle<Pane>,
         project: ModelHandle<Project>,
+        id: ViewId,
         state: &mut Option<proto::view::Variant>,
         cx: &mut MutableAppContext,
     ) -> Option<Task<Result<ViewHandle<Self>>>>;
@@ -599,15 +609,17 @@ pub trait FollowableItem: Item {
     ) -> bool;
     fn apply_update_proto(
         &mut self,
+        project: &ModelHandle<Project>,
         message: proto::update_view::Variant,
         cx: &mut ViewContext<Self>,
-    ) -> Result<()>;
+    ) -> Task<Result<()>>;
 
     fn set_leader_replica_id(&mut self, leader_replica_id: Option<u16>, cx: &mut ViewContext<Self>);
     fn should_unfollow_on_event(event: &Self::Event, cx: &AppContext) -> bool;
 }
 
 pub trait FollowableItemHandle: ItemHandle {
+    fn remote_id(&self, client: &Arc<Client>, cx: &AppContext) -> Option<ViewId>;
     fn set_leader_replica_id(&self, leader_replica_id: Option<u16>, cx: &mut MutableAppContext);
     fn to_state_proto(&self, cx: &AppContext) -> Option<proto::view::Variant>;
     fn add_event_to_update_proto(
@@ -618,13 +630,23 @@ pub trait FollowableItemHandle: ItemHandle {
     ) -> bool;
     fn apply_update_proto(
         &self,
+        project: &ModelHandle<Project>,
         message: proto::update_view::Variant,
         cx: &mut MutableAppContext,
-    ) -> Result<()>;
+    ) -> Task<Result<()>>;
     fn should_unfollow_on_event(&self, event: &dyn Any, cx: &AppContext) -> bool;
 }
 
 impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
+    fn remote_id(&self, client: &Arc<Client>, cx: &AppContext) -> Option<ViewId> {
+        self.read(cx).remote_id().or_else(|| {
+            client.peer_id().map(|creator| ViewId {
+                creator,
+                id: self.id() as u64,
+            })
+        })
+    }
+
     fn set_leader_replica_id(&self, leader_replica_id: Option<u16>, cx: &mut MutableAppContext) {
         self.update(cx, |this, cx| {
             this.set_leader_replica_id(leader_replica_id, cx)
@@ -650,10 +672,11 @@ impl<T: FollowableItem> FollowableItemHandle for ViewHandle<T> {
 
     fn apply_update_proto(
         &self,
+        project: &ModelHandle<Project>,
         message: proto::update_view::Variant,
         cx: &mut MutableAppContext,
-    ) -> Result<()> {
-        self.update(cx, |this, cx| this.apply_update_proto(message, cx))
+    ) -> Task<Result<()>> {
+        self.update(cx, |this, cx| this.apply_update_proto(project, message, cx))
     }
 
     fn should_unfollow_on_event(&self, event: &dyn Any, cx: &AppContext) -> bool {

crates/workspace/src/notifications.rs 🔗

@@ -161,8 +161,8 @@ pub mod simple_message_notification {
 
     pub struct MessageNotification {
         message: String,
-        click_action: Box<dyn Action>,
-        click_message: String,
+        click_action: Option<Box<dyn Action>>,
+        click_message: Option<String>,
     }
 
     pub enum MessageNotificationEvent {
@@ -174,6 +174,14 @@ pub mod simple_message_notification {
     }
 
     impl MessageNotification {
+        pub fn new_messsage<S: AsRef<str>>(message: S) -> MessageNotification {
+            Self {
+                message: message.as_ref().to_string(),
+                click_action: None,
+                click_message: None,
+            }
+        }
+
         pub fn new<S1: AsRef<str>, A: Action, S2: AsRef<str>>(
             message: S1,
             click_action: A,
@@ -181,8 +189,8 @@ pub mod simple_message_notification {
         ) -> Self {
             Self {
                 message: message.as_ref().to_string(),
-                click_action: Box::new(click_action) as Box<dyn Action>,
-                click_message: click_message.as_ref().to_string(),
+                click_action: Some(Box::new(click_action) as Box<dyn Action>),
+                click_message: Some(click_message.as_ref().to_string()),
             }
         }
 
@@ -198,12 +206,15 @@ pub mod simple_message_notification {
 
         fn render(&mut self, cx: &mut gpui::RenderContext<'_, Self>) -> gpui::ElementBox {
             let theme = cx.global::<Settings>().theme.clone();
-            let theme = &theme.update_notification;
+            let theme = &theme.simple_message_notification;
 
             enum MessageNotificationTag {}
 
-            let click_action = self.click_action.boxed_clone();
-            let click_message = self.click_message.clone();
+            let click_action = self
+                .click_action
+                .as_ref()
+                .map(|action| action.boxed_clone());
+            let click_message = self.click_message.as_ref().map(|message| message.clone());
             let message = self.message.clone();
 
             MouseEventHandler::<MessageNotificationTag>::new(0, cx, |state, cx| {
@@ -251,20 +262,28 @@ pub mod simple_message_notification {
                             )
                             .boxed(),
                     )
-                    .with_child({
+                    .with_children({
                         let style = theme.action_message.style_for(state, false);
-
-                        Text::new(click_message, style.text.clone())
-                            .contained()
-                            .with_style(style.container)
-                            .boxed()
+                        if let Some(click_message) = click_message {
+                            Some(
+                                Text::new(click_message, style.text.clone())
+                                    .contained()
+                                    .with_style(style.container)
+                                    .boxed(),
+                            )
+                        } else {
+                            None
+                        }
+                        .into_iter()
                     })
                     .contained()
                     .boxed()
             })
             .with_cursor_style(CursorStyle::PointingHand)
             .on_click(MouseButton::Left, move |_, cx| {
-                cx.dispatch_any_action(click_action.boxed_clone())
+                if let Some(click_action) = click_action.as_ref() {
+                    cx.dispatch_any_action(click_action.boxed_clone())
+                }
             })
             .boxed()
         }
@@ -278,3 +297,38 @@ pub mod simple_message_notification {
         }
     }
 }
+
+pub trait NotifyResultExt {
+    type Ok;
+
+    fn notify_err(
+        self,
+        workspace: &mut Workspace,
+        cx: &mut ViewContext<Workspace>,
+    ) -> Option<Self::Ok>;
+}
+
+impl<T, E> NotifyResultExt for Result<T, E>
+where
+    E: std::fmt::Debug,
+{
+    type Ok = T;
+
+    fn notify_err(self, workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> Option<T> {
+        match self {
+            Ok(value) => Some(value),
+            Err(err) => {
+                workspace.show_notification(0, cx, |cx| {
+                    cx.add_view(|_cx| {
+                        simple_message_notification::MessageNotification::new_messsage(format!(
+                            "Error: {:?}",
+                            err,
+                        ))
+                    })
+                });
+
+                None
+            }
+        }
+    }
+}

crates/workspace/src/shared_screen.rs 🔗

@@ -3,7 +3,7 @@ use crate::{
 };
 use anyhow::{anyhow, Result};
 use call::participant::{Frame, RemoteVideoTrack};
-use client::{PeerId, User};
+use client::{proto::PeerId, User};
 use futures::StreamExt;
 use gpui::{
     elements::*,

crates/workspace/src/workspace.rs 🔗

@@ -14,23 +14,21 @@ pub mod sidebar;
 mod status_bar;
 mod toolbar;
 
-use std::{
-    any::TypeId,
-    borrow::Cow,
-    future::Future,
-    path::{Path, PathBuf},
-    sync::Arc,
-    time::Duration,
-};
-
-use anyhow::{anyhow, Context, Result};
+use anyhow::{anyhow, Result};
 use call::ActiveCall;
-use client::{proto, Client, PeerId, TypedEnvelope, UserStore};
+use client::{
+    proto::{self, PeerId},
+    Client, TypedEnvelope, UserStore,
+};
 use collections::{hash_map, HashMap, HashSet};
-use dock::{DefaultItemFactory, Dock, ToggleDockButton};
+use dock::{Dock, DockDefaultItemFactory, ToggleDockButton};
 use drag_and_drop::DragAndDrop;
 use fs::{self, Fs};
-use futures::{channel::oneshot, FutureExt, StreamExt};
+use futures::{
+    channel::{mpsc, oneshot},
+    future::try_join_all,
+    FutureExt, StreamExt,
+};
 use gpui::{
     actions,
     elements::*,
@@ -42,7 +40,19 @@ use gpui::{
 };
 use item::{FollowableItem, FollowableItemHandle, Item, ItemHandle, ProjectItem};
 use language::LanguageRegistry;
+use std::{
+    any::TypeId,
+    borrow::Cow,
+    future::Future,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Duration,
+};
 
+use crate::{
+    notifications::simple_message_notification::{MessageNotification, OsOpen},
+    persistence::model::{SerializedPane, SerializedPaneGroup, SerializedWorkspace},
+};
 use log::{error, warn};
 use notifications::NotificationHandle;
 pub use pane::*;
@@ -64,11 +74,6 @@ use theme::{Theme, ThemeRegistry};
 pub use toolbar::{ToolbarItemLocation, ToolbarItemView};
 use util::ResultExt;
 
-use crate::{
-    notifications::simple_message_notification::{MessageNotification, OsOpen},
-    persistence::model::{SerializedPane, SerializedPaneGroup, SerializedWorkspace},
-};
-
 #[derive(Clone, PartialEq)]
 pub struct RemoveWorktreeFromProject(pub WorktreeId);
 
@@ -316,6 +321,7 @@ pub fn register_project_item<I: ProjectItem>(cx: &mut MutableAppContext) {
 type FollowableItemBuilder = fn(
     ViewHandle<Pane>,
     ModelHandle<Project>,
+    ViewId,
     &mut Option<proto::view::Variant>,
     &mut MutableAppContext,
 ) -> Option<Task<Result<Box<dyn FollowableItemHandle>>>>;
@@ -331,8 +337,8 @@ pub fn register_followable_item<I: FollowableItem>(cx: &mut MutableAppContext) {
         builders.insert(
             TypeId::of::<I>(),
             (
-                |pane, project, state, cx| {
-                    I::from_state_proto(pane, project, state, cx).map(|task| {
+                |pane, project, id, state, cx| {
+                    I::from_state_proto(pane, project, id, state, cx).map(|task| {
                         cx.foreground()
                             .spawn(async move { Ok(Box::new(task.await?) as Box<_>) })
                     })
@@ -376,7 +382,7 @@ pub struct AppState {
     pub fs: Arc<dyn fs::Fs>,
     pub build_window_options: fn() -> WindowOptions<'static>,
     pub initialize_workspace: fn(&mut Workspace, &Arc<AppState>, &mut ViewContext<Workspace>),
-    pub default_item_factory: DefaultItemFactory,
+    pub dock_default_item_factory: DockDefaultItemFactory,
 }
 
 impl AppState {
@@ -402,7 +408,7 @@ impl AppState {
             user_store,
             initialize_workspace: |_, _, _| {},
             build_window_options: Default::default,
-            default_item_factory: |_, _| unimplemented!(),
+            dock_default_item_factory: |_, _| unimplemented!(),
         })
     }
 }
@@ -458,25 +464,6 @@ impl DelayedDebouncedEditAction {
     }
 }
 
-#[derive(Default)]
-struct LeaderState {
-    followers: HashSet<PeerId>,
-}
-
-type FollowerStatesByLeader = HashMap<PeerId, HashMap<ViewHandle<Pane>, FollowerState>>;
-
-#[derive(Default)]
-struct FollowerState {
-    active_view_id: Option<u64>,
-    items_by_leader_view_id: HashMap<u64, FollowerItem>,
-}
-
-#[derive(Debug)]
-enum FollowerItem {
-    Loading(Vec<proto::update_view::Variant>),
-    Loaded(Box<dyn FollowableItemHandle>),
-}
-
 pub enum Event {
     DockAnchorChanged,
     PaneAdded(ViewHandle<Pane>),
@@ -507,16 +494,37 @@ pub struct Workspace {
     last_leaders_by_pane: HashMap<WeakViewHandle<Pane>, PeerId>,
     window_edited: bool,
     active_call: Option<(ModelHandle<ActiveCall>, Vec<gpui::Subscription>)>,
+    leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>,
     database_id: WorkspaceId,
+    _apply_leader_updates: Task<Result<()>>,
     _observe_current_user: Task<()>,
 }
 
+#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
+pub struct ViewId {
+    pub creator: PeerId,
+    pub id: u64,
+}
+
+#[derive(Default)]
+struct LeaderState {
+    followers: HashSet<PeerId>,
+}
+
+type FollowerStatesByLeader = HashMap<PeerId, HashMap<ViewHandle<Pane>, FollowerState>>;
+
+#[derive(Default)]
+struct FollowerState {
+    active_view_id: Option<ViewId>,
+    items_by_leader_view_id: HashMap<ViewId, Box<dyn FollowableItemHandle>>,
+}
+
 impl Workspace {
     pub fn new(
         serialized_workspace: Option<SerializedWorkspace>,
         workspace_id: WorkspaceId,
         project: ModelHandle<Project>,
-        dock_default_factory: DefaultItemFactory,
+        dock_default_factory: DockDefaultItemFactory,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         cx.observe_fullscreen(|_, _, cx| cx.notify()).detach();
@@ -576,10 +584,24 @@ impl Workspace {
                 })
             }
         });
-
         let handle = cx.handle();
         let weak_handle = cx.weak_handle();
 
+        // All leader updates are enqueued and then processed in a single task, so
+        // that each asynchronous operation can be run in order.
+        let (leader_updates_tx, mut leader_updates_rx) =
+            mpsc::unbounded::<(PeerId, proto::UpdateFollowers)>();
+        let _apply_leader_updates = cx.spawn_weak(|this, mut cx| async move {
+            while let Some((leader_id, update)) = leader_updates_rx.next().await {
+                let Some(this) = this.upgrade(&cx) else { break };
+                Self::process_leader_update(this, leader_id, update, &mut cx)
+                    .await
+                    .log_err();
+            }
+
+            Ok(())
+        });
+
         cx.emit_global(WorkspaceCreated(weak_handle.clone()));
 
         let left_sidebar = cx.add_view(|_| Sidebar::new(SidebarSide::Left));
@@ -637,6 +659,8 @@ impl Workspace {
             active_call,
             database_id: workspace_id,
             _observe_current_user,
+            _apply_leader_updates,
+            leader_updates_tx,
         };
         this.project_remote_id_changed(project.read(cx).remote_id(), cx);
         cx.defer(|this, cx| this.update_window_title(cx));
@@ -704,7 +728,7 @@ impl Workspace {
                     serialized_workspace,
                     workspace_id,
                     project_handle,
-                    app_state.default_item_factory,
+                    app_state.dock_default_item_factory,
                     cx,
                 );
                 (app_state.initialize_workspace)(&mut workspace, &app_state, cx);
@@ -1440,8 +1464,12 @@ impl Workspace {
 
         self.update_followers(
             proto::update_followers::Variant::UpdateActiveView(proto::UpdateActiveView {
-                id: self.active_item(cx).map(|item| item.id() as u64),
-                leader_id: self.leader_for_pane(&pane).map(|id| id.0),
+                id: self.active_item(cx).and_then(|item| {
+                    item.to_followable_item_handle(cx)?
+                        .remote_id(&self.client, cx)
+                        .map(|id| id.to_proto())
+                }),
+                leader_id: self.leader_for_pane(&pane),
             }),
             cx,
         );
@@ -1586,9 +1614,7 @@ impl Workspace {
         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() {
-                    if let FollowerItem::Loaded(item) = item {
-                        item.set_leader_replica_id(None, cx);
-                    }
+                    item.set_leader_replica_id(None, cx);
                 }
             }
         }
@@ -1620,7 +1646,7 @@ impl Workspace {
         let project_id = self.project.read(cx).remote_id()?;
         let request = self.client.request(proto::Follow {
             project_id,
-            leader_id: leader_id.0,
+            leader_id: Some(leader_id),
         });
         Some(cx.spawn_weak(|this, mut cx| async move {
             let response = request.await?;
@@ -1631,11 +1657,22 @@ impl Workspace {
                         .get_mut(&leader_id)
                         .and_then(|states_by_pane| states_by_pane.get_mut(&pane))
                         .ok_or_else(|| anyhow!("following interrupted"))?;
-                    state.active_view_id = response.active_view_id;
+                    state.active_view_id = if let Some(active_view_id) = response.active_view_id {
+                        Some(ViewId::from_proto(active_view_id)?)
+                    } else {
+                        None
+                    };
                     Ok::<_, anyhow::Error>(())
                 })?;
-                Self::add_views_from_leader(this, leader_id, vec![pane], response.views, &mut cx)
-                    .await?;
+                Self::add_views_from_leader(
+                    this.clone(),
+                    leader_id,
+                    vec![pane],
+                    response.views,
+                    &mut cx,
+                )
+                .await?;
+                this.update(&mut cx, |this, cx| this.leader_updated(leader_id, cx));
             }
             Ok(())
         }))
@@ -1681,9 +1718,7 @@ impl Workspace {
             let leader_id = *leader_id;
             if let Some(state) = states_by_pane.remove(pane) {
                 for (_, item) in state.items_by_leader_view_id {
-                    if let FollowerItem::Loaded(item) = item {
-                        item.set_leader_replica_id(None, cx);
-                    }
+                    item.set_leader_replica_id(None, cx);
                 }
 
                 if states_by_pane.is_empty() {
@@ -1692,7 +1727,7 @@ impl Workspace {
                         self.client
                             .send(proto::Unfollow {
                                 project_id,
-                                leader_id: leader_id.0,
+                                leader_id: Some(leader_id),
                             })
                             .log_err();
                     }
@@ -1874,29 +1909,33 @@ impl Workspace {
         mut cx: AsyncAppContext,
     ) -> Result<proto::FollowResponse> {
         this.update(&mut cx, |this, cx| {
+            let client = &this.client;
             this.leader_state
                 .followers
                 .insert(envelope.original_sender_id()?);
 
-            let active_view_id = this
-                .active_item(cx)
-                .and_then(|i| i.to_followable_item_handle(cx))
-                .map(|i| i.id() as u64);
+            let active_view_id = this.active_item(cx).and_then(|i| {
+                Some(
+                    i.to_followable_item_handle(cx)?
+                        .remote_id(client, cx)?
+                        .to_proto(),
+                )
+            });
             Ok(proto::FollowResponse {
                 active_view_id,
                 views: this
                     .panes()
                     .iter()
                     .flat_map(|pane| {
-                        let leader_id = this.leader_for_pane(pane).map(|id| id.0);
+                        let leader_id = this.leader_for_pane(pane);
                         pane.read(cx).items().filter_map({
                             let cx = &cx;
                             move |item| {
-                                let id = item.id() as u64;
                                 let item = item.to_followable_item_handle(cx)?;
+                                let id = item.remote_id(client, cx)?.to_proto();
                                 let variant = item.to_state_proto(cx)?;
                                 Some(proto::View {
-                                    id,
+                                    id: Some(id),
                                     leader_id,
                                     variant: Some(variant),
                                 })
@@ -1926,45 +1965,62 @@ impl Workspace {
         this: ViewHandle<Self>,
         envelope: TypedEnvelope<proto::UpdateFollowers>,
         _: Arc<Client>,
-        mut cx: AsyncAppContext,
+        cx: AsyncAppContext,
     ) -> Result<()> {
         let leader_id = envelope.original_sender_id()?;
-        match envelope
-            .payload
-            .variant
-            .ok_or_else(|| anyhow!("invalid update"))?
-        {
+        this.read_with(&cx, |this, _| {
+            this.leader_updates_tx
+                .unbounded_send((leader_id, envelope.payload))
+        })?;
+        Ok(())
+    }
+
+    async fn process_leader_update(
+        this: ViewHandle<Self>,
+        leader_id: PeerId,
+        update: proto::UpdateFollowers,
+        cx: &mut AsyncAppContext,
+    ) -> Result<()> {
+        match update.variant.ok_or_else(|| anyhow!("invalid update"))? {
             proto::update_followers::Variant::UpdateActiveView(update_active_view) => {
-                this.update(&mut cx, |this, cx| {
-                    this.update_leader_state(leader_id, cx, |state, _| {
-                        state.active_view_id = update_active_view.id;
-                    });
-                    Ok::<_, anyhow::Error>(())
-                })
+                this.update(cx, |this, _| {
+                    if let Some(state) = this.follower_states_by_leader.get_mut(&leader_id) {
+                        for state in state.values_mut() {
+                            state.active_view_id =
+                                if let Some(active_view_id) = update_active_view.id.clone() {
+                                    Some(ViewId::from_proto(active_view_id)?)
+                                } else {
+                                    None
+                                };
+                        }
+                    }
+                    anyhow::Ok(())
+                })?;
             }
             proto::update_followers::Variant::UpdateView(update_view) => {
-                this.update(&mut cx, |this, cx| {
-                    let variant = update_view
-                        .variant
-                        .ok_or_else(|| anyhow!("missing update view variant"))?;
-                    this.update_leader_state(leader_id, cx, |state, cx| {
-                        let variant = variant.clone();
-                        match state
-                            .items_by_leader_view_id
-                            .entry(update_view.id)
-                            .or_insert(FollowerItem::Loading(Vec::new()))
-                        {
-                            FollowerItem::Loaded(item) => {
-                                item.apply_update_proto(variant, cx).log_err();
+                let variant = update_view
+                    .variant
+                    .ok_or_else(|| anyhow!("missing update view variant"))?;
+                let id = update_view
+                    .id
+                    .ok_or_else(|| anyhow!("missing update view id"))?;
+                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() {
+                            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));
                             }
-                            FollowerItem::Loading(updates) => updates.push(variant),
                         }
-                    });
-                    Ok(())
-                })
+                    }
+                    anyhow::Ok(())
+                })?;
+                try_join_all(tasks).await.log_err();
             }
             proto::update_followers::Variant::CreateView(view) => {
-                let panes = this.read_with(&cx, |this, _| {
+                let panes = this.read_with(cx, |this, _| {
                     this.follower_states_by_leader
                         .get(&leader_id)
                         .into_iter()
@@ -1972,13 +2028,10 @@ impl Workspace {
                         .cloned()
                         .collect()
                 });
-                Self::add_views_from_leader(this.clone(), leader_id, panes, vec![view], &mut cx)
-                    .await?;
-                Ok(())
+                Self::add_views_from_leader(this.clone(), leader_id, panes, vec![view], cx).await?;
             }
         }
-        .log_err();
-
+        this.update(cx, |this, cx| this.leader_updated(leader_id, cx));
         Ok(())
     }
 
@@ -2011,16 +2064,19 @@ impl Workspace {
             let mut item_tasks = Vec::new();
             let mut leader_view_ids = Vec::new();
             for view in &views {
+                let Some(id) = &view.id else { continue };
+                let id = ViewId::from_proto(id.clone())?;
                 let mut variant = view.variant.clone();
                 if variant.is_none() {
                     Err(anyhow!("missing variant"))?;
                 }
                 for build_item in &item_builders {
-                    let task =
-                        cx.update(|cx| build_item(pane.clone(), project.clone(), &mut variant, cx));
+                    let task = cx.update(|cx| {
+                        build_item(pane.clone(), project.clone(), id, &mut variant, cx)
+                    });
                     if let Some(task) = task {
                         item_tasks.push(task);
-                        leader_view_ids.push(view.id);
+                        leader_view_ids.push(id);
                         break;
                     } else {
                         assert!(variant.is_some());
@@ -2041,29 +2097,12 @@ impl Workspace {
 
                 for (id, item) in leader_view_ids.into_iter().zip(items) {
                     item.set_leader_replica_id(Some(replica_id), cx);
-                    match state.items_by_leader_view_id.entry(id) {
-                        hash_map::Entry::Occupied(e) => {
-                            let e = e.into_mut();
-                            if let FollowerItem::Loading(updates) = e {
-                                for update in updates.drain(..) {
-                                    item.apply_update_proto(update, cx)
-                                        .context("failed to apply view update")
-                                        .log_err();
-                                }
-                            }
-                            *e = FollowerItem::Loaded(item);
-                        }
-                        hash_map::Entry::Vacant(e) => {
-                            e.insert(FollowerItem::Loaded(item));
-                        }
-                    }
+                    state.items_by_leader_view_id.insert(id, item);
                 }
 
                 Some(())
             });
         }
-        this.update(cx, |this, cx| this.leader_updated(leader_id, cx));
-
         Ok(())
     }
 
@@ -2077,7 +2116,7 @@ impl Workspace {
             self.client
                 .send(proto::UpdateFollowers {
                     project_id,
-                    follower_ids: self.leader_state.followers.iter().map(|f| f.0).collect(),
+                    follower_ids: self.leader_state.followers.iter().copied().collect(),
                     variant: Some(update),
                 })
                 .log_err();
@@ -2097,23 +2136,6 @@ impl Workspace {
             })
     }
 
-    fn update_leader_state(
-        &mut self,
-        leader_id: PeerId,
-        cx: &mut ViewContext<Self>,
-        mut update_fn: impl FnMut(&mut FollowerState, &mut ViewContext<Self>),
-    ) {
-        for (_, state) in self
-            .follower_states_by_leader
-            .get_mut(&leader_id)
-            .into_iter()
-            .flatten()
-        {
-            update_fn(state, cx);
-        }
-        self.leader_updated(leader_id, cx);
-    }
-
     fn leader_updated(&mut self, leader_id: PeerId, cx: &mut ViewContext<Self>) -> Option<()> {
         cx.notify();
 
@@ -2126,7 +2148,7 @@ impl Workspace {
             call::ParticipantLocation::SharedProject { project_id } => {
                 if Some(project_id) == self.project.read(cx).remote_id() {
                     for (pane, state) in self.follower_states_by_leader.get(&leader_id)? {
-                        if let Some(FollowerItem::Loaded(item)) = state
+                        if let Some(item) = state
                             .active_view_id
                             .and_then(|id| state.items_by_leader_view_id.get(&id))
                         {
@@ -2575,6 +2597,24 @@ impl View for Workspace {
     }
 }
 
+impl ViewId {
+    pub(crate) fn from_proto(message: proto::ViewId) -> Result<Self> {
+        Ok(Self {
+            creator: message
+                .creator
+                .ok_or_else(|| anyhow!("creator is missing"))?,
+            id: message.id,
+        })
+    }
+
+    pub(crate) fn to_proto(&self) -> proto::ViewId {
+        proto::ViewId {
+            creator: Some(self.creator),
+            id: self.id,
+        }
+    }
+}
+
 pub trait WorkspaceHandle {
     fn file_project_paths(&self, cx: &AppContext) -> Vec<ProjectPath>;
 }
@@ -2719,8 +2759,8 @@ mod tests {
     pub fn default_item_factory(
         _workspace: &mut Workspace,
         _cx: &mut ViewContext<Workspace>,
-    ) -> Box<dyn ItemHandle> {
-        unimplemented!();
+    ) -> Option<Box<dyn ItemHandle>> {
+        unimplemented!()
     }
 
     #[gpui::test]

crates/zed/Cargo.toml 🔗

@@ -48,7 +48,7 @@ rpc = { path = "../rpc" }
 settings = { path = "../settings" }
 sum_tree = { path = "../sum_tree" }
 text = { path = "../text" }
-terminal = { path = "../terminal" }
+terminal_view = { path = "../terminal_view" }
 theme = { path = "../theme" }
 theme_selector = { path = "../theme_selector" }
 theme_testbench = { path = "../theme_testbench" }

crates/zed/src/main.rs 🔗

@@ -32,13 +32,15 @@ use settings::{
 use smol::process::Command;
 use std::fs::OpenOptions;
 use std::{env, ffi::OsStr, panic, path::PathBuf, sync::Arc, thread, time::Duration};
-use terminal::terminal_container_view::{get_working_directory, TerminalContainer};
+use terminal_view::{get_working_directory, TerminalView};
 
 use fs::RealFs;
 use settings::watched_json::{watch_keymap_file, watch_settings_file, WatchedJsonFile};
 use theme::ThemeRegistry;
 use util::{channel::RELEASE_CHANNEL, paths, ResultExt, TryFutureExt};
-use workspace::{self, item::ItemHandle, AppState, NewFile, OpenPaths, Workspace};
+use workspace::{
+    self, item::ItemHandle, notifications::NotifyResultExt, AppState, NewFile, OpenPaths, Workspace,
+};
 use zed::{self, build_window_options, initialize_workspace, languages, menus};
 
 fn main() {
@@ -119,7 +121,7 @@ fn main() {
         diagnostics::init(cx);
         search::init(cx);
         vim::init(cx);
-        terminal::init(cx);
+        terminal_view::init(cx);
         theme_testbench::init(cx);
 
         cx.spawn(|cx| watch_themes(fs.clone(), themes.clone(), cx))
@@ -150,7 +152,7 @@ fn main() {
             fs,
             build_window_options,
             initialize_workspace,
-            default_item_factory,
+            dock_default_item_factory,
         });
         auto_update::init(http, client::ZED_SERVER_URL.clone(), cx);
 
@@ -591,10 +593,10 @@ async fn handle_cli_connection(
     }
 }
 
-pub fn default_item_factory(
+pub fn dock_default_item_factory(
     workspace: &mut Workspace,
     cx: &mut ViewContext<Workspace>,
-) -> Box<dyn ItemHandle> {
+) -> Option<Box<dyn ItemHandle>> {
     let strategy = cx
         .global::<Settings>()
         .terminal_overrides
@@ -604,8 +606,15 @@ pub fn default_item_factory(
 
     let working_directory = get_working_directory(workspace, cx, strategy);
 
-    let terminal_handle = cx.add_view(|cx| {
-        TerminalContainer::new(working_directory, false, workspace.database_id(), cx)
-    });
-    Box::new(terminal_handle)
+    let window_id = cx.window_id();
+    let terminal = workspace
+        .project()
+        .update(cx, |project, cx| {
+            project.create_terminal(working_directory, window_id, cx)
+        })
+        .notify_err(workspace, cx)?;
+
+    let terminal_view = cx.add_view(|cx| TerminalView::new(terminal, workspace.database_id(), cx));
+
+    Some(Box::new(terminal_view))
 }

crates/zed/src/zed.rs 🔗

@@ -15,12 +15,16 @@ use editor::{Editor, MultiBuffer};
 
 use gpui::{
     actions,
-    geometry::vector::vec2f,
+    geometry::{
+        rect::RectF,
+        vector::{vec2f, Vector2F},
+    },
     impl_actions,
     platform::{WindowBounds, WindowOptions},
     AssetSource, AsyncAppContext, TitlebarOptions, ViewContext, WindowKind,
 };
 use language::Rope;
+use lazy_static::lazy_static;
 pub use lsp;
 pub use project;
 use project_panel::ProjectPanel;
@@ -68,6 +72,17 @@ actions!(
 
 const MIN_FONT_SIZE: f32 = 6.0;
 
+lazy_static! {
+    static ref ZED_WINDOW_SIZE: Option<Vector2F> = env::var("ZED_WINDOW_SIZE")
+        .ok()
+        .as_deref()
+        .and_then(parse_pixel_position_env_var);
+    static ref ZED_WINDOW_POSITION: Option<Vector2F> = env::var("ZED_WINDOW_POSITION")
+        .ok()
+        .as_deref()
+        .and_then(parse_pixel_position_env_var);
+}
+
 pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::MutableAppContext) {
     cx.add_action(about);
     cx.add_global_action(|_: &Hide, cx: &mut gpui::MutableAppContext| {
@@ -336,8 +351,13 @@ pub fn initialize_workspace(
 }
 
 pub fn build_window_options() -> WindowOptions<'static> {
+    let bounds = if let Some((position, size)) = ZED_WINDOW_POSITION.zip(*ZED_WINDOW_SIZE) {
+        WindowBounds::Fixed(RectF::new(position, size))
+    } else {
+        WindowBounds::Maximized
+    };
     WindowOptions {
-        bounds: WindowBounds::Maximized,
+        bounds,
         titlebar: Some(TitlebarOptions {
             title: None,
             appears_transparent: true,
@@ -612,6 +632,13 @@ fn schema_file_match(path: &Path) -> &Path {
         .unwrap()
 }
 
+fn parse_pixel_position_env_var(value: &str) -> Option<Vector2F> {
+    let mut parts = value.split(',');
+    let width: usize = parts.next()?.parse().ok()?;
+    let height: usize = parts.next()?.parse().ok()?;
+    Some(vec2f(width as f32, height as f32))
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;

script/bundle 🔗

@@ -51,13 +51,13 @@ cp -R target/x86_64-apple-darwin/release/WebRTC.framework "${app_path}/Contents/
 
 if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTARIZATION_USERNAME && -n $APPLE_NOTARIZATION_PASSWORD ]]; then
     echo "Signing bundle with Apple-issued certificate"
-    security create-keychain -p $MACOS_CERTIFICATE_PASSWORD zed.keychain || echo ""
+    security create-keychain -p "$MACOS_CERTIFICATE_PASSWORD" zed.keychain || echo ""
     security default-keychain -s zed.keychain
-    security unlock-keychain -p $MACOS_CERTIFICATE_PASSWORD zed.keychain
-    echo $MACOS_CERTIFICATE | base64 --decode > /tmp/zed-certificate.p12
-    security import /tmp/zed-certificate.p12 -k zed.keychain -P $MACOS_CERTIFICATE_PASSWORD -T /usr/bin/codesign
+    security unlock-keychain -p "$MACOS_CERTIFICATE_PASSWORD" zed.keychain
+    echo "$MACOS_CERTIFICATE" | base64 --decode > /tmp/zed-certificate.p12
+    security import /tmp/zed-certificate.p12 -k zed.keychain -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
     rm /tmp/zed-certificate.p12
-    security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k $MACOS_CERTIFICATE_PASSWORD zed.keychain
+    security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$MACOS_CERTIFICATE_PASSWORD" zed.keychain
     /usr/bin/codesign --force --deep --timestamp --options runtime --sign "Zed Industries, Inc." "${app_path}" -v
     security default-keychain -s login.keychain
 else
@@ -66,22 +66,31 @@ else
     codesign --force --deep --sign - "${app_path}" -v
 fi
 
+dmg_target_directory="target/release"
+dmg_source_directory="${dmg_target_directory}/dmg"
+dmg_file_path="${dmg_target_directory}/Zed.dmg"
+
 echo "Creating DMG"
-mkdir -p target/release/dmg
-rm -rf  target/release/dmg/*
-mv "${app_path}" target/release/dmg/
-hdiutil create -volname Zed -srcfolder target/release/dmg -ov -format UDZO target/release/Zed.dmg
+rm -rf ${dmg_source_directory}
+mkdir -p ${dmg_source_directory}
+mv "${app_path}" "${dmg_source_directory}"
+
+ln -s /Applications ${dmg_source_directory}
+hdiutil create -volname Zed -srcfolder "${dmg_source_directory}" -ov -format UDZO "${dmg_file_path}"
+# If someone runs this bundle script locally, a symlink will be placed in `dmg_source_directory`.
+# This symlink causes CPU issues with Zed if the Zed codebase is the project being worked on, so we simply remove it for now.
+rm ${dmg_source_directory}/Applications
 
 if [[ -n $MACOS_CERTIFICATE && -n $MACOS_CERTIFICATE_PASSWORD && -n $APPLE_NOTARIZATION_USERNAME && -n $APPLE_NOTARIZATION_PASSWORD ]]; then
     echo "Notarizing DMG with Apple"
     npm install -g notarize-cli
-    npx notarize-cli --file target/release/Zed.dmg --bundle-id dev.zed.Zed --username $APPLE_NOTARIZATION_USERNAME --password $APPLE_NOTARIZATION_PASSWORD
+    npx notarize-cli --file ${dmg_file_path} --bundle-id dev.zed.Zed --username "$APPLE_NOTARIZATION_USERNAME" --password "$APPLE_NOTARIZATION_PASSWORD"
 fi
 
-# If -o option is specified, open the target/release directory in Finder to reveal the DMG
+# If -o option is specified, open the $dmg_target_directory directory in Finder to reveal the DMG
 while getopts o flag
 do
     case "${flag}" in
-        o) open target/release;;
+        o) open $dmg_target_directory;;
     esac
 done

script/start-local-collaboration 🔗

@@ -0,0 +1,50 @@
+#!/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
+
+# 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.
+resolution_line=$(system_profiler SPDisplaysDataType | grep Resolution | head -n1)
+screen_size=($(echo $resolution_line | egrep -o '[0-9]+'))
+scale_factor=1
+if [[ $resolution_line =~ Retina ]]; then scale_factor=2; fi
+width=$(expr ${screen_size[0]} / 2 / $scale_factor)
+height=${screen_size[1] / $scale_factor}
+
+position_1=0,0
+position_2=${width},0
+
+# Authenticate using the collab server's admin secret.
+export ZED_ADMIN_API_TOKEN=secret
+export ZED_SERVER_URL=http://localhost:8080
+export ZED_WINDOW_SIZE=${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=${username_1} ZED_WINDOW_POSITION=${position_1} target/debug/Zed $@ &
+ZED_IMPERSONATE=${username_2} ZED_WINDOW_POSITION=${position_2} target/debug/Zed &
+wait

styles/src/styleTree/components.ts 🔗

@@ -12,8 +12,16 @@ function isStyleSet(key: any): key is StyleSets {
     "negative",
   ].includes(key);
 }
+
 function isStyle(key: any): key is Styles {
-  return ["default", "active", "disabled", "hovered", "pressed", "inverted"].includes(key);
+  return [
+    "default",
+    "active",
+    "disabled",
+    "hovered",
+    "pressed",
+    "inverted",
+  ].includes(key);
 }
 function getStyle(
   layer: Layer,

styles/src/styleTree/simpleMessageNotification.ts 🔗

@@ -7,11 +7,11 @@ export default function simpleMessageNotification(colorScheme: ColorScheme): Obj
   let layer = colorScheme.middle;
   return {
     message: {
-      ...text(layer, "sans", { size: "md" }),
+      ...text(layer, "sans", { size: "xs" }),
       margin: { left: headerPadding, right: headerPadding },
     },
     actionMessage: {
-      ...text(layer, "sans", { size: "md" }),
+      ...text(layer, "sans", { size: "xs" }),
       margin: { left: headerPadding, top: 6, bottom: 6 },
       hover: {
         color: foreground(layer, "hovered"),