diff --git a/Cargo.lock b/Cargo.lock index 5ad4633259e6a6b5dd82ddfd3a5bdc06a4a370bf..102c8e329e01dce406112ddd846b471d0d9bcf59 100644 --- a/Cargo.lock +++ b/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", diff --git a/crates/auto_update/src/update_notification.rs b/crates/auto_update/src/update_notification.rs index d6f94c708d24dad6cccb6d918ac6074043567906..e6b285b0728355751881686c37b26753fb76c888 100644 --- a/crates/auto_update/src/update_notification.rs +++ b/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::().theme.clone(); - let theme = &theme.simple_message_notification; + let theme = &theme.update_notification; let app_name = cx.global::().display_name(); diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index a7a3331d20be6b93701999de33d0922e184e38af..c0a6cedc622d6db2d6bec4a3ff3895182c541f85 100644 --- a/crates/call/Cargo.toml +++ b/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" } diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 8deb4341180afa02c39dc096f78b95714d93784a..1d22fe50f1a8ff4e9ac530ff991f5f9010a2b4e6 100644 --- a/crates/call/src/room.rs +++ b/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, @@ -41,7 +41,7 @@ pub struct Room { live_kit: Option, status: RoomStatus, local_participant: LocalParticipant, - remote_participants: BTreeMap, + remote_participants: BTreeMap, pending_participants: Vec>, participant_user_ids: HashSet, pending_call_count: usize, @@ -50,7 +50,7 @@ pub struct Room { user_store: ModelHandle, subscriptions: Vec, pending_room_update: Option>, - maintain_connection: Option>>, + maintain_connection: 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 { + pub fn remote_participants(&self) -> &BTreeMap { &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) diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 5e10f9ea8f4374d3e0607be2a0f68fc5259bc431..6d9ec305b697981a6c1c4f8492655db4d9eb6757 100644 --- a/crates/client/src/client.rs +++ b/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, cx: &AppContext) -> Arc { 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; diff --git a/crates/client/src/test.rs b/crates/client/src/test.rs index 3cfba3b1847c4af4655ad625840492db59249974..db9e0d8c487b27a7474373af9d2c25a29e04b9d7 100644 --- a/crates/client/src/test.rs +++ b/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(); diff --git a/crates/collab/.env.toml b/crates/collab/.env.toml index 1945d9cb66b33ee36a8e17f4ebbc8af54f346c5c..b4a6694e5e6578e771406d6947c2c4ad8d2efb0a 100644 --- a/crates/collab/.env.toml +++ b/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" diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index a4ccabf099ce14ce93ee1a8623b77ba1889a2a73..f9e9194496a290fe66297dafc3503ac6ef6f14bb 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] default-run = "collab" edition = "2021" name = "collab" -version = "0.3.0" +version = "0.4.0" [[bin]] name = "collab" diff --git a/crates/collab/k8s/manifest.template.yml b/crates/collab/k8s/manifest.template.yml index 1f0fafb170256b7a75268d6349c38d89cf7f9d69..339d02892ef2cf24f40815c3fd32dc0fb72c317a 100644 --- a/crates/collab/k8s/manifest.template.yml +++ b/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. diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index b9cc8ef9575f360e8c8e940673fc256e602b451f..d002c8a135caec2590be6cf76efda340bcfd04f3 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/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 +); diff --git a/crates/collab/migrations/20221111092550_reconnection_support.sql b/crates/collab/migrations/20221111092550_reconnection_support.sql index 5e8bada2f9492b91212108e0eae1b0b99d53b63a..3289f6bbddb63e08acdc5e89a900193359423b2c 100644 --- a/crates/collab/migrations/20221111092550_reconnection_support.sql +++ b/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" ( diff --git a/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql b/crates/collab/migrations/20221214144346_change_epoch_from_uuid_to_integer.sql new file mode 100644 index 0000000000000000000000000000000000000000..5e02f76ce25d59d799d5e5d9719e4e038d1bac02 --- /dev/null +++ b/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"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 999c87f9ee38f74597f92148ccba40c548a27ce1..b1cbddc77ea398b26d5a9b00bb0216b097a45adc 100644 --- a/crates/collab/src/db.rs +++ b/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>, #[cfg(test)] runtime: Option, - epoch: parking_lot::RwLock, } 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 { + 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> { + pub async fn stale_room_ids( + &self, + environment: &str, + new_server_id: ServerId, + ) -> Result> { 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> { + pub async fn refresh_room( + &self, + room_id: RoomId, + new_server_id: ServerId, + ) -> Result> { 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> { + 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> { 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, ) -> Result> { @@ -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, user_id: UserId, - ) -> Result> { - self.room_transaction(|tx| async move { + ) -> Result>> { + 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, - calling_connection_id: ConnectionId, + room_id: RoomId, + calling_connection: ConnectionId, called_user_id: UserId, ) -> Result> { 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> { 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> { - self.room_transaction(|tx| async move { + pub async fn leave_room( + &self, + connection: ConnectionId, + ) -> Result>> { + 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> { 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>> { 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> { 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)>> { 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)>> { 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>> { 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>> { 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>> { 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> { 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> { 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>> { 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>> { 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::() .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> { - #[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::() .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(&self, f: F) -> Result> + async fn optional_room_transaction(&self, f: F) -> Result>> where F: Send + Fn(TransactionHandle) -> Fut, - Fut: Send + Future>, + Fut: Send + Future>>, { 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) { @@ -2371,6 +2592,23 @@ impl Database { self.run(body).await } + async fn room_transaction(&self, f: F) -> Result> + where + F: Send + Fn(TransactionHandle) -> Fut, + Fut: Send + Future>, + { + let data = self + .optional_room_transaction(move |tx| { + let future = f(tx); + async { + let data = future.await?; + Ok(Some(data)) + } + }) + .await?; + Ok(data.unwrap()) + } + async fn with_transaction(&self, f: &F) -> Result<(DatabaseTransaction, Result)> where F: Send + Fn(TransactionHandle) -> Fut, @@ -2607,6 +2845,7 @@ id_type!(RoomParticipantId); id_type!(ProjectId); id_type!(ProjectCollaboratorId); id_type!(ReplicaId); +id_type!(ServerId); id_type!(SignupId); id_type!(UserId); diff --git a/crates/collab/src/db/project.rs b/crates/collab/src/db/project.rs index 971a8fcefb465114c9703003e2a74f6f38d8c397..5b1f9f8467853344e1c57004e875ba434117195b 100644 --- a/crates/collab/src/db/project.rs +++ b/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, + pub host_connection_server_id: Option, +} + +impl Model { + pub fn host_connection(&self) -> Result { + 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)] diff --git a/crates/collab/src/db/project_collaborator.rs b/crates/collab/src/db/project_collaborator.rs index 5db307f5df27ec07f282207eb253ddae95c44970..a1a99d1170ae5cfb254fb1c85ce50034cf03d13b 100644 --- a/crates/collab/src/db/project_collaborator.rs +++ b/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, diff --git a/crates/collab/src/db/room_participant.rs b/crates/collab/src/db/room_participant.rs index c80c10c1bae25cc1b79a499a4ed5b310f2209527..f939a3bfb8709132b1aa138ea43e21a25b0f61af 100644 --- a/crates/collab/src/db/room_participant.rs +++ b/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, - pub answering_connection_epoch: Option, + pub answering_connection_server_id: Option, pub answering_connection_lost: bool, pub location_kind: Option, pub location_project_id: Option, pub initial_project_id: Option, pub calling_user_id: UserId, pub calling_connection_id: i32, - pub calling_connection_epoch: Uuid, + pub calling_connection_server_id: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/db/server.rs b/crates/collab/src/db/server.rs new file mode 100644 index 0000000000000000000000000000000000000000..e3905f244892e8befd97f52afc119341106cb566 --- /dev/null +++ b/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 {} diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 2d254c2e3702d48b4451301977a988fd1686d91f..9d42c11f8bba88bd164eaab443857d910b97230d 100644 --- a/crates/collab/src/db/tests.rs +++ b/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); } ); diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index 66a9a14b522732c63186a4d665adb74cebe4d09b..2629d340dac0e745e1027d3cd17913023fcb45d3 100644 --- a/crates/collab/src/integration_tests.rs +++ b/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::(); @@ -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::() + .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::>(); 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::>(); 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(); } } diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 7e0f23f5d4096cbf33aaf8ea6275381b96f9208a..27f49f5b1e1fb6a0ba7d6fba7f72efd88723d83d 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -97,6 +97,7 @@ pub struct Config { pub live_kit_secret: Option, pub rust_log: Option, pub log_json: Option, + pub zed_environment: String, } #[derive(Default, Deserialize)] diff --git a/crates/collab/src/main.rs b/crates/collab/src/main.rs index 710910fe033bdca14993e60a986fde578c9bb59a..0f783c13e58d3b64c0e034c18ea6173d63be4036 100644 --- a/crates/collab/src/main.rs +++ b/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::()) + .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?; } _ => { diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 89aec5bb05fb188ccc4601abc9b65a56eebc855e..6a8ae61ed0b7bff3ad44741a2a21528dbb32a5b7 100644 --- a/crates/collab/src/rpc.rs +++ b/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, peer: Arc, pub(crate) connection_pool: Arc>, app_state: Arc, @@ -167,9 +169,10 @@ where } impl Server { - pub fn new(app_state: Arc, executor: Executor) -> Arc { + pub fn new(id: ServerId, app_state: Arc, executor: Executor) -> Arc { 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(&mut self, handler: F) -> &mut Self where F: 'static + Send + Sync + Fn(TypedEnvelope, 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::>(); 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(); diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index ab414e051bcfeb2ec77ab6bac751853fe41b0ab8..2288f77cd358996f5e0ecba98d09d56a42a3dd76 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/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::( - 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::::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::::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::( - peer_id.0 as usize, - format!("Follow {} into external project", peer_github_login), - Some(Box::new(FollowNextCollaborator)), - theme.tooltip.clone(), - cx, - ) - .boxed() + }) + .with_tooltip::( + 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 } diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index abc62605f93cc34046e3663e41c0e561f14b3cb6..1b851c3f7595e000e3242bec529b9ff4c8d7bc45 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -54,7 +54,7 @@ pub fn init(app_state: Arc, 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); diff --git a/crates/collab_ui/src/contact_list.rs b/crates/collab_ui/src/contact_list.rs index bc8b2947c4b278fa05d6e78c091a6517b971e890..48a4d1a2b541d69f7fa8c41ab21da1e766103301 100644 --- a/crates/collab_ui/src/contact_list.rs +++ b/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::::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::::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 }); diff --git a/crates/db/src/query.rs b/crates/db/src/query.rs index 731fca15cb5c47b58e89aac1eb2a7b42189829c2..01132d383c2928937d26d7733870f2c430798a39 100644 --- a/crates/db/src/query.rs +++ b/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), diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 9122706ad388b1f736c4b7eca6cf853bdb3c92f2..89f5bb54a974d7d1643f7902247a51a7bdc41548 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/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); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1d5ee458b81555ca963ee4b68dac20d48fb4d4b1..8a3c7452ef5971a6dfdac39a0ab53d24dec3f550 100644 --- a/crates/editor/src/editor.rs +++ b/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, input_enabled: bool, leader_replica_id: Option, + remote_id: Option, hover_state: HoverState, link_go_to_definition_state: LinkGoToDefinitionState, _subscriptions: Vec, @@ -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::(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, - event: &language::Event, + event: &multi_buffer::Event, cx: &mut ViewContext, ) { 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, + predecessor: ExcerptId, + excerpts: Vec<(ExcerptId, ExcerptRange)>, + }, + ExcerptsRemoved { + ids: Vec, + }, 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, } diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 582c75904d383a4228aa58651ce2ed72fc2d46aa..2fcc5f0014ef942a9d12762a6ae424de1aabfcc1 100644 --- a/crates/editor/src/editor_tests.rs +++ b/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"; diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 5883091d4794c3fd7be7dad4617b902e944d4bd6..0057df778b3d2795e9f02dd21e2fbc6c341c8762 100644 --- a/crates/editor/src/items.rs +++ b/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 { + self.remote_id + } + fn from_state_proto( pane: ViewHandle, project: ModelHandle, + remote_id: ViewId, state: &mut Option, cx: &mut MutableAppContext, ) -> Option>>> { - 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::>(); + let buffers = project.update(cx, |project, cx| { + buffer_ids + .iter() + .map(|id| project.open_buffer_by_id(*id, cx)) + .collect::>() }); + Some(cx.spawn(|mut cx| async move { - let buffer = buffer.await?; - let editor = pane - .read_with(&cx, |pane, cx| { - pane.items_of_type::().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::(); + 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::>>()?; + 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 { - 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, - _: &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, message: update_view::Variant, cx: &mut ViewContext, - ) -> 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> { + 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::>(); + + let mut removals = message + .deleted_excerpts + .into_iter() + .map(ExcerptId::from_proto) + .collect::>(); + 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::>(); + let selections = message + .selections + .into_iter() + .filter_map(|selection| deserialize_selection(&multibuffer, selection)) + .collect::>(); + 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::>() + }); + + 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, +) -> Option { + 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) -> 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> { + 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> { 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 { + 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, cx: &mut ViewContext) -> bool { if let Ok(data) = data.downcast::() { diff --git a/crates/editor/src/multi_buffer.rs b/crates/editor/src/multi_buffer.rs index d758792e6c29f88e1b17a344c949d2c50f999454..d0dd34a931d36bbe83a1ad72aabe2e221e5a42be 100644 --- a/crates/editor/src/multi_buffer.rs +++ b/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, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + ExcerptsAdded { + buffer: ModelHandle, + predecessor: ExcerptId, + excerpts: Vec<(ExcerptId, ExcerptRange)>, + }, + ExcerptsRemoved { + ids: Vec, + }, + Edited, + Reloaded, + Reparsed, + Saved, + FileHandleChanged, + Closed, + DirtyChanged, + DiagnosticsUpdated, +} + #[derive(Clone)] struct History { next_transaction_id: TransactionId, @@ -833,6 +853,30 @@ impl MultiBuffer { ) -> Vec 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( + &mut self, + prev_excerpt_id: ExcerptId, + buffer: ModelHandle, + ranges: impl IntoIterator)>, + cx: &mut ModelContext, + ) 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.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.sync(cx); + let ids = excerpt_ids.into_iter().collect::>(); + 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, - event: &Event, + event: &language::Event, cx: &mut ModelContext, ) { - 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> { @@ -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)> { + self.excerpts + .iter() + .map(|excerpt| (excerpt.id, &excerpt.buffer, excerpt.range.clone())) + } + pub fn excerpt_boundaries_in_range( &self, range: R, @@ -2746,6 +2821,10 @@ impl MultiBufferSnapshot { } } + pub fn buffer_id_for_excerpt(&self, excerpt_id: ExcerptId) -> Option { + Some(self.excerpt(excerpt_id)?.buffer_id) + } + fn excerpt<'a>(&'a self, excerpt_id: ExcerptId) -> Option<&'a Excerpt> { let mut cursor = self.excerpts.cursor::>(); 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)); diff --git a/crates/language/src/proto.rs b/crates/language/src/proto.rs index 674ce4f50eb5988098c32c3d7557852f9e2b912a..9612deb5bd81a475f95911f35378d6e60238cd16 100644 --- a/crates/language/src/proto.rs +++ b/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]>) -> Vec) -> 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) -> Arc<[Selecti pub fn deserialize_selection(selection: proto::Selection) -> Option> { 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, }) diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 76c60f9556235605e59a62cc5bd8c70aa9aaff1b..dd4d2be5b6847b24d2cf952a73b313c7d1676f49 100644 --- a/crates/project/Cargo.toml +++ b/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" diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 3ea12617351ecc1708741ad1a60aef6e73702740..a0eb84558154e8f26c129ce6c7d5f7379b3fd0d6 100644 --- a/crates/project/src/lsp_command.rs +++ b/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, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 512ac702d062be924ac5183a9e98d34be80a45f7..7f2fcb516f82bd6102e9abd391c406b2b6e34af8 100644 --- a/crates/project/src/project.rs +++ b/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, fs: Arc, client_state: Option, - collaborators: HashMap, + collaborators: HashMap, client_subscriptions: Vec, _subscriptions: Vec, opened_buffer: (watch::Sender<()>, watch::Receiver<()>), - shared_buffers: HashMap>, + shared_buffers: HashMap>, #[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), 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 { + pub fn collaborators(&self) -> &HashMap { &self.collaborators } @@ -1193,6 +1193,34 @@ impl Project { !self.is_local() } + pub fn create_terminal( + &mut self, + working_directory: Option, + window_id: usize, + cx: &mut ModelContext, + ) -> Result> { + if self.is_remote() { + return Err(anyhow!( + "creating terminals as a guest is not supported yet" + )); + } else { + let settings = cx.global::(); + 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, - 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 { + Ok(Self { + peer_id: message.peer_id.ok_or_else(|| anyhow!("invalid peer id"))?, replica_id: message.replica_id as ReplicaId, - } + }) } } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index cf58adfe0b04f764af98b65419eaaca8cfaa546e..9528bd10b7de96ad1e05063c6c445786fc3d8f5c 100644 --- a/crates/rpc/proto/zed.proto +++ b/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; diff --git a/crates/rpc/src/macros.rs b/crates/rpc/src/macros.rs index 38d35893ee70c34615da14aabb561c417f83170b..89e605540da1157f5530ad7236b23358dc127c1a 100644 --- a/crates/rpc/src/macros.rs +++ b/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, - original_sender_id: Option, + original_sender_id: Option, ) -> Envelope { Envelope { id, diff --git a/crates/rpc/src/peer.rs b/crates/rpc/src/peer.rs index 66ba6a40292d87d13a83943b0e40239ee37b526d..d2a4e6e0804119cbdbcb316861c27caeab57850a 100644 --- a/crates/rpc/src/peer.rs +++ b/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 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 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 TypedEnvelope { } pub struct Peer { + epoch: AtomicU32, pub connections: RwLock>, 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 { + pub fn new(epoch: u32) -> Arc { 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( self: &Arc, @@ -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(); diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 6d9bc9a0aa348af8c1a14f442323fcf06064688e..385caf3565f639b973007a67669378f59584ea55 100644 --- a/crates/rpc/src/proto.rs +++ b/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, - original_sender_id: Option, + original_sender_id: Option, ) -> Envelope; fn from_envelope(envelope: Envelope) -> Option; } @@ -74,6 +76,66 @@ impl AnyTypedEnvelope for TypedEnvelope { } } +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 { + Some(self.cmp(other)) + } +} + +impl std::hash::Hash for PeerId { + fn hash(&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 { + 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); + } } diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 1d4a4496d0af0f1bb9cff6f584c7561ac6fa99e2..d06f1c1c5ce0c1e54ea1a5d85c0b8acb8edb5f23 100644 --- a/crates/rpc/src/rpc.rs +++ b/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; diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 13b754a4178eae117aea5b1090751c6b738c27ec..1659ddd4517167f87c5e4ec2d28a0123ae75cf0b 100644 --- a/crates/search/src/project_search.rs +++ b/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(); diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 5137751579e90fc52aa8efbd0f049975fd79cc54..f0c64a1bb995f3a710301de16fd8c00e02dc0088 100644 --- a/crates/settings/src/settings.rs +++ b/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(&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 { + 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 { diff --git a/crates/terminal/Cargo.toml b/crates/terminal/Cargo.toml index 5593ee92d4dc4fc4135c8f30a6dbaeee6753eb6d..0dea7bfbcfa80ef39150f68d26f5b74978c53b65 100644 --- a/crates/terminal/Cargo.toml +++ b/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" diff --git a/crates/terminal/src/terminal.rs b/crates/terminal/src/terminal.rs index 0cbb6d36b11bffd07c04f5fae65504b0dac29136..7cdac33cda45da3f9c85f3144f1be04e7abadbf0 100644 --- a/crates/terminal/src/terminal.rs +++ b/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, - pub shell: Option, + pub shell: Shell, pub source: std::io::Error, } @@ -238,24 +226,20 @@ impl TerminalError { }) } - pub fn shell_to_string(&self) -> Option { - self.shell.as_ref().map(|shell| match shell { + pub fn shell_to_string(&self) -> String { + match &self.shell { Shell::System => "".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 => "".to_string(), - - Shell::Program(s) => s, - Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")), - }) - .unwrap_or_else(|| "".to_string()) + match &self.shell { + Shell::System => "".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, - shell: Option, - env: Option>, + shell: Shell, + mut env: HashMap, blink_settings: Option, - alternate_scroll: &AlternateScroll, + alternate_scroll: AlternateScroll, window_id: usize, - item_id: ItemId, - workspace_id: WorkspaceId, ) -> Result { 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, - mode: TermMode, - display_offset: usize, - selection_text: Option, - selection: Option, - cursor: RenderableCursor, - cursor_char: char, - size: TerminalSize, - last_hovered_hyperlink: Option<(String, RangeInclusive, usize)>, + pub cells: Vec, + pub mode: TermMode, + pub display_offset: usize, + pub selection_text: Option, + pub selection: Option, + pub cursor: RenderableCursor, + pub cursor_char: char, + pub size: TerminalSize, + pub last_hovered_hyperlink: Option<(String, RangeInclusive, usize)>, } impl Default for TerminalContent { @@ -525,19 +505,17 @@ pub struct Terminal { /// This is only used for terminal hyperlink checking last_mouse_position: Option, pub matches: Vec>, - last_content: TerminalContent, + pub last_content: TerminalContent, last_synced: Instant, sync_task: Option>, - selection_head: Option, - breadcrumb_text: String, + pub selection_head: Option, + pub breadcrumb_text: String, shell_pid: u32, shell_fd: u32, - foreground_process_info: Option, + pub foreground_process_info: Option, 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, ) -> Task>> { 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>) { + 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, + ) + } } diff --git a/crates/terminal/src/terminal_container_view.rs b/crates/terminal/src/terminal_container_view.rs deleted file mode 100644 index 8f4bfeeb5364d6c3c49f20976230c17efb148379..0000000000000000000000000000000000000000 --- a/crates/terminal/src/terminal_container_view.rs +++ /dev/null @@ -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::(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), - Error(ViewHandle), -} - -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, -} - -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, - ) { - let strategy = cx - .global::() - .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, - modal: bool, - workspace_id: WorkspaceId, - cx: &mut ViewContext, - ) -> Self { - let settings = cx.global::(); - 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::().unwrap(), - }); - TerminalContainerContent::Error(view) - } - }; - - TerminalContainer { - content, - associated_directory: working_directory, - } - } - - fn connected(&self) -> Option> { - 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) { - 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::(); - 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, - 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, - ) -> Option { - //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 { - 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) {} - - fn can_save(&self, _cx: &gpui::AppContext) -> bool { - false - } - - fn save( - &mut self, - _project: gpui::ModelHandle, - _cx: &mut ViewContext, - ) -> gpui::Task> { - unreachable!("save should not have been called"); - } - - fn save_as( - &mut self, - _project: gpui::ModelHandle, - _abs_path: std::path::PathBuf, - _cx: &mut ViewContext, - ) -> gpui::Task> { - unreachable!("save_as should not have been called"); - } - - fn reload( - &mut self, - _project: gpui::ModelHandle, - _cx: &mut ViewContext, - ) -> gpui::Task> { - 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) -> Option> { - Some(Box::new(handle.clone())) - } - - fn to_item_events(event: &Self::Event) -> Vec { - 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> { - 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, - _workspace: WeakViewHandle, - workspace_id: workspace::WorkspaceId, - item_id: workspace::ItemId, - cx: &mut ViewContext, - ) -> Task>> { - 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) { - 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; - - 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 { - match event { - Event::Wakeup => Some(SearchEvent::MatchesInvalidated), - Event::SelectionsChanged => Some(SearchEvent::ActiveMatchChanged), - _ => None, - } - } - - /// Clear stored matches - fn clear_matches(&mut self, cx: &mut ViewContext) { - 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, cx: &mut ViewContext) { - 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) -> 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, cx: &mut ViewContext) { - 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, - ) -> Task> { - 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, - cx: &mut ViewContext, - ) -> Option { - 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 { - 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 { - 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 { - 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 { - 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())); - }); - } -} diff --git a/crates/terminal/src/terminal_view.rs b/crates/terminal/src/terminal_view.rs deleted file mode 100644 index 21e055319af62bfa42b168635fdbb5129c061492..0000000000000000000000000000000000000000 --- a/crates/terminal/src/terminal_view.rs +++ /dev/null @@ -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, - 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, - 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, - modal: bool, - cx: &mut ViewContext, - ) -> 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 { - 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) { - self.has_bell = false; - cx.emit(Event::Wakeup); - } - - pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext) { - 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) { - 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::() - .terminal_overrides - .option_as_meta - .unwrap_or(false), - ) - }); - } - } - - fn clear(&mut self, _: &Clear, cx: &mut ViewContext) { - 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 - .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) { - 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.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, - ) -> Task>> { - self.terminal - .update(cx, |term, cx| term.find_matches(query, cx)) - } - - pub fn terminal(&self) -> &ModelHandle { - &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) { - 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.terminal.update(cx, |term, _| term.copy()) - } - - ///Attempt to paste the clipboard into the terminal - fn paste(&mut self, _: &Paste, cx: &mut ViewContext) { - 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.clear_bel(cx); - self.terminal.update(cx, |term, _| { - term.input(text.0.to_string()); - }); - } - - fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext) { - 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::() - .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.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.terminal.update(cx, |terminal, _| { - terminal.focus_out(); - }); - cx.notify(); - } - - fn key_down(&mut self, event: &gpui::KeyDownEvent, cx: &mut ViewContext) -> bool { - self.clear_bel(cx); - self.pause_cursor_blinking(cx); - - self.terminal.update(cx, |term, cx| { - term.try_keystroke( - &event.keystroke, - cx.global::() - .terminal_overrides - .option_as_meta - .unwrap_or(false), - ) - }) - } - - //IME stuff - fn selected_text_range(&self, cx: &AppContext) -> Option> { - 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>, - text: &str, - cx: &mut ViewContext, - ) { - 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 - } -} diff --git a/crates/terminal/src/tests/terminal_test_context.rs b/crates/terminal/src/tests/terminal_test_context.rs deleted file mode 100644 index 67ebb558052f40a7f0d273034c3f7802fe8586ba..0000000000000000000000000000000000000000 --- a/crates/terminal/src/tests/terminal_test_context.rs +++ /dev/null @@ -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, ViewHandle) { - 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, - path: impl AsRef, - ) -> (ModelHandle, 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, - path: impl AsRef, - ) -> (ModelHandle, Entry) { - self.create_wt(project, false, path).await - } - - async fn create_wt( - &mut self, - project: ModelHandle, - is_dir: bool, - path: impl AsRef, - ) -> (ModelHandle, 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, - entry: Entry, - project: ModelHandle, - ) { - 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>) { - 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); - } -} diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..05fda2c75f6893c5d502b6b223044f8bceaa5ad6 --- /dev/null +++ b/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" diff --git a/crates/terminal/README.md b/crates/terminal_view/README.md similarity index 100% rename from crates/terminal/README.md rename to crates/terminal_view/README.md diff --git a/crates/terminal/scripts/print256color.sh b/crates/terminal_view/scripts/print256color.sh similarity index 100% rename from crates/terminal/scripts/print256color.sh rename to crates/terminal_view/scripts/print256color.sh diff --git a/crates/terminal/scripts/truecolor.sh b/crates/terminal_view/scripts/truecolor.sh similarity index 100% rename from crates/terminal/scripts/truecolor.sh rename to crates/terminal_view/scripts/truecolor.sh diff --git a/crates/terminal/src/persistence.rs b/crates/terminal_view/src/persistence.rs similarity index 81% rename from crates/terminal/src/persistence.rs rename to crates/terminal_view/src/persistence.rs index 1669a3a546773fa461d94152953e962d4ac6ec7c..f090b384a44ae1c0a0e5743ed9060e14ccf6d052 100644 --- a/crates/terminal/src/persistence.rs +++ b/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 = + pub static ref TERMINAL_DB: TerminalDb = &[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> { - SELECT working_directory - FROM terminals + pub async fn take_working_directory(item_id: ItemId, workspace_id: WorkspaceId) -> Result> { + DELETE FROM terminals WHERE item_id = ? AND workspace_id = ? + RETURNING working_directory } } } diff --git a/crates/terminal/src/terminal_element.rs b/crates/terminal_view/src/terminal_element.rs similarity index 98% rename from crates/terminal/src/terminal_element.rs rename to crates/terminal_view/src/terminal_element.rs index adfcb47024c91c79e420edbad98101599745bcfb..08ed3ecc2d1da04352dac73a9438a5f3087c8b74 100644 --- a/crates/terminal/src/terminal_element.rs +++ b/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, diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs new file mode 100644 index 0000000000000000000000000000000000000000..7602a3db2223651d95b03ded49936da1d273888c --- /dev/null +++ b/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::(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, + has_new_content: bool, + //Currently using iTerm bell, show bell emoji in tab until input is received + has_bell: bool, + context_menu: ViewHandle, + 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, + ) { + let strategy = cx.global::().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, + workspace_id: WorkspaceId, + cx: &mut ViewContext, + ) -> 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 { + 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) { + self.has_bell = false; + cx.emit(Event::Wakeup); + } + + pub fn deploy_context_menu(&mut self, action: &DeployContextMenu, cx: &mut ViewContext) { + 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) { + 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::() + .terminal_overrides + .option_as_meta + .unwrap_or(false), + ) + }); + } + } + + fn clear(&mut self, _: &Clear, cx: &mut ViewContext) { + 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 + .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) { + 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.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, + ) -> Task>> { + 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 { + &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) { + 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.terminal.update(cx, |term, _| term.copy()) + } + + ///Attempt to paste the clipboard into the terminal + fn paste(&mut self, _: &Paste, cx: &mut ViewContext) { + 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.clear_bel(cx); + self.terminal.update(cx, |term, _| { + term.input(text.0.to_string()); + }); + } + + fn send_keystroke(&mut self, text: &SendKeystroke, cx: &mut ViewContext) { + 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::() + .terminal_overrides + .option_as_meta + .unwrap_or(false), + ); + }); + } + } +} + +pub fn regex_search_for_query(query: project::search::SearchQuery) -> Option { + 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.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.terminal.update(cx, |terminal, _| { + terminal.focus_out(); + }); + cx.notify(); + } + + fn key_down(&mut self, event: &gpui::KeyDownEvent, cx: &mut ViewContext) -> bool { + self.clear_bel(cx); + self.pause_cursor_blinking(cx); + + self.terminal.update(cx, |term, cx| { + term.try_keystroke( + &event.keystroke, + cx.global::() + .terminal_overrides + .option_as_meta + .unwrap_or(false), + ) + }) + } + + //IME stuff + fn selected_text_range(&self, cx: &AppContext) -> Option> { + 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>, + text: &str, + cx: &mut ViewContext, + ) { + 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, + 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, + ) -> Option { + //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 { + 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) {} + + fn can_save(&self, _cx: &gpui::AppContext) -> bool { + false + } + + fn save( + &mut self, + _project: gpui::ModelHandle, + _cx: &mut ViewContext, + ) -> gpui::Task> { + unreachable!("save should not have been called"); + } + + fn save_as( + &mut self, + _project: gpui::ModelHandle, + _abs_path: std::path::PathBuf, + _cx: &mut ViewContext, + ) -> gpui::Task> { + unreachable!("save_as should not have been called"); + } + + fn reload( + &mut self, + _project: gpui::ModelHandle, + _cx: &mut ViewContext, + ) -> gpui::Task> { + 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) -> Option> { + Some(Box::new(handle.clone())) + } + + fn to_item_events(event: &Self::Event) -> Vec { + 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> { + 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, + _workspace: WeakViewHandle, + workspace_id: workspace::WorkspaceId, + item_id: workspace::ItemId, + cx: &mut ViewContext, + ) -> Task>> { + 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) { + 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; + + 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 { + 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.terminal().update(cx, |term, _| term.matches.clear()) + } + + /// Store matches returned from find_matches somewhere for rendering + fn update_matches(&mut self, matches: Vec, cx: &mut ViewContext) { + 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) -> 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, cx: &mut ViewContext) { + 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, + ) -> Task> { + 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, + cx: &mut ViewContext, + ) -> Option { + // 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 { + 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 { + 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 { + 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 { + 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, ViewHandle) { + 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, + path: impl AsRef, + cx: &mut TestAppContext, + ) -> (ModelHandle, Entry) { + create_wt(project, true, path, cx).await + } + + ///Creates a worktree with 1 file: /root{suffix}.txt + async fn create_file_wt( + project: ModelHandle, + path: impl AsRef, + cx: &mut TestAppContext, + ) -> (ModelHandle, Entry) { + create_wt(project, false, path, cx).await + } + + async fn create_wt( + project: ModelHandle, + is_dir: bool, + path: impl AsRef, + cx: &mut TestAppContext, + ) -> (ModelHandle, 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, + entry: Entry, + project: ModelHandle, + 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)); + }); + } +} diff --git a/crates/text/src/text.rs b/crates/text/src/text.rs index 5c2f7b7a518e63d00568db573960f23e69cb6093..5aa91ede8aa61e61104c21b7fb671c33d8bdcd21 100644 --- a/crates/text/src/text.rs +++ b/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 } diff --git a/crates/workspace/src/dock.rs b/crates/workspace/src/dock.rs index a9cfc3fc72829fd73183d815c6e93dceebd3890c..19fed4bf59004f917d86943f96cd45f8e0c6c4ab 100644 --- a/crates/workspace/src/dock.rs +++ b/crates/workspace/src/dock.rs @@ -126,18 +126,21 @@ impl DockPosition { } } -pub type DefaultItemFactory = - fn(&mut Workspace, &mut ViewContext) -> Box; +pub type DockDefaultItemFactory = + fn(workspace: &mut Workspace, cx: &mut ViewContext) -> Option>; pub struct Dock { position: DockPosition, panel_sizes: HashMap, pane: ViewHandle, - default_item_factory: DefaultItemFactory, + default_item_factory: DockDefaultItemFactory, } impl Dock { - pub fn new(default_item_factory: DefaultItemFactory, cx: &mut ViewContext) -> Self { + pub fn new( + default_item_factory: DockDefaultItemFactory, + cx: &mut ViewContext, + ) -> Self { let position = DockPosition::Hidden(cx.global::().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, - ) -> Box { - Box::new(cx.add_view(|_| TestItem::new())) + ) -> Option> { + Some(Box::new(cx.add_view(|_| TestItem::new()))) } #[gpui::test] diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 14f847fd54b429f43e5aaa0ea352588995f2532c..0a8311fd5cb3998a392908c522fc7f5e1efbe792 100644 --- a/crates/workspace/src/item.rs +++ b/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 ItemHandle for ViewHandle { 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 ItemHandle for ViewHandle { 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; fn to_state_proto(&self, cx: &AppContext) -> Option; fn from_state_proto( pane: ViewHandle, project: ModelHandle, + id: ViewId, state: &mut Option, cx: &mut MutableAppContext, ) -> Option>>>; @@ -599,15 +609,17 @@ pub trait FollowableItem: Item { ) -> bool; fn apply_update_proto( &mut self, + project: &ModelHandle, message: proto::update_view::Variant, cx: &mut ViewContext, - ) -> Result<()>; + ) -> Task>; fn set_leader_replica_id(&mut self, leader_replica_id: Option, cx: &mut ViewContext); fn should_unfollow_on_event(event: &Self::Event, cx: &AppContext) -> bool; } pub trait FollowableItemHandle: ItemHandle { + fn remote_id(&self, client: &Arc, cx: &AppContext) -> Option; fn set_leader_replica_id(&self, leader_replica_id: Option, cx: &mut MutableAppContext); fn to_state_proto(&self, cx: &AppContext) -> Option; fn add_event_to_update_proto( @@ -618,13 +630,23 @@ pub trait FollowableItemHandle: ItemHandle { ) -> bool; fn apply_update_proto( &self, + project: &ModelHandle, message: proto::update_view::Variant, cx: &mut MutableAppContext, - ) -> Result<()>; + ) -> Task>; fn should_unfollow_on_event(&self, event: &dyn Any, cx: &AppContext) -> bool; } impl FollowableItemHandle for ViewHandle { + fn remote_id(&self, client: &Arc, cx: &AppContext) -> Option { + 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, cx: &mut MutableAppContext) { self.update(cx, |this, cx| { this.set_leader_replica_id(leader_replica_id, cx) @@ -650,10 +672,11 @@ impl FollowableItemHandle for ViewHandle { fn apply_update_proto( &self, + project: &ModelHandle, message: proto::update_view::Variant, cx: &mut MutableAppContext, - ) -> Result<()> { - self.update(cx, |this, cx| this.apply_update_proto(message, cx)) + ) -> Task> { + self.update(cx, |this, cx| this.apply_update_proto(project, message, cx)) } fn should_unfollow_on_event(&self, event: &dyn Any, cx: &AppContext) -> bool { diff --git a/crates/workspace/src/notifications.rs b/crates/workspace/src/notifications.rs index 91656727d0efc6f161b24490f29eb99c326a8d94..43feede1904a9afe8dbf2b53b4987b9dfa21bc8d 100644 --- a/crates/workspace/src/notifications.rs +++ b/crates/workspace/src/notifications.rs @@ -161,8 +161,8 @@ pub mod simple_message_notification { pub struct MessageNotification { message: String, - click_action: Box, - click_message: String, + click_action: Option>, + click_message: Option, } pub enum MessageNotificationEvent { @@ -174,6 +174,14 @@ pub mod simple_message_notification { } impl MessageNotification { + pub fn new_messsage>(message: S) -> MessageNotification { + Self { + message: message.as_ref().to_string(), + click_action: None, + click_message: None, + } + } + pub fn new, A: Action, S2: AsRef>( 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, - click_message: click_message.as_ref().to_string(), + click_action: Some(Box::new(click_action) as Box), + 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::().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::::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, + ) -> Option; +} + +impl NotifyResultExt for Result +where + E: std::fmt::Debug, +{ + type Ok = T; + + fn notify_err(self, workspace: &mut Workspace, cx: &mut ViewContext) -> Option { + 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 + } + } + } +} diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index 7dee642423c805e9581520b523c5182d424c8ccb..d292ece3d5fb821f07e8ed80a0931ebb44bf0e96 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/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::*, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 5afc40c7b71b373e802f6d304f6117bf1dc94508..c30dc2ea2997ff25a9dfe7287f40cb2d70a85a90 100644 --- a/crates/workspace/src/workspace.rs +++ b/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(cx: &mut MutableAppContext) { type FollowableItemBuilder = fn( ViewHandle, ModelHandle, + ViewId, &mut Option, &mut MutableAppContext, ) -> Option>>>; @@ -331,8 +337,8 @@ pub fn register_followable_item(cx: &mut MutableAppContext) { builders.insert( TypeId::of::(), ( - |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, pub build_window_options: fn() -> WindowOptions<'static>, pub initialize_workspace: fn(&mut Workspace, &Arc, &mut ViewContext), - 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, -} - -type FollowerStatesByLeader = HashMap, FollowerState>>; - -#[derive(Default)] -struct FollowerState { - active_view_id: Option, - items_by_leader_view_id: HashMap, -} - -#[derive(Debug)] -enum FollowerItem { - Loading(Vec), - Loaded(Box), -} - pub enum Event { DockAnchorChanged, PaneAdded(ViewHandle), @@ -507,16 +494,37 @@ pub struct Workspace { last_leaders_by_pane: HashMap, PeerId>, window_edited: bool, active_call: Option<(ModelHandle, Vec)>, + leader_updates_tx: mpsc::UnboundedSender<(PeerId, proto::UpdateFollowers)>, database_id: WorkspaceId, + _apply_leader_updates: Task>, _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, +} + +type FollowerStatesByLeader = HashMap, FollowerState>>; + +#[derive(Default)] +struct FollowerState { + active_view_id: Option, + items_by_leader_view_id: HashMap>, +} + impl Workspace { pub fn new( serialized_workspace: Option, workspace_id: WorkspaceId, project: ModelHandle, - dock_default_factory: DefaultItemFactory, + dock_default_factory: DockDefaultItemFactory, cx: &mut ViewContext, ) -> 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 { 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, envelope: TypedEnvelope, _: Arc, - 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, + 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, - mut update_fn: impl FnMut(&mut FollowerState, &mut ViewContext), - ) { - 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) -> 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 { + 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; } @@ -2719,8 +2759,8 @@ mod tests { pub fn default_item_factory( _workspace: &mut Workspace, _cx: &mut ViewContext, - ) -> Box { - unimplemented!(); + ) -> Option> { + unimplemented!() } #[gpui::test] diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index edf8d60b624dd0a8591049cbf18aa74daa3e9cc7..2879dfc24c5cd06c7a052bea0fa2238a881f47f4 100644 --- a/crates/zed/Cargo.toml +++ b/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" } diff --git a/crates/zed/resources/app-icon-preview.png b/crates/zed/resources/app-icon-preview.png index a33124e4a0fc51298f5ec0772f17e3ef16c2d53f..2d6ade1dd348a934ebec7342c0249560125d0bb7 100644 Binary files a/crates/zed/resources/app-icon-preview.png and b/crates/zed/resources/app-icon-preview.png differ diff --git a/crates/zed/resources/app-icon-preview@2x.png b/crates/zed/resources/app-icon-preview@2x.png index 42dbeec599461646e8a8397940d2e3db320644eb..0a55727e413ad5679cffbd8c14d2f1086f6b572f 100644 Binary files a/crates/zed/resources/app-icon-preview@2x.png and b/crates/zed/resources/app-icon-preview@2x.png differ diff --git a/crates/zed/resources/app-icon.png b/crates/zed/resources/app-icon.png index b7d1cfe362b19291411f3762a8704f585c46e70c..6c8a1b6bd42daf6654c6c1808e58fba3c8c9e140 100644 Binary files a/crates/zed/resources/app-icon.png and b/crates/zed/resources/app-icon.png differ diff --git a/crates/zed/resources/app-icon@2x.png b/crates/zed/resources/app-icon@2x.png index e80f864538490c1b3016eb64bab343420f944cf0..3509df92caf25f4b25dfdc7e8c93a946f7d94354 100644 Binary files a/crates/zed/resources/app-icon@2x.png and b/crates/zed/resources/app-icon@2x.png differ diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f0447f4054c954259e37e50df7a5f95d1c1c50fd..bffd567505b1973163f50ecc8afdd28886a48470 100644 --- a/crates/zed/src/main.rs +++ b/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, -) -> Box { +) -> Option> { let strategy = cx .global::() .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)) } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 9a827da8b7c3f7aef969c8562a42de0fae97aa2a..099fb4eea934cf286da36959040595f1bbb0774f 100644 --- a/crates/zed/src/zed.rs +++ b/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 = env::var("ZED_WINDOW_SIZE") + .ok() + .as_deref() + .and_then(parse_pixel_position_env_var); + static ref ZED_WINDOW_POSITION: Option = env::var("ZED_WINDOW_POSITION") + .ok() + .as_deref() + .and_then(parse_pixel_position_env_var); +} + pub fn init(app_state: &Arc, 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 { + 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::*; diff --git a/script/bundle b/script/bundle index 113d47b525acff3688a5eea9aafe6ece6e8b9e8d..94efbdf0afb75013b690eeb796d58671e44658e9 100755 --- a/script/bundle +++ b/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 diff --git a/script/start-local-collaboration b/script/start-local-collaboration new file mode 100755 index 0000000000000000000000000000000000000000..9c63b301e550253e2e64d9526eb30e0f4a19f7bf --- /dev/null +++ b/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 diff --git a/styles/src/styleTree/components.ts b/styles/src/styleTree/components.ts index 3244e7e4eab418aa9e365076437da56123dc6aa4..847b937416e0dfc5751bde28f1a84f4d53d8166a 100644 --- a/styles/src/styleTree/components.ts +++ b/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, diff --git a/styles/src/styleTree/simpleMessageNotification.ts b/styles/src/styleTree/simpleMessageNotification.ts index 76ff5e1ca5f3ecd30498b59f7035899a61a9d226..2697bedc777e21d7fda8fdfef59a697de24bfd6c 100644 --- a/styles/src/styleTree/simpleMessageNotification.ts +++ b/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"),