diff --git a/Cargo.lock b/Cargo.lock index 00b6922e07..3d22a64359 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2254,6 +2254,7 @@ dependencies = [ "prost", "rand 0.8.5", "release_channel", + "remote_projects", "reqwest", "rpc", "rustc-demangle", @@ -2299,7 +2300,6 @@ dependencies = [ "editor", "emojis", "extensions_ui", - "feature_flags", "futures 0.3.28", "fuzzy", "gpui", @@ -7728,7 +7728,9 @@ dependencies = [ name = "recent_projects" version = "0.1.0" dependencies = [ + "anyhow", "editor", + "feature_flags", "fuzzy", "gpui", "language", @@ -7736,10 +7738,15 @@ dependencies = [ "ordered-float 2.10.0", "picker", "project", + "remote_projects", + "rpc", "serde", "serde_json", + "settings", "smol", + "theme", "ui", + "ui_text_field", "util", "workspace", ] @@ -7866,6 +7873,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "remote_projects" +version = "0.1.0" +dependencies = [ + "anyhow", + "client", + "gpui", + "rpc", + "serde", + "serde_json", +] + [[package]] name = "rend" version = "0.4.0" @@ -12303,6 +12322,7 @@ dependencies = [ "parking_lot", "postage", "project", + "remote_projects", "schemars", "serde", "serde_json", @@ -12601,6 +12621,7 @@ dependencies = [ "quick_action_bar", "recent_projects", "release_channel", + "remote_projects", "rope", "search", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4b9bad5554..d2ff0c5066 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,7 @@ members = [ "crates/refineable", "crates/refineable/derive_refineable", "crates/release_channel", + "crates/remote_projects", "crates/rich_text", "crates/rope", "crates/rpc", @@ -200,6 +201,7 @@ project_symbols = { path = "crates/project_symbols" } quick_action_bar = { path = "crates/quick_action_bar" } recent_projects = { path = "crates/recent_projects" } release_channel = { path = "crates/release_channel" } +remote_projects = { path = "crates/remote_projects" } rich_text = { path = "crates/rich_text" } rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } diff --git a/assets/icons/server.svg b/assets/icons/server.svg index 10fbdcbff4..a8b6ad92b3 100644 --- a/assets/icons/server.svg +++ b/assets/icons/server.svg @@ -1,5 +1,16 @@ - - - - + + + + + diff --git a/crates/call/src/room.rs b/crates/call/src/room.rs index 7ec80334e4..22940537d5 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/room.rs @@ -1203,14 +1203,24 @@ impl Room { project: Model, cx: &mut ModelContext, ) -> Task> { - if let Some(project_id) = project.read(cx).remote_id() { - return Task::ready(Ok(project_id)); - } + let request = if let Some(remote_project_id) = project.read(cx).remote_project_id() { + self.client.request(proto::ShareProject { + room_id: self.id(), + worktrees: vec![], + remote_project_id: Some(remote_project_id.0), + }) + } else { + if let Some(project_id) = project.read(cx).remote_id() { + return Task::ready(Ok(project_id)); + } + + self.client.request(proto::ShareProject { + room_id: self.id(), + worktrees: project.read(cx).worktree_metadata_protos(cx), + remote_project_id: None, + }) + }; - let request = self.client.request(proto::ShareProject { - room_id: self.id(), - worktrees: project.read(cx).worktree_metadata_protos(cx), - }); cx.spawn(|this, mut cx| async move { let response = request.await?; diff --git a/crates/channel/src/channel.rs b/crates/channel/src/channel.rs index f592c1f8e7..aee92d0f6c 100644 --- a/crates/channel/src/channel.rs +++ b/crates/channel/src/channel.rs @@ -11,9 +11,7 @@ pub use channel_chat::{ mentions_to_proto, ChannelChat, ChannelChatEvent, ChannelMessage, ChannelMessageId, MessageParams, }; -pub use channel_store::{ - Channel, ChannelEvent, ChannelMembership, ChannelStore, DevServer, RemoteProject, -}; +pub use channel_store::{Channel, ChannelEvent, ChannelMembership, ChannelStore}; #[cfg(test)] mod channel_store_tests; diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 0d323a2fa0..7b07c7a530 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -3,10 +3,7 @@ mod channel_index; use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat, ChannelMessage}; use anyhow::{anyhow, Result}; use channel_index::ChannelIndex; -use client::{ - ChannelId, Client, ClientSettings, DevServerId, ProjectId, RemoteProjectId, Subscription, User, - UserId, UserStore, -}; +use client::{ChannelId, Client, ClientSettings, ProjectId, Subscription, User, UserId, UserStore}; use collections::{hash_map, HashMap, HashSet}; use futures::{channel::mpsc, future::Shared, Future, FutureExt, StreamExt}; use gpui::{ @@ -15,7 +12,7 @@ use gpui::{ }; use language::Capability; use rpc::{ - proto::{self, ChannelRole, ChannelVisibility, DevServerStatus}, + proto::{self, ChannelRole, ChannelVisibility}, TypedEnvelope, }; use settings::Settings; @@ -53,57 +50,12 @@ impl From for HostedProject { } } } - -#[derive(Debug, Clone)] -pub struct RemoteProject { - pub id: RemoteProjectId, - pub project_id: Option, - pub channel_id: ChannelId, - pub name: SharedString, - pub path: SharedString, - pub dev_server_id: DevServerId, -} - -impl From for RemoteProject { - fn from(project: proto::RemoteProject) -> Self { - Self { - id: RemoteProjectId(project.id), - project_id: project.project_id.map(|id| ProjectId(id)), - channel_id: ChannelId(project.channel_id), - name: project.name.into(), - path: project.path.into(), - dev_server_id: DevServerId(project.dev_server_id), - } - } -} - -#[derive(Debug, Clone)] -pub struct DevServer { - pub id: DevServerId, - pub channel_id: ChannelId, - pub name: SharedString, - pub status: DevServerStatus, -} - -impl From for DevServer { - fn from(dev_server: proto::DevServer) -> Self { - Self { - id: DevServerId(dev_server.dev_server_id), - channel_id: ChannelId(dev_server.channel_id), - status: dev_server.status(), - name: dev_server.name.into(), - } - } -} - pub struct ChannelStore { pub channel_index: ChannelIndex, channel_invitations: Vec>, channel_participants: HashMap>>, channel_states: HashMap, hosted_projects: HashMap, - remote_projects: HashMap, - dev_servers: HashMap, outgoing_invites: HashSet<(ChannelId, UserId)>, update_channels_tx: mpsc::UnboundedSender, @@ -133,8 +85,6 @@ pub struct ChannelState { observed_chat_message: Option, role: Option, projects: HashSet, - dev_servers: HashSet, - remote_projects: HashSet, } impl Channel { @@ -265,8 +215,6 @@ impl ChannelStore { channel_index: ChannelIndex::default(), channel_participants: Default::default(), hosted_projects: Default::default(), - remote_projects: Default::default(), - dev_servers: Default::default(), outgoing_invites: Default::default(), opened_buffers: Default::default(), opened_chats: Default::default(), @@ -366,40 +314,6 @@ impl ChannelStore { projects } - pub fn dev_servers_for_id(&self, channel_id: ChannelId) -> Vec { - let mut dev_servers: Vec = self - .channel_states - .get(&channel_id) - .map(|state| state.dev_servers.clone()) - .unwrap_or_default() - .into_iter() - .flat_map(|id| self.dev_servers.get(&id).cloned()) - .collect(); - dev_servers.sort_by_key(|s| (s.name.clone(), s.id)); - dev_servers - } - - pub fn find_dev_server_by_id(&self, id: DevServerId) -> Option<&DevServer> { - self.dev_servers.get(&id) - } - - pub fn find_remote_project_by_id(&self, id: RemoteProjectId) -> Option<&RemoteProject> { - self.remote_projects.get(&id) - } - - pub fn remote_projects_for_id(&self, channel_id: ChannelId) -> Vec { - let mut remote_projects: Vec = self - .channel_states - .get(&channel_id) - .map(|state| state.remote_projects.clone()) - .unwrap_or_default() - .into_iter() - .flat_map(|id| self.remote_projects.get(&id).cloned()) - .collect(); - remote_projects.sort_by_key(|p| (p.name.clone(), p.id)); - remote_projects - } - pub fn has_open_channel_buffer(&self, channel_id: ChannelId, _cx: &AppContext) -> bool { if let Some(buffer) = self.opened_buffers.get(&channel_id) { if let OpenedModelHandle::Open(buffer) = buffer { @@ -901,46 +815,6 @@ impl ChannelStore { Ok(()) }) } - - pub fn create_remote_project( - &mut self, - channel_id: ChannelId, - dev_server_id: DevServerId, - name: String, - path: String, - cx: &mut ModelContext, - ) -> Task> { - let client = self.client.clone(); - cx.background_executor().spawn(async move { - client - .request(proto::CreateRemoteProject { - channel_id: channel_id.0, - dev_server_id: dev_server_id.0, - name, - path, - }) - .await - }) - } - - pub fn create_dev_server( - &mut self, - channel_id: ChannelId, - name: String, - cx: &mut ModelContext, - ) -> Task> { - let client = self.client.clone(); - cx.background_executor().spawn(async move { - let result = client - .request(proto::CreateDevServer { - channel_id: channel_id.0, - name, - }) - .await?; - Ok(result) - }) - } - pub fn get_channel_member_details( &self, channel_id: ChannelId, @@ -1221,11 +1095,7 @@ impl ChannelStore { || !payload.latest_channel_message_ids.is_empty() || !payload.latest_channel_buffer_versions.is_empty() || !payload.hosted_projects.is_empty() - || !payload.deleted_hosted_projects.is_empty() - || !payload.dev_servers.is_empty() - || !payload.deleted_dev_servers.is_empty() - || !payload.remote_projects.is_empty() - || !payload.deleted_remote_projects.is_empty(); + || !payload.deleted_hosted_projects.is_empty(); if channels_changed { if !payload.delete_channels.is_empty() { @@ -1313,60 +1183,6 @@ impl ChannelStore { .remove_hosted_project(old_project.project_id); } } - - for remote_project in payload.remote_projects { - let remote_project: RemoteProject = remote_project.into(); - if let Some(old_remote_project) = self - .remote_projects - .insert(remote_project.id, remote_project.clone()) - { - self.channel_states - .entry(old_remote_project.channel_id) - .or_default() - .remove_remote_project(old_remote_project.id); - } - self.channel_states - .entry(remote_project.channel_id) - .or_default() - .add_remote_project(remote_project.id); - } - - for remote_project_id in payload.deleted_remote_projects { - let remote_project_id = RemoteProjectId(remote_project_id); - - if let Some(old_project) = self.remote_projects.remove(&remote_project_id) { - self.channel_states - .entry(old_project.channel_id) - .or_default() - .remove_remote_project(old_project.id); - } - } - - for dev_server in payload.dev_servers { - let dev_server: DevServer = dev_server.into(); - if let Some(old_server) = self.dev_servers.insert(dev_server.id, dev_server.clone()) - { - self.channel_states - .entry(old_server.channel_id) - .or_default() - .remove_dev_server(old_server.id); - } - self.channel_states - .entry(dev_server.channel_id) - .or_default() - .add_dev_server(dev_server.id); - } - - for dev_server_id in payload.deleted_dev_servers { - let dev_server_id = DevServerId(dev_server_id); - - if let Some(old_server) = self.dev_servers.remove(&dev_server_id) { - self.channel_states - .entry(old_server.channel_id) - .or_default() - .remove_dev_server(old_server.id); - } - } } cx.notify(); @@ -1481,20 +1297,4 @@ impl ChannelState { fn remove_hosted_project(&mut self, project_id: ProjectId) { self.projects.remove(&project_id); } - - fn add_remote_project(&mut self, remote_project_id: RemoteProjectId) { - self.remote_projects.insert(remote_project_id); - } - - fn remove_remote_project(&mut self, remote_project_id: RemoteProjectId) { - self.remote_projects.remove(&remote_project_id); - } - - fn add_dev_server(&mut self, dev_server_id: DevServerId) { - self.dev_servers.insert(dev_server_id); - } - - fn remove_dev_server(&mut self, dev_server_id: DevServerId) { - self.dev_servers.remove(&dev_server_id); - } } diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 2c5632593d..5479b73c71 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -30,7 +30,9 @@ pub struct ProjectId(pub u64); #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] pub struct DevServerId(pub u64); -#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)] +#[derive( + Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize, +)] pub struct RemoteProjectId(pub u64); #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 8f1a125cb7..1b58438e9a 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -93,6 +93,7 @@ notifications = { workspace = true, features = ["test-support"] } pretty_assertions.workspace = true project = { workspace = true, features = ["test-support"] } release_channel.workspace = true +remote_projects.workspace = true rpc = { workspace = true, features = ["test-support"] } sea-orm = { version = "0.12.x", features = ["sqlx-sqlite"] } serde_json.workspace = true diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index bc14721e21..c9d064edec 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -398,26 +398,21 @@ CREATE TABLE hosted_projects ( channel_id INTEGER NOT NULL REFERENCES channels(id), name TEXT NOT NULL, visibility TEXT NOT NULL, - deleted_at TIMESTAMP NULL, - dev_server_id INTEGER REFERENCES dev_servers(id), - dev_server_path TEXT + deleted_at TIMESTAMP NULL ); CREATE INDEX idx_hosted_projects_on_channel_id ON hosted_projects (channel_id); CREATE UNIQUE INDEX uix_hosted_projects_on_channel_id_and_name ON hosted_projects (channel_id, name) WHERE (deleted_at IS NULL); CREATE TABLE dev_servers ( id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL REFERENCES channels(id), + user_id INTEGER NOT NULL REFERENCES users(id), name TEXT NOT NULL, hashed_token TEXT NOT NULL ); -CREATE INDEX idx_dev_servers_on_channel_id ON dev_servers (channel_id); CREATE TABLE remote_projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, - channel_id INTEGER NOT NULL REFERENCES channels(id), dev_server_id INTEGER NOT NULL REFERENCES dev_servers(id), - name TEXT NOT NULL, path TEXT NOT NULL ); diff --git a/crates/collab/migrations/20240412165156_dev_servers_per_user.sql b/crates/collab/migrations/20240412165156_dev_servers_per_user.sql new file mode 100644 index 0000000000..7ef9e2fde0 --- /dev/null +++ b/crates/collab/migrations/20240412165156_dev_servers_per_user.sql @@ -0,0 +1,7 @@ +DELETE FROM remote_projects; +DELETE FROM dev_servers; + +ALTER TABLE dev_servers DROP COLUMN channel_id; +ALTER TABLE dev_servers ADD COLUMN user_id INT NOT NULL REFERENCES users(id); + +ALTER TABLE remote_projects DROP COLUMN channel_id; diff --git a/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql b/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql new file mode 100644 index 0000000000..923b948cee --- /dev/null +++ b/crates/collab/migrations/20240417192746_unique_remote_projects_by_paths.sql @@ -0,0 +1,3 @@ +ALTER TABLE remote_projects DROP COLUMN name; +ALTER TABLE remote_projects +ADD CONSTRAINT unique_path_constraint UNIQUE(dev_server_id, path); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 24bae3fba7..4a7ae9197a 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -655,8 +655,6 @@ pub struct ChannelsForUser { pub channel_memberships: Vec, pub channel_participants: HashMap>, pub hosted_projects: Vec, - pub dev_servers: Vec, - pub remote_projects: Vec, pub observed_buffer_versions: Vec, pub observed_channel_messages: Vec, @@ -764,6 +762,7 @@ pub struct Project { pub collaborators: Vec, pub worktrees: BTreeMap, pub language_servers: Vec, + pub remote_project_id: Option, } pub struct ProjectCollaborator { @@ -786,8 +785,7 @@ impl ProjectCollaborator { #[derive(Debug)] pub struct LeftProject { pub id: ProjectId, - pub host_user_id: Option, - pub host_connection_id: Option, + pub should_unshare: bool, pub connection_ids: Vec, } diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 279f767df8..3f168e0854 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -640,15 +640,10 @@ impl Database { .get_hosted_projects(&channel_ids, &roles_by_channel_id, tx) .await?; - let dev_servers = self.get_dev_servers(&channel_ids, tx).await?; - let remote_projects = self.get_remote_projects(&channel_ids, tx).await?; - Ok(ChannelsForUser { channel_memberships, channels, hosted_projects, - dev_servers, - remote_projects, channel_participants, latest_buffer_versions, latest_channel_messages, diff --git a/crates/collab/src/db/queries/dev_servers.rs b/crates/collab/src/db/queries/dev_servers.rs index 4767f24734..ceb7d905da 100644 --- a/crates/collab/src/db/queries/dev_servers.rs +++ b/crates/collab/src/db/queries/dev_servers.rs @@ -1,6 +1,9 @@ -use sea_orm::{ActiveValue, ColumnTrait, DatabaseTransaction, EntityTrait, QueryFilter}; +use rpc::proto; +use sea_orm::{ + ActiveValue, ColumnTrait, DatabaseTransaction, EntityTrait, IntoActiveModel, QueryFilter, +}; -use super::{channel, dev_server, ChannelId, Database, DevServerId, UserId}; +use super::{dev_server, remote_project, Database, DevServerId, UserId}; impl Database { pub async fn get_dev_server( @@ -16,40 +19,105 @@ impl Database { .await } - pub async fn get_dev_servers( + pub async fn get_dev_servers(&self, user_id: UserId) -> crate::Result> { + self.transaction(|tx| async move { + Ok(dev_server::Entity::find() + .filter(dev_server::Column::UserId.eq(user_id)) + .all(&*tx) + .await?) + }) + .await + } + + pub async fn remote_projects_update( &self, - channel_ids: &Vec, + user_id: UserId, + ) -> crate::Result { + self.transaction( + |tx| async move { self.remote_projects_update_internal(user_id, &tx).await }, + ) + .await + } + + pub async fn remote_projects_update_internal( + &self, + user_id: UserId, tx: &DatabaseTransaction, - ) -> crate::Result> { - let servers = dev_server::Entity::find() - .filter(dev_server::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0))) + ) -> crate::Result { + let dev_servers = dev_server::Entity::find() + .filter(dev_server::Column::UserId.eq(user_id)) .all(tx) .await?; - Ok(servers) + + let remote_projects = remote_project::Entity::find() + .filter( + remote_project::Column::DevServerId + .is_in(dev_servers.iter().map(|d| d.id).collect::>()), + ) + .find_also_related(super::project::Entity) + .all(tx) + .await?; + + Ok(proto::RemoteProjectsUpdate { + dev_servers: dev_servers + .into_iter() + .map(|d| d.to_proto(proto::DevServerStatus::Offline)) + .collect(), + remote_projects: remote_projects + .into_iter() + .map(|(remote_project, project)| remote_project.to_proto(project)) + .collect(), + }) } pub async fn create_dev_server( &self, - channel_id: ChannelId, name: &str, hashed_access_token: &str, user_id: UserId, - ) -> crate::Result<(channel::Model, dev_server::Model)> { + ) -> crate::Result<(dev_server::Model, proto::RemoteProjectsUpdate)> { self.transaction(|tx| async move { - let channel = self.get_channel_internal(channel_id, &tx).await?; - self.check_user_is_channel_admin(&channel, user_id, &tx) - .await?; - let dev_server = dev_server::Entity::insert(dev_server::ActiveModel { id: ActiveValue::NotSet, hashed_token: ActiveValue::Set(hashed_access_token.to_string()), - channel_id: ActiveValue::Set(channel_id), name: ActiveValue::Set(name.to_string()), + user_id: ActiveValue::Set(user_id), }) .exec_with_returning(&*tx) .await?; - Ok((channel, dev_server)) + let remote_projects = self.remote_projects_update_internal(user_id, &tx).await?; + + Ok((dev_server, remote_projects)) + }) + .await + } + + pub async fn delete_dev_server( + &self, + id: DevServerId, + user_id: UserId, + ) -> crate::Result { + self.transaction(|tx| async move { + let Some(dev_server) = dev_server::Entity::find_by_id(id).one(&*tx).await? else { + return Err(anyhow::anyhow!("no dev server with id {}", id))?; + }; + if dev_server.user_id != user_id { + return Err(anyhow::anyhow!(proto::ErrorCode::Forbidden))?; + } + + remote_project::Entity::delete_many() + .filter(remote_project::Column::DevServerId.eq(id)) + .exec(&*tx) + .await?; + + dev_server::Entity::delete(dev_server.into_active_model()) + .exec(&*tx) + .await?; + + let remote_projects = self.remote_projects_update_internal(user_id, &tx).await?; + + Ok(remote_projects) }) .await } diff --git a/crates/collab/src/db/queries/projects.rs b/crates/collab/src/db/queries/projects.rs index 03b8b5d29e..94a083698c 100644 --- a/crates/collab/src/db/queries/projects.rs +++ b/crates/collab/src/db/queries/projects.rs @@ -30,6 +30,7 @@ impl Database { room_id: RoomId, connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], + remote_project_id: Option, ) -> Result> { self.room_transaction(room_id, |tx| async move { let participant = room_participant::Entity::find() @@ -58,6 +59,30 @@ impl Database { return Err(anyhow!("guests cannot share projects"))?; } + if let Some(remote_project_id) = remote_project_id { + let project = project::Entity::find() + .filter(project::Column::RemoteProjectId.eq(Some(remote_project_id))) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no remote project"))?; + + if project.room_id.is_some() { + return Err(anyhow!("project already shared"))?; + }; + + let project = project::Entity::update(project::ActiveModel { + room_id: ActiveValue::Set(Some(room_id)), + ..project.into_active_model() + }) + .exec(&*tx) + .await?; + + // todo! check user is a project-collaborator + + let room = self.get_room(room_id, &tx).await?; + return Ok((project.id, room)); + } + let project = project::ActiveModel { room_id: ActiveValue::set(Some(participant.room_id)), host_user_id: ActiveValue::set(Some(participant.user_id)), @@ -111,6 +136,7 @@ impl Database { &self, project_id: ProjectId, connection: ConnectionId, + user_id: Option, ) -> Result, Vec)>> { self.project_transaction(project_id, |tx| async move { let guest_connection_ids = self.project_guest_connection_ids(project_id, &tx).await?; @@ -118,19 +144,37 @@ impl Database { .one(&*tx) .await? .ok_or_else(|| anyhow!("project not found"))?; + let room = if let Some(room_id) = project.room_id { + Some(self.get_room(room_id, &tx).await?) + } else { + None + }; if project.host_connection()? == connection { - let room = if let Some(room_id) = project.room_id { - Some(self.get_room(room_id, &tx).await?) - } else { - None - }; project::Entity::delete(project.into_active_model()) .exec(&*tx) .await?; - Ok((room, guest_connection_ids)) - } else { - Err(anyhow!("cannot unshare a project hosted by another user"))? + return Ok((room, guest_connection_ids)); } + if let Some(remote_project_id) = project.remote_project_id { + if let Some(user_id) = user_id { + if user_id + != self + .owner_for_remote_project(remote_project_id, &tx) + .await? + { + Err(anyhow!("cannot unshare a project hosted by another user"))? + } + project::Entity::update(project::ActiveModel { + room_id: ActiveValue::Set(None), + ..project.into_active_model() + }) + .exec(&*tx) + .await?; + return Ok((room, guest_connection_ids)); + } + } + + Err(anyhow!("cannot unshare a project hosted by another user"))? }) .await } @@ -753,6 +797,7 @@ impl Database { name: language_server.name, }) .collect(), + remote_project_id: project.remote_project_id, }; Ok((project, replica_id as ReplicaId)) } @@ -794,8 +839,7 @@ impl Database { Ok(LeftProject { id: project.id, connection_ids, - host_user_id: None, - host_connection_id: None, + should_unshare: false, }) }) .await @@ -832,7 +876,7 @@ impl Database { .find_related(project_collaborator::Entity) .all(&*tx) .await?; - let connection_ids = collaborators + let connection_ids: Vec = collaborators .into_iter() .map(|collaborator| collaborator.connection()) .collect(); @@ -870,8 +914,7 @@ impl Database { let left_project = LeftProject { id: project_id, - host_user_id: project.host_user_id, - host_connection_id: Some(project.host_connection()?), + should_unshare: connection == project.host_connection()?, connection_ids, }; Ok((room, left_project)) @@ -914,7 +957,7 @@ impl Database { capability: Capability, tx: &DatabaseTransaction, ) -> Result<(project::Model, ChannelRole)> { - let (project, remote_project) = project::Entity::find_by_id(project_id) + let (mut project, remote_project) = project::Entity::find_by_id(project_id) .find_also_related(remote_project::Entity) .one(tx) .await? @@ -933,27 +976,44 @@ impl Database { PrincipalId::UserId(user_id) => user_id, }; - let role = if let Some(remote_project) = remote_project { - let channel = channel::Entity::find_by_id(remote_project.channel_id) - .one(tx) - .await? - .ok_or_else(|| anyhow!("no such channel"))?; - - self.check_user_is_channel_participant(&channel, user_id, &tx) - .await? - } else if let Some(room_id) = project.room_id { - // what's the users role? - let current_participant = room_participant::Entity::find() + let role_from_room = if let Some(room_id) = project.room_id { + room_participant::Entity::find() .filter(room_participant::Column::RoomId.eq(room_id)) .filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id)) .one(tx) .await? - .ok_or_else(|| anyhow!("no such room"))?; - - current_participant.role.unwrap_or(ChannelRole::Guest) + .and_then(|participant| participant.role) } else { - return Err(anyhow!("not authorized to read projects"))?; + None }; + let role_from_remote_project = if let Some(remote_project) = remote_project { + let dev_server = dev_server::Entity::find_by_id(remote_project.dev_server_id) + .one(tx) + .await? + .ok_or_else(|| anyhow!("no such channel"))?; + if user_id == dev_server.user_id { + // If the user left the room "uncleanly" they may rejoin the + // remote project before leave_room runs. IN that case kick + // the project out of the room pre-emptively. + if role_from_room.is_none() { + project = project::Entity::update(project::ActiveModel { + room_id: ActiveValue::Set(None), + ..project.into_active_model() + }) + .exec(tx) + .await?; + } + Some(ChannelRole::Admin) + } else { + None + } + } else { + None + }; + + let role = role_from_remote_project + .or(role_from_room) + .unwrap_or(ChannelRole::Banned); match capability { Capability::ReadWrite => { diff --git a/crates/collab/src/db/queries/remote_projects.rs b/crates/collab/src/db/queries/remote_projects.rs index 86538d219e..9baf9ad0c8 100644 --- a/crates/collab/src/db/queries/remote_projects.rs +++ b/crates/collab/src/db/queries/remote_projects.rs @@ -8,8 +8,8 @@ use sea_orm::{ use crate::db::ProjectId; use super::{ - channel, project, project_collaborator, remote_project, worktree, ChannelId, Database, - DevServerId, RejoinedProject, RemoteProjectId, ResharedProject, ServerId, UserId, + dev_server, project, project_collaborator, remote_project, worktree, Database, DevServerId, + RejoinedProject, RemoteProjectId, ResharedProject, ServerId, UserId, }; impl Database { @@ -26,29 +26,6 @@ impl Database { .await } - pub async fn get_remote_projects( - &self, - channel_ids: &Vec, - tx: &DatabaseTransaction, - ) -> crate::Result> { - let servers = remote_project::Entity::find() - .filter(remote_project::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0))) - .find_also_related(project::Entity) - .all(tx) - .await?; - Ok(servers - .into_iter() - .map(|(remote_project, project)| proto::RemoteProject { - id: remote_project.id.to_proto(), - project_id: project.map(|p| p.id.to_proto()), - channel_id: remote_project.channel_id.to_proto(), - name: remote_project.name, - dev_server_id: remote_project.dev_server_id.to_proto(), - path: remote_project.path, - }) - .collect()) - } - pub async fn get_remote_projects_for_dev_server( &self, dev_server_id: DevServerId, @@ -64,8 +41,6 @@ impl Database { .map(|(remote_project, project)| proto::RemoteProject { id: remote_project.id.to_proto(), project_id: project.map(|p| p.id.to_proto()), - channel_id: remote_project.channel_id.to_proto(), - name: remote_project.name, dev_server_id: remote_project.dev_server_id.to_proto(), path: remote_project.path, }) @@ -74,6 +49,38 @@ impl Database { .await } + pub async fn remote_project_ids_for_user( + &self, + user_id: UserId, + tx: &DatabaseTransaction, + ) -> crate::Result> { + let dev_servers = dev_server::Entity::find() + .filter(dev_server::Column::UserId.eq(user_id)) + .find_with_related(remote_project::Entity) + .all(tx) + .await?; + + Ok(dev_servers + .into_iter() + .flat_map(|(_, projects)| projects.into_iter().map(|p| p.id)) + .collect()) + } + + pub async fn owner_for_remote_project( + &self, + remote_project_id: RemoteProjectId, + tx: &DatabaseTransaction, + ) -> crate::Result { + let dev_server = remote_project::Entity::find_by_id(remote_project_id) + .find_also_related(dev_server::Entity) + .one(tx) + .await? + .and_then(|(_, dev_server)| dev_server) + .ok_or_else(|| anyhow!("no remote project"))?; + + Ok(dev_server.user_id) + } + pub async fn get_stale_dev_server_projects( &self, connection: ConnectionId, @@ -95,28 +102,30 @@ impl Database { pub async fn create_remote_project( &self, - channel_id: ChannelId, dev_server_id: DevServerId, - name: &str, path: &str, user_id: UserId, - ) -> crate::Result<(channel::Model, remote_project::Model)> { + ) -> crate::Result<(remote_project::Model, proto::RemoteProjectsUpdate)> { self.transaction(|tx| async move { - let channel = self.get_channel_internal(channel_id, &tx).await?; - self.check_user_is_channel_admin(&channel, user_id, &tx) - .await?; + let dev_server = dev_server::Entity::find_by_id(dev_server_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no dev server with id {}", dev_server_id))?; + if dev_server.user_id != user_id { + return Err(anyhow!("not your dev server"))?; + } let project = remote_project::Entity::insert(remote_project::ActiveModel { - name: ActiveValue::Set(name.to_string()), id: ActiveValue::NotSet, - channel_id: ActiveValue::Set(channel_id), dev_server_id: ActiveValue::Set(dev_server_id), path: ActiveValue::Set(path.to_string()), }) .exec_with_returning(&*tx) .await?; - Ok((channel, project)) + let status = self.remote_projects_update_internal(user_id, &tx).await?; + + Ok((project, status)) }) .await } @@ -127,8 +136,13 @@ impl Database { dev_server_id: DevServerId, connection: ConnectionId, worktrees: &[proto::WorktreeMetadata], - ) -> crate::Result { + ) -> crate::Result<(proto::RemoteProject, UserId, proto::RemoteProjectsUpdate)> { self.transaction(|tx| async move { + let dev_server = dev_server::Entity::find_by_id(dev_server_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no dev server with id {}", dev_server_id))?; + let remote_project = remote_project::Entity::find_by_id(remote_project_id) .one(&*tx) .await? @@ -168,7 +182,15 @@ impl Database { .await?; } - Ok(remote_project.to_proto(Some(project))) + let status = self + .remote_projects_update_internal(dev_server.user_id, &tx) + .await?; + + Ok(( + remote_project.to_proto(Some(project)), + dev_server.user_id, + status, + )) }) .await } diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 46552740f3..9cd22666eb 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -849,11 +849,32 @@ impl Database { .into_values::<_, QueryProjectIds>() .all(&*tx) .await?; + + // if any project in the room has a remote-project-id that belongs to a dev server that this user owns. + let remote_projects_for_user = self + .remote_project_ids_for_user(leaving_participant.user_id, &tx) + .await?; + + let remote_projects_to_unshare = project::Entity::find() + .filter( + Condition::all() + .add(project::Column::RoomId.eq(room_id)) + .add( + project::Column::RemoteProjectId + .is_in(remote_projects_for_user.clone()), + ), + ) + .all(&*tx) + .await? + .into_iter() + .map(|project| project.id) + .collect::>(); let mut left_projects = HashMap::default(); let mut collaborators = project_collaborator::Entity::find() .filter(project_collaborator::Column::ProjectId.is_in(project_ids)) .stream(&*tx) .await?; + while let Some(collaborator) = collaborators.next().await { let collaborator = collaborator?; let left_project = @@ -861,9 +882,8 @@ impl Database { .entry(collaborator.project_id) .or_insert(LeftProject { id: collaborator.project_id, - host_user_id: Default::default(), connection_ids: Default::default(), - host_connection_id: None, + should_unshare: false, }); let collaborator_connection_id = collaborator.connection(); @@ -871,9 +891,10 @@ impl Database { left_project.connection_ids.push(collaborator_connection_id); } - if collaborator.is_host { - left_project.host_user_id = Some(collaborator.user_id); - left_project.host_connection_id = Some(collaborator_connection_id); + if (collaborator.is_host && collaborator.connection() == connection) + || remote_projects_to_unshare.contains(&collaborator.project_id) + { + left_project.should_unshare = true; } } drop(collaborators); @@ -915,6 +936,17 @@ impl Database { .exec(&*tx) .await?; + if !remote_projects_to_unshare.is_empty() { + project::Entity::update_many() + .filter(project::Column::Id.is_in(remote_projects_to_unshare)) + .set(project::ActiveModel { + room_id: ActiveValue::Set(None), + ..Default::default() + }) + .exec(&*tx) + .await?; + } + let (channel, room) = self.get_channel_room(room_id, &tx).await?; let deleted = if room.participants.is_empty() { let result = room::Entity::delete_by_id(room_id).exec(&*tx).await?; @@ -1264,38 +1296,46 @@ impl Database { } drop(db_participants); - let mut db_projects = db_room + let db_projects = db_room .find_related(project::Entity) .find_with_related(worktree::Entity) - .stream(tx) + .all(tx) .await?; - while let Some(row) = db_projects.next().await { - let (db_project, db_worktree) = row?; + for (db_project, db_worktrees) in db_projects { 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() - .find(|project| project.id == db_project.id.to_proto()) - { - project - } else { - participant.projects.push(proto::ParticipantProject { - id: db_project.id.to_proto(), - worktree_root_names: Default::default(), - }); - participant.projects.last_mut().unwrap() - }; + participant.projects.push(proto::ParticipantProject { + id: db_project.id.to_proto(), + worktree_root_names: Default::default(), + }); + let project = participant.projects.last_mut().unwrap(); - if let Some(db_worktree) = db_worktree { + for db_worktree in db_worktrees { if db_worktree.visible { project.worktree_root_names.push(db_worktree.root_name); } } + } else if let Some(remote_project_id) = db_project.remote_project_id { + let host = self.owner_for_remote_project(remote_project_id, tx).await?; + if let Some((_, participant)) = participants + .iter_mut() + .find(|(_, v)| v.user_id == host.to_proto()) + { + participant.projects.push(proto::ParticipantProject { + id: db_project.id.to_proto(), + worktree_root_names: Default::default(), + }); + let project = participant.projects.last_mut().unwrap(); + + for db_worktree in db_worktrees { + if db_worktree.visible { + project.worktree_root_names.push(db_worktree.root_name); + } + } + } } } - drop(db_projects); let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?; let mut followers = Vec::new(); diff --git a/crates/collab/src/db/tables/dev_server.rs b/crates/collab/src/db/tables/dev_server.rs index cd98ae4892..053db808a4 100644 --- a/crates/collab/src/db/tables/dev_server.rs +++ b/crates/collab/src/db/tables/dev_server.rs @@ -1,4 +1,4 @@ -use crate::db::{ChannelId, DevServerId}; +use crate::db::{DevServerId, UserId}; use rpc::proto; use sea_orm::entity::prelude::*; @@ -8,20 +8,28 @@ pub struct Model { #[sea_orm(primary_key)] pub id: DevServerId, pub name: String, - pub channel_id: ChannelId, + pub user_id: UserId, pub hashed_token: String, } impl ActiveModelBehavior for ActiveModel {} #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] -pub enum Relation {} +pub enum Relation { + #[sea_orm(has_many = "super::remote_project::Entity")] + RemoteProject, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RemoteProject.def() + } +} impl Model { pub fn to_proto(&self, status: proto::DevServerStatus) -> proto::DevServer { proto::DevServer { dev_server_id: self.id.to_proto(), - channel_id: self.channel_id.to_proto(), name: self.name.clone(), status: status as i32, } diff --git a/crates/collab/src/db/tables/remote_project.rs b/crates/collab/src/db/tables/remote_project.rs index ba486d9733..a3c2b25725 100644 --- a/crates/collab/src/db/tables/remote_project.rs +++ b/crates/collab/src/db/tables/remote_project.rs @@ -1,5 +1,5 @@ use super::project; -use crate::db::{ChannelId, DevServerId, RemoteProjectId}; +use crate::db::{DevServerId, RemoteProjectId}; use rpc::proto; use sea_orm::entity::prelude::*; @@ -8,9 +8,7 @@ use sea_orm::entity::prelude::*; pub struct Model { #[sea_orm(primary_key)] pub id: RemoteProjectId, - pub channel_id: ChannelId, pub dev_server_id: DevServerId, - pub name: String, pub path: String, } @@ -20,6 +18,12 @@ impl ActiveModelBehavior for ActiveModel {} pub enum Relation { #[sea_orm(has_one = "super::project::Entity")] Project, + #[sea_orm( + belongs_to = "super::dev_server::Entity", + from = "Column::DevServerId", + to = "super::dev_server::Column::Id" + )] + DevServer, } impl Related for Entity { @@ -28,14 +32,18 @@ impl Related for Entity { } } +impl Related for Entity { + fn to() -> RelationDef { + Relation::DevServer.def() + } +} + impl Model { pub fn to_proto(&self, project: Option) -> proto::RemoteProject { proto::RemoteProject { id: self.id.to_proto(), project_id: project.map(|p| p.id.to_proto()), - channel_id: self.channel_id.to_proto(), dev_server_id: self.dev_server_id.to_proto(), - name: self.name.clone(), path: self.path.clone(), } } diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index 96e0898709..c78ba9ec91 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/crates/collab/src/db/tests/db_tests.rs @@ -535,18 +535,18 @@ async fn test_project_count(db: &Arc) { .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 0); - db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[]) + db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[], None) .await .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 1); - db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[]) + db.share_project(room_id, ConnectionId { owner_id, id: 1 }, &[], None) .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 { owner_id, id: 0 }, &[]) + db.share_project(room_id, ConnectionId { owner_id, id: 0 }, &[], None) .await .unwrap(); assert_eq!(db.project_count_excluding_admins().await.unwrap(), 2); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index da8328c411..3cba88b543 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -255,6 +255,13 @@ impl DevServerSession { pub fn dev_server_id(&self) -> DevServerId { self.0.dev_server_id().unwrap() } + + fn dev_server(&self) -> &dev_server::Model { + match &self.0.principal { + Principal::DevServer(dev_server) => dev_server, + _ => unreachable!(), + } + } } impl Deref for DevServerSession { @@ -405,6 +412,7 @@ impl Server { .add_request_handler(user_handler(rejoin_remote_projects)) .add_request_handler(user_handler(create_remote_project)) .add_request_handler(user_handler(create_dev_server)) + .add_request_handler(user_handler(delete_dev_server)) .add_request_handler(dev_server_handler(share_remote_project)) .add_request_handler(dev_server_handler(shutdown_dev_server)) .add_request_handler(dev_server_handler(reconnect_dev_server)) @@ -1044,12 +1052,14 @@ impl Server { .await?; } - let (contacts, channels_for_user, channel_invites) = future::try_join3( - self.app_state.db.get_contacts(user.id), - self.app_state.db.get_channels_for_user(user.id), - self.app_state.db.get_channel_invites_for_user(user.id), - ) - .await?; + let (contacts, channels_for_user, channel_invites, remote_projects) = + future::try_join4( + self.app_state.db.get_contacts(user.id), + self.app_state.db.get_channels_for_user(user.id), + self.app_state.db.get_channel_invites_for_user(user.id), + self.app_state.db.remote_projects_update(user.id), + ) + .await?; { let mut pool = self.connection_pool.lock(); @@ -1067,9 +1077,10 @@ impl Server { )?; self.peer.send( connection_id, - build_channels_update(channels_for_user, channel_invites, &pool), + build_channels_update(channels_for_user, channel_invites), )?; } + send_remote_projects_update(user.id, remote_projects, session).await; if let Some(incoming_call) = self.app_state.db.incoming_call_for_user(user.id).await? @@ -1087,9 +1098,6 @@ impl Server { }; pool.add_dev_server(connection_id, dev_server.id, zed_version); } - update_dev_server_status(dev_server, proto::DevServerStatus::Online, &session) - .await; - // todo!() allow only one connection. let projects = self .app_state @@ -1098,6 +1106,13 @@ impl Server { .await?; self.peer .send(connection_id, proto::DevServerInstructions { projects })?; + + let status = self + .app_state + .db + .remote_projects_update(dev_server.user_id) + .await?; + send_remote_projects_update(dev_server.user_id, status, &session).await; } } @@ -1401,10 +1416,8 @@ async fn connection_lost( update_user_contacts(session.user_id(), &session).await?; }, - Principal::DevServer(dev_server) => { - lost_dev_server_connection(&session).await?; - update_dev_server_status(&dev_server, proto::DevServerStatus::Offline, &session) - .await; + Principal::DevServer(_) => { + lost_dev_server_connection(&session.for_dev_server().unwrap()).await?; }, } }, @@ -1941,6 +1954,9 @@ async fn share_project( RoomId::from_proto(request.room_id), session.connection_id, &request.worktrees, + request + .remote_project_id + .map(|id| RemoteProjectId::from_proto(id)), ) .await?; response.send(proto::ShareProjectResponse { @@ -1954,14 +1970,25 @@ async fn share_project( /// Unshare a project from the room. async fn unshare_project(message: proto::UnshareProject, session: Session) -> Result<()> { let project_id = ProjectId::from_proto(message.project_id); - unshare_project_internal(project_id, &session).await + unshare_project_internal( + project_id, + session.connection_id, + session.user_id(), + &session, + ) + .await } -async fn unshare_project_internal(project_id: ProjectId, session: &Session) -> Result<()> { +async fn unshare_project_internal( + project_id: ProjectId, + connection_id: ConnectionId, + user_id: Option, + session: &Session, +) -> Result<()> { let (room, guest_connection_ids) = &*session .db() .await - .unshare_project(project_id, session.connection_id) + .unshare_project(project_id, connection_id, user_id) .await?; let message = proto::UnshareProject { @@ -1969,7 +1996,7 @@ async fn unshare_project_internal(project_id: ProjectId, session: &Session) -> R }; broadcast( - Some(session.connection_id), + Some(connection_id), guest_connection_ids.iter().copied(), |conn_id| session.peer.send(conn_id, message.clone()), ); @@ -1980,13 +2007,13 @@ async fn unshare_project_internal(project_id: ProjectId, session: &Session) -> R Ok(()) } -/// Share a project into the room. +/// DevServer makes a project available online async fn share_remote_project( request: proto::ShareRemoteProject, response: Response, session: DevServerSession, ) -> Result<()> { - let remote_project = session + let (remote_project, user_id, status) = session .db() .await .share_remote_project( @@ -2000,22 +2027,7 @@ async fn share_remote_project( return Err(anyhow!("failed to share remote project"))?; }; - for (connection_id, _) in session - .connection_pool() - .await - .channel_connection_ids(ChannelId::from_proto(remote_project.channel_id)) - { - session - .peer - .send( - connection_id, - proto::UpdateChannels { - remote_projects: vec![remote_project.clone()], - ..Default::default() - }, - ) - .trace_err(); - } + send_remote_projects_update(user_id, status, &session).await; response.send(proto::ShareProjectResponse { project_id })?; @@ -2081,19 +2093,21 @@ fn join_project_internal( }) .collect::>(); + let add_project_collaborator = proto::AddProjectCollaborator { + project_id: project_id.to_proto(), + collaborator: Some(proto::Collaborator { + peer_id: Some(session.connection_id.into()), + replica_id: replica_id.0 as u32, + user_id: guest_user_id.to_proto(), + }), + }; + for collaborator in &collaborators { session .peer .send( collaborator.peer_id.unwrap().into(), - proto::AddProjectCollaborator { - project_id: project_id.to_proto(), - collaborator: Some(proto::Collaborator { - peer_id: Some(session.connection_id.into()), - replica_id: replica_id.0 as u32, - user_id: guest_user_id.to_proto(), - }), - }, + add_project_collaborator.clone(), ) .trace_err(); } @@ -2105,7 +2119,10 @@ fn join_project_internal( replica_id: replica_id.0 as u32, collaborators: collaborators.clone(), language_servers: project.language_servers.clone(), - role: project.role.into(), // todo + role: project.role.into(), + remote_project_id: project + .remote_project_id + .map(|remote_project_id| remote_project_id.0 as u64), })?; for (worktree_id, worktree) in mem::take(&mut project.worktrees) { @@ -2188,8 +2205,6 @@ async fn leave_project(request: proto::LeaveProject, session: UserSession) -> Re let (room, project) = &*db.leave_project(project_id, sender_id).await?; tracing::info!( %project_id, - host_user_id = ?project.host_user_id, - host_connection_id = ?project.host_connection_id, "leave project" ); @@ -2224,13 +2239,33 @@ async fn create_remote_project( response: Response, session: UserSession, ) -> Result<()> { - let (channel, remote_project) = session + let dev_server_id = DevServerId(request.dev_server_id as i32); + let dev_server_connection_id = session + .connection_pool() + .await + .dev_server_connection_id(dev_server_id); + let Some(dev_server_connection_id) = dev_server_connection_id else { + Err(ErrorCode::DevServerOffline + .message("Cannot create a remote project when the dev server is offline".to_string()) + .anyhow())? + }; + + let path = request.path.clone(); + //Check that the path exists on the dev server + session + .peer + .forward_request( + session.connection_id, + dev_server_connection_id, + proto::ValidateRemoteProjectRequest { path: path.clone() }, + ) + .await?; + + let (remote_project, update) = session .db() .await .create_remote_project( - ChannelId(request.channel_id as i32), DevServerId(request.dev_server_id as i32), - &request.name, &request.path, session.user_id(), ) @@ -2242,25 +2277,12 @@ async fn create_remote_project( .get_remote_projects_for_dev_server(remote_project.dev_server_id) .await?; - let update = proto::UpdateChannels { - remote_projects: vec![remote_project.to_proto(None)], - ..Default::default() - }; - let connection_pool = session.connection_pool().await; - for (connection_id, role) in connection_pool.channel_connection_ids(channel.root_id()) { - if role.can_see_all_descendants() { - session.peer.send(connection_id, update.clone())?; - } - } + session.peer.send( + dev_server_connection_id, + proto::DevServerInstructions { projects }, + )?; - let dev_server_id = remote_project.dev_server_id; - let dev_server_connection_id = connection_pool.dev_server_connection_id(dev_server_id); - if let Some(dev_server_connection_id) = dev_server_connection_id { - session.peer.send( - dev_server_connection_id, - proto::DevServerInstructions { projects }, - )?; - } + send_remote_projects_update(session.user_id(), update, &session).await; response.send(proto::CreateRemoteProjectResponse { remote_project: Some(remote_project.to_proto(None)), @@ -2276,37 +2298,56 @@ async fn create_dev_server( let access_token = auth::random_token(); let hashed_access_token = auth::hash_access_token(&access_token); - let (channel, dev_server) = session + let (dev_server, status) = session .db() .await - .create_dev_server( - ChannelId(request.channel_id as i32), - &request.name, - &hashed_access_token, - session.user_id(), - ) + .create_dev_server(&request.name, &hashed_access_token, session.user_id()) .await?; - let update = proto::UpdateChannels { - dev_servers: vec![dev_server.to_proto(proto::DevServerStatus::Offline)], - ..Default::default() - }; - let connection_pool = session.connection_pool().await; - for (connection_id, role) in connection_pool.channel_connection_ids(channel.root_id()) { - if role.can_see_channel(channel.visibility) { - session.peer.send(connection_id, update.clone())?; - } - } + send_remote_projects_update(session.user_id(), status, &session).await; response.send(proto::CreateDevServerResponse { dev_server_id: dev_server.id.0 as u64, - channel_id: request.channel_id, access_token: auth::generate_dev_server_token(dev_server.id.0 as usize, access_token), name: request.name.clone(), })?; Ok(()) } +async fn delete_dev_server( + request: proto::DeleteDevServer, + response: Response, + session: UserSession, +) -> Result<()> { + let dev_server_id = DevServerId(request.dev_server_id as i32); + let dev_server = session.db().await.get_dev_server(dev_server_id).await?; + if dev_server.user_id != session.user_id() { + return Err(anyhow!(ErrorCode::Forbidden))?; + } + + let connection_id = session + .connection_pool() + .await + .dev_server_connection_id(dev_server_id); + if let Some(connection_id) = connection_id { + shutdown_dev_server_internal(dev_server_id, connection_id, &session).await?; + session + .peer + .send(connection_id, proto::ShutdownDevServer {})?; + } + + let status = session + .db() + .await + .delete_dev_server(dev_server_id, session.user_id()) + .await?; + + send_remote_projects_update(session.user_id(), status, &session).await; + + response.send(proto::Ack {})?; + Ok(()) +} + async fn rejoin_remote_projects( request: proto::RejoinRemoteProjects, response: Response, @@ -2403,8 +2444,15 @@ async fn shutdown_dev_server( session: DevServerSession, ) -> Result<()> { response.send(proto::Ack {})?; + shutdown_dev_server_internal(session.dev_server_id(), session.connection_id, &session).await +} + +async fn shutdown_dev_server_internal( + dev_server_id: DevServerId, + connection_id: ConnectionId, + session: &Session, +) -> Result<()> { let (remote_projects, dev_server) = { - let dev_server_id = session.dev_server_id(); let db = session.db().await; let remote_projects = db.get_remote_projects_for_dev_server(dev_server_id).await?; let dev_server = db.get_dev_server(dev_server_id).await?; @@ -2412,22 +2460,26 @@ async fn shutdown_dev_server( }; for project_id in remote_projects.iter().filter_map(|p| p.project_id) { - unshare_project_internal(ProjectId::from_proto(project_id), &session.0).await?; + unshare_project_internal( + ProjectId::from_proto(project_id), + connection_id, + None, + session, + ) + .await?; } - let update = proto::UpdateChannels { - remote_projects, - dev_servers: vec![dev_server.to_proto(proto::DevServerStatus::Offline)], - ..Default::default() - }; - - for (connection_id, _) in session + session .connection_pool() .await - .channel_connection_ids(dev_server.channel_id) - { - session.peer.send(connection_id, update.clone()).trace_err(); - } + .set_dev_server_offline(dev_server_id); + + let status = session + .db() + .await + .remote_projects_update(dev_server.user_id) + .await?; + send_remote_projects_update(dev_server.user_id, status, &session).await; Ok(()) } @@ -4626,7 +4678,7 @@ fn notify_membership_updated( ..Default::default() }; - let mut update = build_channels_update(result.new_channels, vec![], connection_pool); + let mut update = build_channels_update(result.new_channels, vec![]); update.delete_channels = result .removed_channels .into_iter() @@ -4659,7 +4711,6 @@ fn build_update_user_channels(channels: &ChannelsForUser) -> proto::UpdateUserCh fn build_channels_update( channels: ChannelsForUser, channel_invites: Vec, - pool: &ConnectionPool, ) -> proto::UpdateChannels { let mut update = proto::UpdateChannels::default(); @@ -4684,13 +4735,6 @@ fn build_channels_update( } update.hosted_projects = channels.hosted_projects; - update.dev_servers = channels - .dev_servers - .into_iter() - .map(|dev_server| dev_server.to_proto(pool.dev_server_status(dev_server.id))) - .collect(); - update.remote_projects = channels.remote_projects; - update } @@ -4777,24 +4821,19 @@ fn channel_updated( ); } -async fn update_dev_server_status( - dev_server: &dev_server::Model, - status: proto::DevServerStatus, +async fn send_remote_projects_update( + user_id: UserId, + mut status: proto::RemoteProjectsUpdate, session: &Session, ) { let pool = session.connection_pool().await; - let connections = pool.channel_connection_ids(dev_server.channel_id); - for (connection_id, _) in connections { - session - .peer - .send( - connection_id, - proto::UpdateChannels { - dev_servers: vec![dev_server.to_proto(status)], - ..Default::default() - }, - ) - .trace_err(); + for dev_server in &mut status.dev_servers { + dev_server.status = + pool.dev_server_status(DevServerId(dev_server.dev_server_id as i32)) as i32; + } + let connections = pool.user_connection_ids(user_id); + for connection_id in connections { + session.peer.send(connection_id, status.clone()).trace_err(); } } @@ -4833,7 +4872,7 @@ async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> Ok(()) } -async fn lost_dev_server_connection(session: &Session) -> Result<()> { +async fn lost_dev_server_connection(session: &DevServerSession) -> Result<()> { log::info!("lost dev server connection, unsharing projects"); let project_ids = session .db() @@ -4843,9 +4882,14 @@ async fn lost_dev_server_connection(session: &Session) -> Result<()> { for project_id in project_ids { // not unshare re-checks the connection ids match, so we get away with no transaction - unshare_project_internal(project_id, &session).await?; + unshare_project_internal(project_id, session.connection_id, None, &session).await?; } + let user_id = session.dev_server().user_id; + let update = session.db().await.remote_projects_update(user_id).await?; + + send_remote_projects_update(user_id, update, session).await; + Ok(()) } @@ -4947,7 +4991,7 @@ async fn leave_channel_buffers_for_session(session: &Session) -> Result<()> { fn project_left(project: &db::LeftProject, session: &UserSession) { for connection_id in &project.connection_ids { - if project.host_user_id == Some(session.user_id()) { + if project.should_unshare { session .peer .send( diff --git a/crates/collab/src/rpc/connection_pool.rs b/crates/collab/src/rpc/connection_pool.rs index 856fc616a3..5a7632f391 100644 --- a/crates/collab/src/rpc/connection_pool.rs +++ b/crates/collab/src/rpc/connection_pool.rs @@ -13,6 +13,7 @@ pub struct ConnectionPool { connected_users: BTreeMap, connected_dev_servers: BTreeMap, channels: ChannelPool, + offline_dev_servers: HashSet, } #[derive(Default, Serialize)] @@ -106,12 +107,17 @@ impl ConnectionPool { } PrincipalId::DevServerId(dev_server_id) => { self.connected_dev_servers.remove(&dev_server_id); + self.offline_dev_servers.remove(&dev_server_id); } } self.connections.remove(&connection_id).unwrap(); Ok(()) } + pub fn set_dev_server_offline(&mut self, dev_server_id: DevServerId) { + self.offline_dev_servers.insert(dev_server_id); + } + pub fn connections(&self) -> impl Iterator { self.connections.values() } @@ -137,7 +143,9 @@ impl ConnectionPool { } pub fn dev_server_status(&self, dev_server_id: DevServerId) -> proto::DevServerStatus { - if self.dev_server_connection_id(dev_server_id).is_some() { + if self.dev_server_connection_id(dev_server_id).is_some() + && !self.offline_dev_servers.contains(&dev_server_id) + { proto::DevServerStatus::Online } else { proto::DevServerStatus::Offline diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 14b0a87485..ae0035f3a2 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/crates/collab/src/tests/channel_tests.rs @@ -1023,6 +1023,8 @@ async fn test_channel_link_notifications( .await .unwrap(); + executor.run_until_parked(); + // the new channel shows for b and c assert_channels_list_shape( client_a.channel_store(), diff --git a/crates/collab/src/tests/dev_server_tests.rs b/crates/collab/src/tests/dev_server_tests.rs index 91849b4fb9..40ecc66bd7 100644 --- a/crates/collab/src/tests/dev_server_tests.rs +++ b/crates/collab/src/tests/dev_server_tests.rs @@ -1,45 +1,40 @@ -use std::path::Path; +use std::{path::Path, sync::Arc}; +use call::ActiveCall; use editor::Editor; use fs::Fs; -use gpui::VisualTestContext; -use rpc::proto::DevServerStatus; +use gpui::{TestAppContext, VisualTestContext, WindowHandle}; +use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt}; use serde_json::json; +use workspace::{AppState, Workspace}; -use crate::tests::TestServer; +use crate::tests::{following_tests::join_channel, TestServer}; + +use super::TestClient; #[gpui::test] async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppContext) { let (server, client) = TestServer::start1(cx).await; - let channel_id = server - .make_channel("test", None, (&client, cx), &mut []) - .await; + let store = cx.update(|cx| remote_projects::Store::global(cx).clone()); - let resp = client - .channel_store() + let resp = store .update(cx, |store, cx| { - store.create_dev_server(channel_id, "server-1".to_string(), cx) + store.create_dev_server("server-1".to_string(), cx) }) .await .unwrap(); - client.channel_store().update(cx, |store, _| { - assert_eq!(store.dev_servers_for_id(channel_id).len(), 1); - assert_eq!(store.dev_servers_for_id(channel_id)[0].name, "server-1"); - assert_eq!( - store.dev_servers_for_id(channel_id)[0].status, - DevServerStatus::Offline - ); + store.update(cx, |store, _| { + assert_eq!(store.dev_servers().len(), 1); + assert_eq!(store.dev_servers()[0].name, "server-1"); + assert_eq!(store.dev_servers()[0].status, DevServerStatus::Offline); }); let dev_server = server.create_dev_server(resp.access_token, cx2).await; cx.executor().run_until_parked(); - client.channel_store().update(cx, |store, _| { - assert_eq!( - store.dev_servers_for_id(channel_id)[0].status, - DevServerStatus::Online - ); + store.update(cx, |store, _| { + assert_eq!(store.dev_servers()[0].status, DevServerStatus::Online); }); dev_server @@ -54,13 +49,10 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC ) .await; - client - .channel_store() + store .update(cx, |store, cx| { store.create_remote_project( - channel_id, client::DevServerId(resp.dev_server_id), - "project-1".to_string(), "/remote".to_string(), cx, ) @@ -70,12 +62,11 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC cx.executor().run_until_parked(); - let remote_workspace = client - .channel_store() + let remote_workspace = store .update(cx, |store, cx| { - let projects = store.remote_projects_for_id(channel_id); + let projects = store.remote_projects(); assert_eq!(projects.len(), 1); - assert_eq!(projects[0].name, "project-1"); + assert_eq!(projects[0].path, "/remote"); workspace::join_remote_project( projects[0].project_id.unwrap(), client.app_state.clone(), @@ -87,19 +78,19 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC cx.executor().run_until_parked(); - let cx2 = VisualTestContext::from_window(remote_workspace.into(), cx).as_mut(); - cx2.simulate_keystrokes("cmd-p 1 enter"); + let cx = VisualTestContext::from_window(remote_workspace.into(), cx).as_mut(); + cx.simulate_keystrokes("cmd-p 1 enter"); let editor = remote_workspace - .update(cx2, |ws, cx| { + .update(cx, |ws, cx| { ws.active_item_as::(cx).unwrap().clone() }) .unwrap(); - editor.update(cx2, |ed, cx| { + editor.update(cx, |ed, cx| { assert_eq!(ed.text(cx).to_string(), "remote\nremote\nremote"); }); - cx2.simulate_input("wow!"); - cx2.simulate_keystrokes("cmd-s"); + cx.simulate_input("wow!"); + cx.simulate_keystrokes("cmd-s"); let content = dev_server .fs() @@ -108,3 +99,263 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC .unwrap(); assert_eq!(content, "wow!remote\nremote\nremote\n"); } + +#[gpui::test] +async fn test_dev_server_env_files( + cx1: &mut gpui::TestAppContext, + cx2: &mut gpui::TestAppContext, + cx3: &mut gpui::TestAppContext, +) { + let (server, client1, client2, channel_id) = TestServer::start2(cx1, cx2).await; + + let (_dev_server, remote_workspace) = + create_remote_project(&server, client1.app_state.clone(), cx1, cx3).await; + + cx1.executor().run_until_parked(); + + let cx1 = VisualTestContext::from_window(remote_workspace.into(), cx1).as_mut(); + cx1.simulate_keystrokes("cmd-p . e enter"); + + let editor = remote_workspace + .update(cx1, |ws, cx| { + ws.active_item_as::(cx).unwrap().clone() + }) + .unwrap(); + editor.update(cx1, |ed, cx| { + assert_eq!(ed.text(cx).to_string(), "SECRET"); + }); + + cx1.update(|cx| { + workspace::join_channel( + channel_id, + client1.app_state.clone(), + Some(remote_workspace), + cx, + ) + }) + .await + .unwrap(); + cx1.executor().run_until_parked(); + + remote_workspace + .update(cx1, |ws, cx| { + assert!(ws.project().read(cx).is_shared()); + }) + .unwrap(); + + join_channel(channel_id, &client2, cx2).await.unwrap(); + cx2.executor().run_until_parked(); + + let (workspace2, cx2) = client2.active_workspace(cx2); + let editor = workspace2.update(cx2, |ws, cx| { + ws.active_item_as::(cx).unwrap().clone() + }); + // TODO: it'd be nice to hide .env files from other people + editor.update(cx2, |ed, cx| { + assert_eq!(ed.text(cx).to_string(), "SECRET"); + }); +} + +async fn create_remote_project( + server: &TestServer, + client_app_state: Arc, + cx: &mut TestAppContext, + cx_devserver: &mut TestAppContext, +) -> (TestClient, WindowHandle) { + let store = cx.update(|cx| remote_projects::Store::global(cx).clone()); + + let resp = store + .update(cx, |store, cx| { + store.create_dev_server("server-1".to_string(), cx) + }) + .await + .unwrap(); + let dev_server = server + .create_dev_server(resp.access_token, cx_devserver) + .await; + + cx.executor().run_until_parked(); + + dev_server + .fs() + .insert_tree( + "/remote", + json!({ + "1.txt": "remote\nremote\nremote", + ".env": "SECRET", + }), + ) + .await; + + store + .update(cx, |store, cx| { + store.create_remote_project( + client::DevServerId(resp.dev_server_id), + "/remote".to_string(), + cx, + ) + }) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + let workspace = store + .update(cx, |store, cx| { + let projects = store.remote_projects(); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].path, "/remote"); + workspace::join_remote_project(projects[0].project_id.unwrap(), client_app_state, cx) + }) + .await + .unwrap(); + + cx.executor().run_until_parked(); + + (dev_server, workspace) +} + +#[gpui::test] +async fn test_dev_server_leave_room( + cx1: &mut gpui::TestAppContext, + cx2: &mut gpui::TestAppContext, + cx3: &mut gpui::TestAppContext, +) { + let (server, client1, client2, channel_id) = TestServer::start2(cx1, cx2).await; + + let (_dev_server, remote_workspace) = + create_remote_project(&server, client1.app_state.clone(), cx1, cx3).await; + + cx1.update(|cx| { + workspace::join_channel( + channel_id, + client1.app_state.clone(), + Some(remote_workspace), + cx, + ) + }) + .await + .unwrap(); + cx1.executor().run_until_parked(); + + remote_workspace + .update(cx1, |ws, cx| { + assert!(ws.project().read(cx).is_shared()); + }) + .unwrap(); + + join_channel(channel_id, &client2, cx2).await.unwrap(); + cx2.executor().run_until_parked(); + + cx1.update(|cx| ActiveCall::global(cx).update(cx, |active_call, cx| active_call.hang_up(cx))) + .await + .unwrap(); + + cx1.executor().run_until_parked(); + + let (workspace, cx2) = client2.active_workspace(cx2); + cx2.update(|cx| assert!(workspace.read(cx).project().read(cx).is_disconnected())); +} + +#[gpui::test] +async fn test_dev_server_reconnect( + cx1: &mut gpui::TestAppContext, + cx2: &mut gpui::TestAppContext, + cx3: &mut gpui::TestAppContext, +) { + let (mut server, client1) = TestServer::start1(cx1).await; + let channel_id = server + .make_channel("test", None, (&client1, cx1), &mut []) + .await; + + let (_dev_server, remote_workspace) = + create_remote_project(&server, client1.app_state.clone(), cx1, cx3).await; + + cx1.update(|cx| { + workspace::join_channel( + channel_id, + client1.app_state.clone(), + Some(remote_workspace), + cx, + ) + }) + .await + .unwrap(); + cx1.executor().run_until_parked(); + + remote_workspace + .update(cx1, |ws, cx| { + assert!(ws.project().read(cx).is_shared()); + }) + .unwrap(); + + drop(client1); + + let client2 = server.create_client(cx2, "user_a").await; + + let store = cx2.update(|cx| remote_projects::Store::global(cx).clone()); + + store + .update(cx2, |store, cx| { + let projects = store.remote_projects(); + workspace::join_remote_project( + projects[0].project_id.unwrap(), + client2.app_state.clone(), + cx, + ) + }) + .await + .unwrap(); +} + +#[gpui::test] +async fn test_create_remote_project_path_validation( + cx1: &mut gpui::TestAppContext, + cx2: &mut gpui::TestAppContext, + cx3: &mut gpui::TestAppContext, +) { + let (server, client1) = TestServer::start1(cx1).await; + let _channel_id = server + .make_channel("test", None, (&client1, cx1), &mut []) + .await; + + // Creating a project with a path that does exist should not fail + let (_dev_server, _) = + create_remote_project(&server, client1.app_state.clone(), cx1, cx2).await; + + cx1.executor().run_until_parked(); + + let store = cx1.update(|cx| remote_projects::Store::global(cx).clone()); + + let resp = store + .update(cx1, |store, cx| { + store.create_dev_server("server-2".to_string(), cx) + }) + .await + .unwrap(); + + cx1.executor().run_until_parked(); + + let _dev_server = server.create_dev_server(resp.access_token, cx3).await; + + cx1.executor().run_until_parked(); + + // Creating a remote project with a path that does not exist should fail + let result = store + .update(cx1, |store, cx| { + store.create_remote_project( + client::DevServerId(resp.dev_server_id), + "/notfound".to_string(), + cx, + ) + }) + .await; + + cx1.executor().run_until_parked(); + + let error = result.unwrap_err(); + assert!(matches!( + error.error_code(), + ErrorCode::RemoteProjectPathDoesNotExist + )); +} diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 728c8806fe..65e57a8ff3 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -3743,6 +3743,10 @@ async fn test_leaving_project( buffer_b2.read_with(cx_b, |buffer, _| assert_eq!(buffer.text(), "a-contents")); + project_a.read_with(cx_a, |project, _| { + assert_eq!(project.collaborators().len(), 2); + }); + // Drop client B's connection and ensure client A and client C observe client B leaving. client_b.disconnect(&cx_b.to_async()); executor.advance_clock(RECONNECT_TIMEOUT); diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index fc0d0fdaf9..f189fd22db 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -284,6 +284,7 @@ impl TestServer { collab_ui::init(&app_state, cx); file_finder::init(cx); menu::init(); + remote_projects::init(client.clone(), cx); settings::KeymapFile::load_asset("keymaps/default-macos.json", cx).unwrap(); }); diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 89d669f991..ff78b50853 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -39,7 +39,6 @@ db.workspace = true editor.workspace = true emojis.workspace = true extensions_ui.workspace = true -feature_flags.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true diff --git a/crates/collab_ui/src/channel_view.rs b/crates/collab_ui/src/channel_view.rs index 49753ccd6f..59099dd486 100644 --- a/crates/collab_ui/src/channel_view.rs +++ b/crates/collab_ui/src/channel_view.rs @@ -305,10 +305,6 @@ impl ChannelView { }); } ChannelBufferEvent::BufferEdited => { - // Emit the edited event on the editor context so that other views can update it's state (e.g. markdown preview) - self.editor.update(cx, |_, cx| { - cx.emit(EditorEvent::Edited); - }); if self.editor.read(cx).is_focused(cx) { self.acknowledge_buffer_version(cx); } else { diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index b27b891c38..8b5eed08d9 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -1,20 +1,17 @@ mod channel_modal; mod contact_finder; -mod dev_server_modal; use self::channel_modal::ChannelModal; -use self::dev_server_modal::DevServerModal; use crate::{ channel_view::ChannelView, chat_panel::ChatPanel, face_pile::FacePile, CollaborationPanelSettings, }; use call::ActiveCall; -use channel::{Channel, ChannelEvent, ChannelStore, RemoteProject}; +use channel::{Channel, ChannelEvent, ChannelStore}; use client::{ChannelId, Client, Contact, ProjectId, User, UserStore}; use contact_finder::ContactFinder; use db::kvp::KEY_VALUE_STORE; use editor::{Editor, EditorElement, EditorStyle}; -use feature_flags::{self, FeatureFlagAppExt}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, anchored, canvas, deferred, div, fill, list, point, prelude::*, px, AnyElement, @@ -27,7 +24,7 @@ use gpui::{ use menu::{Cancel, Confirm, SecondaryConfirm, SelectNext, SelectPrev}; use project::{Fs, Project}; use rpc::{ - proto::{self, ChannelVisibility, DevServerStatus, PeerId}, + proto::{self, ChannelVisibility, PeerId}, ErrorCode, ErrorExt, }; use serde_derive::{Deserialize, Serialize}; @@ -191,7 +188,6 @@ enum ListEntry { id: ProjectId, name: SharedString, }, - RemoteProject(channel::RemoteProject), Contact { contact: Arc, calling: bool, @@ -282,23 +278,10 @@ impl CollabPanel { .push(cx.observe(&this.user_store, |this, _, cx| { this.update_entries(true, cx) })); - let mut has_opened = false; - this.subscriptions.push(cx.observe( - &this.channel_store, - move |this, channel_store, cx| { - if !has_opened { - if !channel_store - .read(cx) - .dev_servers_for_id(ChannelId(1)) - .is_empty() - { - this.manage_remote_projects(ChannelId(1), cx); - has_opened = true; - } - } + this.subscriptions + .push(cx.observe(&this.channel_store, move |this, _, cx| { this.update_entries(true, cx) - }, - )); + })); this.subscriptions .push(cx.observe(&active_call, |this, _, cx| this.update_entries(true, cx))); this.subscriptions.push(cx.subscribe( @@ -586,7 +569,6 @@ impl CollabPanel { } let hosted_projects = channel_store.projects_for_id(channel.id); - let remote_projects = channel_store.remote_projects_for_id(channel.id); let has_children = channel_store .channel_at_index(mat.candidate_id + 1) .map_or(false, |next_channel| { @@ -624,12 +606,6 @@ impl CollabPanel { for (name, id) in hosted_projects { self.entries.push(ListEntry::HostedProject { id, name }); } - - if cx.has_flag::() { - for remote_project in remote_projects { - self.entries.push(ListEntry::RemoteProject(remote_project)); - } - } } } @@ -1089,59 +1065,6 @@ impl CollabPanel { .tooltip(move |cx| Tooltip::text("Open Project", cx)) } - fn render_remote_project( - &self, - remote_project: &RemoteProject, - is_selected: bool, - cx: &mut ViewContext, - ) -> impl IntoElement { - let id = remote_project.id; - let name = remote_project.name.clone(); - let maybe_project_id = remote_project.project_id; - - let dev_server = self - .channel_store - .read(cx) - .find_dev_server_by_id(remote_project.dev_server_id); - - let tooltip_text = SharedString::from(match dev_server { - Some(dev_server) => format!("Open Remote Project ({})", dev_server.name), - None => "Open Remote Project".to_string(), - }); - - let dev_server_is_online = dev_server.map(|s| s.status) == Some(DevServerStatus::Online); - - let dev_server_text_color = if dev_server_is_online { - Color::Default - } else { - Color::Disabled - }; - - ListItem::new(ElementId::NamedInteger( - "remote-project".into(), - id.0 as usize, - )) - .indent_level(2) - .indent_step_size(px(20.)) - .selected(is_selected) - .on_click(cx.listener(move |this, _, cx| { - //TODO display error message if dev server is offline - if dev_server_is_online { - if let Some(project_id) = maybe_project_id { - this.join_remote_project(project_id, cx); - } - } - })) - .start_slot( - h_flex() - .relative() - .gap_1() - .child(IconButton::new(0, IconName::FileTree).icon_color(dev_server_text_color)), - ) - .child(Label::new(name.clone()).color(dev_server_text_color)) - .tooltip(move |cx| Tooltip::text(tooltip_text.clone(), cx)) - } - fn has_subchannels(&self, ix: usize) -> bool { self.entries.get(ix).map_or(false, |entry| { if let ListEntry::Channel { has_children, .. } = entry { @@ -1343,24 +1266,11 @@ impl CollabPanel { } if self.channel_store.read(cx).is_root_channel(channel_id) { - context_menu = context_menu - .separator() - .entry( - "Manage Members", - None, - cx.handler_for(&this, move |this, cx| { - this.manage_members(channel_id, cx) - }), - ) - .when(cx.has_flag::(), |context_menu| { - context_menu.entry( - "Manage Remote Projects", - None, - cx.handler_for(&this, move |this, cx| { - this.manage_remote_projects(channel_id, cx) - }), - ) - }) + context_menu = context_menu.separator().entry( + "Manage Members", + None, + cx.handler_for(&this, move |this, cx| this.manage_members(channel_id, cx)), + ) } else { context_menu = context_menu.entry( "Move this channel", @@ -1624,12 +1534,6 @@ impl CollabPanel { } => { // todo() } - ListEntry::RemoteProject(project) => { - if let Some(project_id) = project.project_id { - self.join_remote_project(project_id, cx) - } - } - ListEntry::OutgoingRequest(_) => {} ListEntry::ChannelEditor { .. } => {} } @@ -1801,18 +1705,6 @@ impl CollabPanel { self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx); } - fn manage_remote_projects(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { - let channel_store = self.channel_store.clone(); - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - workspace.update(cx, |workspace, cx| { - workspace.toggle_modal(cx, |cx| { - DevServerModal::new(channel_store.clone(), channel_id, cx) - }); - }); - } - fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext) { if let Some(channel) = self.selected_channel() { self.remove_channel(channel.id, cx) @@ -2113,18 +2005,6 @@ impl CollabPanel { .detach_and_prompt_err("Failed to join channel", cx, |_, _| None) } - fn join_remote_project(&mut self, project_id: ProjectId, cx: &mut ViewContext) { - let Some(workspace) = self.workspace.upgrade() else { - return; - }; - let app_state = workspace.read(cx).app_state().clone(); - workspace::join_remote_project(project_id, app_state, cx).detach_and_prompt_err( - "Failed to join project", - cx, - |_, _| None, - ) - } - fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { let Some(workspace) = self.workspace.upgrade() else { return; @@ -2260,9 +2140,6 @@ impl CollabPanel { ListEntry::HostedProject { id, name } => self .render_channel_project(*id, name, is_selected, cx) .into_any_element(), - ListEntry::RemoteProject(remote_project) => self - .render_remote_project(remote_project, is_selected, cx) - .into_any_element(), } } @@ -3005,11 +2882,6 @@ impl PartialEq for ListEntry { return id == other_id; } } - ListEntry::RemoteProject(project) => { - if let ListEntry::RemoteProject(other) = other { - return project.id == other.id; - } - } ListEntry::ChannelNotes { channel_id } => { if let ListEntry::ChannelNotes { channel_id: other_id, diff --git a/crates/collab_ui/src/collab_panel/dev_server_modal.rs b/crates/collab_ui/src/collab_panel/dev_server_modal.rs deleted file mode 100644 index 4e2057c140..0000000000 --- a/crates/collab_ui/src/collab_panel/dev_server_modal.rs +++ /dev/null @@ -1,622 +0,0 @@ -use channel::{ChannelStore, DevServer, RemoteProject}; -use client::{ChannelId, DevServerId, RemoteProjectId}; -use editor::Editor; -use gpui::{ - AppContext, ClipboardItem, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, - ScrollHandle, Task, View, ViewContext, -}; -use rpc::proto::{self, CreateDevServerResponse, DevServerStatus}; -use ui::{prelude::*, Indicator, List, ListHeader, ModalContent, ModalHeader, Tooltip}; -use util::ResultExt; -use workspace::ModalView; - -pub struct DevServerModal { - mode: Mode, - focus_handle: FocusHandle, - scroll_handle: ScrollHandle, - channel_store: Model, - channel_id: ChannelId, - remote_project_name_editor: View, - remote_project_path_editor: View, - dev_server_name_editor: View, - _subscriptions: [gpui::Subscription; 2], -} - -#[derive(Default)] -struct CreateDevServer { - creating: Option>, - dev_server: Option, -} - -struct CreateRemoteProject { - dev_server_id: DevServerId, - creating: Option>, - remote_project: Option, -} - -enum Mode { - Default, - CreateRemoteProject(CreateRemoteProject), - CreateDevServer(CreateDevServer), -} - -impl DevServerModal { - pub fn new( - channel_store: Model, - channel_id: ChannelId, - cx: &mut ViewContext, - ) -> Self { - let name_editor = cx.new_view(|cx| Editor::single_line(cx)); - let path_editor = cx.new_view(|cx| Editor::single_line(cx)); - let dev_server_name_editor = cx.new_view(|cx| { - let mut editor = Editor::single_line(cx); - editor.set_placeholder_text("Dev server name", cx); - editor - }); - - let focus_handle = cx.focus_handle(); - - let subscriptions = [ - cx.observe(&channel_store, |_, _, cx| { - cx.notify(); - }), - cx.on_focus_out(&focus_handle, |_, _cx| { /* cx.emit(DismissEvent) */ }), - ]; - - Self { - mode: Mode::Default, - focus_handle, - scroll_handle: ScrollHandle::new(), - channel_store, - channel_id, - remote_project_name_editor: name_editor, - remote_project_path_editor: path_editor, - dev_server_name_editor, - _subscriptions: subscriptions, - } - } - - pub fn create_remote_project( - &mut self, - dev_server_id: DevServerId, - cx: &mut ViewContext, - ) { - let channel_id = self.channel_id; - let name = self - .remote_project_name_editor - .read(cx) - .text(cx) - .trim() - .to_string(); - let path = self - .remote_project_path_editor - .read(cx) - .text(cx) - .trim() - .to_string(); - - if name == "" { - return; - } - if path == "" { - return; - } - - let create = self.channel_store.update(cx, |store, cx| { - store.create_remote_project(channel_id, dev_server_id, name, path, cx) - }); - - let task = cx.spawn(|this, mut cx| async move { - let result = create.await; - if let Err(e) = &result { - cx.prompt( - gpui::PromptLevel::Critical, - "Failed to create project", - Some(&format!("{:?}. Please try again.", e)), - &["Ok"], - ) - .await - .log_err(); - } - this.update(&mut cx, |this, _| { - this.mode = Mode::CreateRemoteProject(CreateRemoteProject { - dev_server_id, - creating: None, - remote_project: result.ok().and_then(|r| r.remote_project), - }); - }) - .log_err(); - }); - - self.mode = Mode::CreateRemoteProject(CreateRemoteProject { - dev_server_id, - creating: Some(task), - remote_project: None, - }); - } - - pub fn create_dev_server(&mut self, cx: &mut ViewContext) { - let name = self - .dev_server_name_editor - .read(cx) - .text(cx) - .trim() - .to_string(); - - if name == "" { - return; - } - - let dev_server = self.channel_store.update(cx, |store, cx| { - store.create_dev_server(self.channel_id, name.clone(), cx) - }); - - let task = cx.spawn(|this, mut cx| async move { - match dev_server.await { - Ok(dev_server) => { - this.update(&mut cx, |this, _| { - this.mode = Mode::CreateDevServer(CreateDevServer { - creating: None, - dev_server: Some(dev_server), - }); - }) - .log_err(); - } - Err(e) => { - cx.prompt( - gpui::PromptLevel::Critical, - "Failed to create server", - Some(&format!("{:?}. Please try again.", e)), - &["Ok"], - ) - .await - .log_err(); - this.update(&mut cx, |this, _| { - this.mode = Mode::CreateDevServer(Default::default()); - }) - .log_err(); - } - } - }); - - self.mode = Mode::CreateDevServer(CreateDevServer { - creating: Some(task), - dev_server: None, - }); - cx.notify() - } - - fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { - match self.mode { - Mode::Default => cx.emit(DismissEvent), - Mode::CreateRemoteProject(_) | Mode::CreateDevServer(_) => { - self.mode = Mode::Default; - cx.notify(); - } - } - } - - fn render_dev_server( - &mut self, - dev_server: &DevServer, - cx: &mut ViewContext, - ) -> impl IntoElement { - let channel_store = self.channel_store.read(cx); - let dev_server_id = dev_server.id; - let status = dev_server.status; - - v_flex() - .w_full() - .child( - h_flex() - .group("dev-server") - .justify_between() - .child( - h_flex() - .gap_2() - .child( - div() - .id(("status", dev_server.id.0)) - .relative() - .child(Icon::new(IconName::Server).size(IconSize::Small)) - .child( - div().absolute().bottom_0().left(rems_from_px(8.0)).child( - Indicator::dot().color(match status { - DevServerStatus::Online => Color::Created, - DevServerStatus::Offline => Color::Deleted, - }), - ), - ) - .tooltip(move |cx| { - Tooltip::text( - match status { - DevServerStatus::Online => "Online", - DevServerStatus::Offline => "Offline", - }, - cx, - ) - }), - ) - .child(dev_server.name.clone()) - .child( - h_flex() - .visible_on_hover("dev-server") - .gap_1() - .child( - IconButton::new("edit-dev-server", IconName::Pencil) - .disabled(true) //TODO implement this on the collab side - .tooltip(|cx| { - Tooltip::text("Coming Soon - Edit dev server", cx) - }), - ) - .child( - IconButton::new("remove-dev-server", IconName::Trash) - .disabled(true) //TODO implement this on the collab side - .tooltip(|cx| { - Tooltip::text("Coming Soon - Remove dev server", cx) - }), - ), - ), - ) - .child( - h_flex().gap_1().child( - IconButton::new("add-remote-project", IconName::Plus) - .tooltip(|cx| Tooltip::text("Add a remote project", cx)) - .on_click(cx.listener(move |this, _, cx| { - this.mode = Mode::CreateRemoteProject(CreateRemoteProject { - dev_server_id, - creating: None, - remote_project: None, - }); - cx.notify(); - })), - ), - ), - ) - .child( - v_flex() - .w_full() - .bg(cx.theme().colors().title_bar_background) - .border() - .border_color(cx.theme().colors().border_variant) - .rounded_md() - .my_1() - .py_0p5() - .px_3() - .child( - List::new().empty_message("No projects.").children( - channel_store - .remote_projects_for_id(dev_server.channel_id) - .iter() - .filter_map(|remote_project| { - if remote_project.dev_server_id == dev_server.id { - Some(self.render_remote_project(remote_project, cx)) - } else { - None - } - }), - ), - ), - ) - // .child(div().ml_8().child( - // Button::new(("add-project", dev_server_id.0), "Add Project").on_click(cx.listener( - // move |this, _, cx| { - // this.mode = Mode::CreateRemoteProject(CreateRemoteProject { - // dev_server_id, - // creating: None, - // remote_project: None, - // }); - // cx.notify(); - // }, - // )), - // )) - } - - fn render_remote_project( - &mut self, - project: &RemoteProject, - _: &mut ViewContext, - ) -> impl IntoElement { - h_flex() - .gap_2() - .child(Icon::new(IconName::FileTree)) - .child(Label::new(project.name.clone())) - .child(Label::new(format!("({})", project.path.clone())).color(Color::Muted)) - } - - fn render_create_dev_server(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let Mode::CreateDevServer(CreateDevServer { - creating, - dev_server, - }) = &self.mode - else { - unreachable!() - }; - - self.dev_server_name_editor.update(cx, |editor, _| { - editor.set_read_only(creating.is_some() || dev_server.is_some()) - }); - v_flex() - .px_1() - .pt_0p5() - .gap_px() - .child( - v_flex().py_0p5().px_1().child( - h_flex() - .px_1() - .py_0p5() - .child( - IconButton::new("back", IconName::ArrowLeft) - .style(ButtonStyle::Transparent) - .on_click(cx.listener(|this, _: &gpui::ClickEvent, cx| { - this.mode = Mode::Default; - cx.notify(); - })), - ) - .child(Headline::new("Register dev server")), - ), - ) - .child( - h_flex() - .ml_5() - .gap_2() - .child("Name") - .child(self.dev_server_name_editor.clone()) - .on_action( - cx.listener(|this, _: &menu::Confirm, cx| this.create_dev_server(cx)), - ) - .when(creating.is_none() && dev_server.is_none(), |div| { - div.child( - Button::new("create-dev-server", "Create").on_click(cx.listener( - move |this, _, cx| { - this.create_dev_server(cx); - }, - )), - ) - }) - .when(creating.is_some() && dev_server.is_none(), |div| { - div.child(Button::new("create-dev-server", "Creating...").disabled(true)) - }), - ) - .when_some(dev_server.clone(), |div, dev_server| { - let channel_store = self.channel_store.read(cx); - let status = channel_store - .find_dev_server_by_id(DevServerId(dev_server.dev_server_id)) - .map(|server| server.status) - .unwrap_or(DevServerStatus::Offline); - let instructions = SharedString::from(format!( - "zed --dev-server-token {}", - dev_server.access_token - )); - div.child( - v_flex() - .ml_8() - .gap_2() - .child(Label::new(format!( - "Please log into `{}` and run:", - dev_server.name - ))) - .child(instructions.clone()) - .child( - IconButton::new("copy-access-token", IconName::Copy) - .on_click(cx.listener(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new( - instructions.to_string(), - )) - })) - .icon_size(IconSize::Small) - .tooltip(|cx| Tooltip::text("Copy access token", cx)), - ) - .when(status == DevServerStatus::Offline, |this| { - this.child(Label::new("Waiting for connection...")) - }) - .when(status == DevServerStatus::Online, |this| { - this.child(Label::new("Connection established! 🎊")).child( - Button::new("done", "Done").on_click(cx.listener(|this, _, cx| { - this.mode = Mode::Default; - cx.notify(); - })), - ) - }), - ) - }) - } - - fn render_default(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let channel_store = self.channel_store.read(cx); - let dev_servers = channel_store.dev_servers_for_id(self.channel_id); - // let dev_servers = Vec::new(); - - v_flex() - .id("scroll-container") - .h_full() - .overflow_y_scroll() - .track_scroll(&self.scroll_handle) - .px_1() - .pt_0p5() - .gap_px() - .child( - ModalHeader::new("Manage Remote Project") - .child(Headline::new("Remote Projects").size(HeadlineSize::Small)), - ) - .child( - ModalContent::new().child( - List::new() - .empty_message("No dev servers registered.") - .header(Some( - ListHeader::new("Dev Servers").end_slot( - Button::new("register-dev-server-button", "New Server") - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .tooltip(|cx| Tooltip::text("Register a new dev server", cx)) - .on_click(cx.listener(|this, _, cx| { - this.mode = Mode::CreateDevServer(Default::default()); - this.dev_server_name_editor - .read(cx) - .focus_handle(cx) - .focus(cx); - cx.notify(); - })), - ), - )) - .children(dev_servers.iter().map(|dev_server| { - self.render_dev_server(dev_server, cx).into_any_element() - })), - ), - ) - } - - fn render_create_project(&self, cx: &mut ViewContext) -> impl IntoElement { - let Mode::CreateRemoteProject(CreateRemoteProject { - dev_server_id, - creating, - remote_project, - }) = &self.mode - else { - unreachable!() - }; - let channel_store = self.channel_store.read(cx); - let (dev_server_name, dev_server_status) = channel_store - .find_dev_server_by_id(*dev_server_id) - .map(|server| (server.name.clone(), server.status)) - .unwrap_or((SharedString::from(""), DevServerStatus::Offline)); - v_flex() - .px_1() - .pt_0p5() - .gap_px() - .child( - ModalHeader::new("Manage Remote Project") - .child(Headline::new("Manage Remote Projects")), - ) - .child( - h_flex() - .py_0p5() - .px_1() - .child(div().px_1().py_0p5().child( - IconButton::new("back", IconName::ArrowLeft).on_click(cx.listener( - |this, _, cx| { - this.mode = Mode::Default; - cx.notify() - }, - )), - )) - .child("Add Project..."), - ) - .child( - h_flex() - .ml_5() - .gap_2() - .child( - div() - .id(("status", dev_server_id.0)) - .relative() - .child(Icon::new(IconName::Server)) - .child(div().absolute().bottom_0().left(rems_from_px(12.0)).child( - Indicator::dot().color(match dev_server_status { - DevServerStatus::Online => Color::Created, - DevServerStatus::Offline => Color::Deleted, - }), - )) - .tooltip(move |cx| { - Tooltip::text( - match dev_server_status { - DevServerStatus::Online => "Online", - DevServerStatus::Offline => "Offline", - }, - cx, - ) - }), - ) - .child(dev_server_name.clone()), - ) - .child( - h_flex() - .ml_5() - .gap_2() - .child("Name") - .child(self.remote_project_name_editor.clone()) - .on_action(cx.listener(|this, _: &menu::Confirm, cx| { - cx.focus_view(&this.remote_project_path_editor) - })), - ) - .child( - h_flex() - .ml_5() - .gap_2() - .child("Path") - .child(self.remote_project_path_editor.clone()) - .on_action( - cx.listener(|this, _: &menu::Confirm, cx| this.create_dev_server(cx)), - ) - .when(creating.is_none() && remote_project.is_none(), |div| { - div.child(Button::new("create-remote-server", "Create").on_click({ - let dev_server_id = *dev_server_id; - cx.listener(move |this, _, cx| { - this.create_remote_project(dev_server_id, cx) - }) - })) - }) - .when(creating.is_some(), |div| { - div.child(Button::new("create-dev-server", "Creating...").disabled(true)) - }), - ) - .when_some(remote_project.clone(), |div, remote_project| { - let channel_store = self.channel_store.read(cx); - let status = channel_store - .find_remote_project_by_id(RemoteProjectId(remote_project.id)) - .map(|project| { - if project.project_id.is_some() { - DevServerStatus::Online - } else { - DevServerStatus::Offline - } - }) - .unwrap_or(DevServerStatus::Offline); - div.child( - v_flex() - .ml_5() - .ml_8() - .gap_2() - .when(status == DevServerStatus::Offline, |this| { - this.child(Label::new("Waiting for project...")) - }) - .when(status == DevServerStatus::Online, |this| { - this.child(Label::new("Project online! 🎊")).child( - Button::new("done", "Done").on_click(cx.listener(|this, _, cx| { - this.mode = Mode::Default; - cx.notify(); - })), - ) - }), - ) - }) - } -} -impl ModalView for DevServerModal {} - -impl FocusableView for DevServerModal { - fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { - self.focus_handle.clone() - } -} - -impl EventEmitter for DevServerModal {} - -impl Render for DevServerModal { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .track_focus(&self.focus_handle) - .elevation_3(cx) - .key_context("DevServerModal") - .on_action(cx.listener(Self::cancel)) - .pb_4() - .w(rems(34.)) - .min_h(rems(20.)) - .max_h(rems(40.)) - .child(match &self.mode { - Mode::Default => self.render_default(cx).into_any_element(), - Mode::CreateRemoteProject(_) => self.render_create_project(cx).into_any_element(), - Mode::CreateDevServer(_) => self.render_create_dev_server(cx).into_any_element(), - }) - } -} diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 7fd8b26b72..5f9ee3a013 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -171,44 +171,48 @@ impl Render for CollabTitlebarItem { let room = room.read(cx); let project = self.project.read(cx); let is_local = project.is_local(); - let is_shared = is_local && project.is_shared(); + let is_remote_project = project.remote_project_id().is_some(); + let is_shared = (is_local || is_remote_project) && project.is_shared(); let is_muted = room.is_muted(); let is_deafened = room.is_deafened().unwrap_or(false); let is_screen_sharing = room.is_screen_sharing(); let can_use_microphone = room.can_use_microphone(); let can_share_projects = room.can_share_projects(); - this.when(is_local && can_share_projects, |this| { - this.child( - Button::new( - "toggle_sharing", - if is_shared { "Unshare" } else { "Share" }, - ) - .tooltip(move |cx| { - Tooltip::text( - if is_shared { - "Stop sharing project with call participants" - } else { - "Share project with call participants" - }, - cx, + this.when( + (is_local || is_remote_project) && can_share_projects, + |this| { + this.child( + Button::new( + "toggle_sharing", + if is_shared { "Unshare" } else { "Share" }, ) - }) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Accent)) - .selected(is_shared) - .label_size(LabelSize::Small) - .on_click(cx.listener( - move |this, _, cx| { - if is_shared { - this.unshare_project(&Default::default(), cx); - } else { - this.share_project(&Default::default(), cx); - } - }, - )), - ) - }) + .tooltip(move |cx| { + Tooltip::text( + if is_shared { + "Stop sharing project with call participants" + } else { + "Share project with call participants" + }, + cx, + ) + }) + .style(ButtonStyle::Subtle) + .selected_style(ButtonStyle::Tinted(TintColor::Accent)) + .selected(is_shared) + .label_size(LabelSize::Small) + .on_click(cx.listener( + move |this, _, cx| { + if is_shared { + this.unshare_project(&Default::default(), cx); + } else { + this.share_project(&Default::default(), cx); + } + }, + )), + ) + }, + ) .child( div() .child( @@ -406,7 +410,7 @@ impl CollabTitlebarItem { ) } - pub fn render_project_name(&self, cx: &mut ViewContext) -> impl Element { + pub fn render_project_name(&self, cx: &mut ViewContext) -> impl IntoElement { let name = { let mut names = self.project.read(cx).visible_worktrees(cx).map(|worktree| { let worktree = worktree.read(cx); @@ -423,15 +427,26 @@ impl CollabTitlebarItem { }; let workspace = self.workspace.clone(); - popover_menu("project_name_trigger") - .trigger( - Button::new("project_name_trigger", name) - .when(!is_project_selected, |b| b.color(Color::Muted)) - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .tooltip(move |cx| Tooltip::text("Recent Projects", cx)), - ) - .menu(move |cx| Some(Self::render_project_popover(workspace.clone(), cx))) + Button::new("project_name_trigger", name) + .when(!is_project_selected, |b| b.color(Color::Muted)) + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small) + .tooltip(move |cx| { + Tooltip::for_action( + "Recent Projects", + &recent_projects::OpenRecent { + create_new_window: false, + }, + cx, + ) + }) + .on_click(cx.listener(move |_, _, cx| { + if let Some(workspace) = workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + RecentProjects::open(workspace, false, cx); + }) + } + })) } pub fn render_project_branch(&self, cx: &mut ViewContext) -> Option { @@ -607,17 +622,6 @@ impl CollabTitlebarItem { Some(view) } - pub fn render_project_popover( - workspace: WeakView, - cx: &mut WindowContext<'_>, - ) -> View { - let view = RecentProjects::open_popover(workspace, cx); - - let focus_handle = view.focus_handle(cx); - cx.focus(&focus_handle); - view - } - fn render_connection_status( &self, status: &client::Status, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index ac3b3e95c1..da617c3fea 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -81,6 +81,7 @@ impl FollowableItem for Editor { let mut buffers = futures::future::try_join_all(buffers?) .await .debug_assert_ok("leaders don't share views for unshared buffers")?; + let editor = pane.update(&mut cx, |pane, cx| { let mut editors = pane.items_of_type::(); editors.find(|editor| { diff --git a/crates/headless/src/headless.rs b/crates/headless/src/headless.rs index 677389b637..13e6cbb9fa 100644 --- a/crates/headless/src/headless.rs +++ b/crates/headless/src/headless.rs @@ -1,20 +1,25 @@ use anyhow::Result; -use client::{user::UserStore, Client, ClientSettings, RemoteProjectId}; +use client::RemoteProjectId; +use client::{user::UserStore, Client, ClientSettings}; use fs::Fs; use futures::Future; -use gpui::{AppContext, AsyncAppContext, Context, Global, Model, ModelContext, Task, WeakModel}; +use gpui::{ + AppContext, AsyncAppContext, BorrowAppContext, Context, Global, Model, ModelContext, Task, + WeakModel, +}; use language::LanguageRegistry; use node_runtime::NodeRuntime; use postage::stream::Stream; -use project::Project; -use rpc::{proto, TypedEnvelope}; -use settings::Settings; +use project::{Project, WorktreeSettings}; +use rpc::{proto, ErrorCode, TypedEnvelope}; +use settings::{Settings, SettingsStore}; use std::{collections::HashMap, sync::Arc}; use util::{ResultExt, TryFutureExt}; pub struct DevServer { client: Arc, app_state: AppState, + remote_shutdown: bool, projects: HashMap>, _subscriptions: Vec, _maintain_connection: Task>, @@ -35,6 +40,15 @@ pub fn init(client: Arc, app_state: AppState, cx: &mut AppContext) { let dev_server = cx.new_model(|cx| DevServer::new(client.clone(), app_state, cx)); cx.set_global(GlobalDevServer(dev_server.clone())); + // Dev server cannot have any private files for now + cx.update_global(|store: &mut SettingsStore, _| { + let old_settings = store.get::(None); + store.override_global(WorktreeSettings { + private_files: Some(vec![]), + ..old_settings.clone() + }); + }); + // Set up a handler when the dev server is shut down by the user pressing Ctrl-C let (tx, rx) = futures::channel::oneshot::channel(); set_ctrlc_handler(move || tx.send(()).log_err().unwrap()).log_err(); @@ -53,7 +67,7 @@ pub fn init(client: Arc, app_state: AppState, cx: &mut AppContext) { log::info!("Connected to {}", server_url); } Err(e) => { - log::error!("Error connecting to {}: {}", server_url, e); + log::error!("Error connecting to '{}': {}", server_url, e); cx.update(|cx| cx.quit()).log_err(); } } @@ -89,19 +103,31 @@ impl DevServer { DevServer { _subscriptions: vec![ - client.add_message_handler(cx.weak_model(), Self::handle_dev_server_instructions) + client.add_message_handler(cx.weak_model(), Self::handle_dev_server_instructions), + client.add_request_handler( + cx.weak_model(), + Self::handle_validate_remote_project_request, + ), + client.add_message_handler(cx.weak_model(), Self::handle_shutdown), ], _maintain_connection: maintain_connection, projects: Default::default(), + remote_shutdown: false, app_state, client, } } fn app_will_quit(&mut self, _: &mut ModelContext) -> impl Future { - let request = self.client.request(proto::ShutdownDevServer {}); + let request = if self.remote_shutdown { + None + } else { + Some(self.client.request(proto::ShutdownDevServer {})) + }; async move { - request.await.log_err(); + if let Some(request) = request { + request.await.log_err(); + } } } @@ -148,6 +174,35 @@ impl DevServer { Ok(()) } + async fn handle_validate_remote_project_request( + this: Model, + envelope: TypedEnvelope, + _: Arc, + cx: AsyncAppContext, + ) -> Result { + let path = std::path::Path::new(&envelope.payload.path); + let fs = cx.read_model(&this, |this, _| this.app_state.fs.clone())?; + + let path_exists = fs.is_dir(path).await; + if !path_exists { + return Err(anyhow::anyhow!(ErrorCode::RemoteProjectPathDoesNotExist))?; + } + + Ok(proto::Ack {}) + } + + async fn handle_shutdown( + this: Model, + _envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + this.remote_shutdown = true; + cx.quit(); + }) + } + fn unshare_project( &mut self, remote_project_id: &RemoteProjectId, diff --git a/crates/picker/src/highlighted_match_with_paths.rs b/crates/picker/src/highlighted_match_with_paths.rs index 02994c87a7..9d49368f71 100644 --- a/crates/picker/src/highlighted_match_with_paths.rs +++ b/crates/picker/src/highlighted_match_with_paths.rs @@ -11,6 +11,7 @@ pub struct HighlightedText { pub text: String, pub highlight_positions: Vec, pub char_count: usize, + pub color: Color, } impl HighlightedText { @@ -39,13 +40,17 @@ impl HighlightedText { text, highlight_positions, char_count, + color: Color::Default, } } -} + pub fn color(self, color: Color) -> Self { + Self { color, ..self } + } +} impl RenderOnce for HighlightedText { - fn render(self, _cx: &mut WindowContext) -> impl IntoElement { - HighlightedLabel::new(self.text, self.highlight_positions) + fn render(self, _: &mut WindowContext) -> impl IntoElement { + HighlightedLabel::new(self.text, self.highlight_positions).color(self.color) } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 6113ea8e92..ad7b67ffe0 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -15,7 +15,8 @@ pub mod search_history; use anyhow::{anyhow, bail, Context as _, Result}; use async_trait::async_trait; use client::{ - proto, Client, Collaborator, PendingEntitySubscription, ProjectId, TypedEnvelope, UserStore, + proto, Client, Collaborator, PendingEntitySubscription, ProjectId, RemoteProjectId, + TypedEnvelope, UserStore, }; use clock::ReplicaId; use collections::{hash_map, BTreeMap, HashMap, HashSet, VecDeque}; @@ -207,6 +208,7 @@ pub struct Project { prettier_instances: HashMap, tasks: Model, hosted_project_id: Option, + remote_project_id: Option, search_history: SearchHistory, } @@ -268,6 +270,7 @@ enum ProjectClientState { capability: Capability, remote_id: u64, replica_id: ReplicaId, + in_room: bool, }, } @@ -723,6 +726,7 @@ impl Project { prettier_instances: HashMap::default(), tasks, hosted_project_id: None, + remote_project_id: None, search_history: Self::new_search_history(), } }) @@ -836,6 +840,7 @@ impl Project { capability: Capability::ReadWrite, remote_id, replica_id, + in_room: response.payload.remote_project_id.is_none(), }, supplementary_language_servers: HashMap::default(), language_servers: Default::default(), @@ -877,6 +882,10 @@ impl Project { prettier_instances: HashMap::default(), tasks, hosted_project_id: None, + remote_project_id: response + .payload + .remote_project_id + .map(|remote_project_id| RemoteProjectId(remote_project_id)), search_history: Self::new_search_history(), }; this.set_role(role, cx); @@ -1235,6 +1244,10 @@ impl Project { self.hosted_project_id } + pub fn remote_project_id(&self) -> Option { + self.remote_project_id + } + pub fn replica_id(&self) -> ReplicaId { match self.client_state { ProjectClientState::Remote { replica_id, .. } => replica_id, @@ -1552,7 +1565,16 @@ impl Project { pub fn shared(&mut self, project_id: u64, cx: &mut ModelContext) -> Result<()> { if !matches!(self.client_state, ProjectClientState::Local) { - return Err(anyhow!("project was already shared")); + if let ProjectClientState::Remote { in_room, .. } = &mut self.client_state { + if *in_room || self.remote_project_id.is_none() { + return Err(anyhow!("project was already shared")); + } else { + *in_room = true; + return Ok(()); + } + } else { + return Err(anyhow!("project was already shared")); + } } self.client_subscriptions.push( self.client @@ -1763,7 +1785,14 @@ impl Project { fn unshare_internal(&mut self, cx: &mut AppContext) -> Result<()> { if self.is_remote() { - return Err(anyhow!("attempted to unshare a remote project")); + if self.remote_project_id().is_some() { + if let ProjectClientState::Remote { in_room, .. } = &mut self.client_state { + *in_room = false + } + return Ok(()); + } else { + return Err(anyhow!("attempted to unshare a remote project")); + } } if let ProjectClientState::Shared { remote_id, .. } = self.client_state { @@ -6959,7 +6988,8 @@ impl Project { pub fn is_shared(&self) -> bool { match &self.client_state { ProjectClientState::Shared { .. } => true, - ProjectClientState::Local | ProjectClientState::Remote { .. } => false, + ProjectClientState::Local => false, + ProjectClientState::Remote { in_room, .. } => *in_room, } } diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 3e8f63d133..e11ff9148e 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -13,14 +13,21 @@ path = "src/recent_projects.rs" doctest = false [dependencies] +anyhow.workspace = true +feature_flags.workspace = true fuzzy.workspace = true gpui.workspace = true menu.workspace = true ordered-float.workspace = true picker.workspace = true +remote_projects.workspace = true +rpc.workspace = true serde.workspace = true +settings.workspace = true smol.workspace = true +theme.workspace = true ui.workspace = true +ui_text_field.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index ba85a966bc..6090590b17 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -1,6 +1,9 @@ +mod remote_projects; + +use feature_flags::FeatureFlagAppExt; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Result, + Action, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Subscription, Task, View, ViewContext, WeakView, }; use ordered_float::OrderedFloat; @@ -8,11 +11,21 @@ use picker::{ highlighted_match_with_paths::{HighlightedMatchWithPaths, HighlightedText}, Picker, PickerDelegate, }; +use remote_projects::RemoteProjects; +use rpc::proto::DevServerStatus; use serde::Deserialize; -use std::{path::Path, sync::Arc}; -use ui::{prelude::*, tooltip_container, ListItem, ListItemSpacing, Tooltip}; -use util::paths::PathExt; -use workspace::{ModalView, Workspace, WorkspaceId, WorkspaceLocation, WORKSPACE_DB}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use ui::{ + prelude::*, tooltip_container, ButtonLike, IconWithIndicator, Indicator, KeyBinding, ListItem, + ListItemSpacing, Tooltip, +}; +use util::{paths::PathExt, ResultExt}; +use workspace::{ + AppState, ModalView, SerializedWorkspaceLocation, Workspace, WorkspaceId, WORKSPACE_DB, +}; #[derive(PartialEq, Clone, Deserialize, Default)] pub struct OpenRecent { @@ -25,9 +38,12 @@ fn default_create_new_window() -> bool { } gpui::impl_actions!(projects, [OpenRecent]); +gpui::actions!(projects, [OpenRemote]); pub fn init(cx: &mut AppContext) { cx.observe_new_views(RecentProjects::register).detach(); + cx.observe_new_views(remote_projects::RemoteProjects::register) + .detach(); } pub struct RecentProjects { @@ -55,10 +71,11 @@ impl RecentProjects { let workspaces = WORKSPACE_DB .recent_workspaces_on_disk() .await + .log_err() .unwrap_or_default(); this.update(&mut cx, move |this, cx| { this.picker.update(cx, move |picker, cx| { - picker.delegate.workspaces = workspaces; + picker.delegate.set_workspaces(workspaces); picker.update_matches(picker.query(cx), cx) }) }) @@ -75,9 +92,7 @@ impl RecentProjects { fn register(workspace: &mut Workspace, _: &mut ViewContext) { workspace.register_action(|workspace, open_recent: &OpenRecent, cx| { let Some(recent_projects) = workspace.active_modal::(cx) else { - if let Some(handler) = Self::open(workspace, open_recent.create_new_window, cx) { - handler.detach_and_log_err(cx); - } + Self::open(workspace, open_recent.create_new_window, cx); return; }; @@ -89,24 +104,17 @@ impl RecentProjects { }); } - fn open( - _: &mut Workspace, + pub fn open( + workspace: &mut Workspace, create_new_window: bool, cx: &mut ViewContext, - ) -> Option>> { - Some(cx.spawn(|workspace, mut cx| async move { - workspace.update(&mut cx, |workspace, cx| { - let weak_workspace = cx.view().downgrade(); - workspace.toggle_modal(cx, |cx| { - let delegate = - RecentProjectsDelegate::new(weak_workspace, create_new_window, true); - - let modal = Self::new(delegate, 34., cx); - modal - }); - })?; - Ok(()) - })) + ) { + let weak = cx.view().downgrade(); + workspace.toggle_modal(cx, |cx| { + let delegate = RecentProjectsDelegate::new(weak, create_new_window, true); + let modal = Self::new(delegate, 34., cx); + modal + }) } pub fn open_popover(workspace: WeakView, cx: &mut WindowContext<'_>) -> View { @@ -143,13 +151,14 @@ impl Render for RecentProjects { pub struct RecentProjectsDelegate { workspace: WeakView, - workspaces: Vec<(WorkspaceId, WorkspaceLocation)>, + workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>, selected_match_index: usize, matches: Vec, render_paths: bool, create_new_window: bool, // Flag to reset index when there is a new query vs not reset index when user delete an item reset_selected_match_index: bool, + has_any_remote_projects: bool, } impl RecentProjectsDelegate { @@ -162,8 +171,17 @@ impl RecentProjectsDelegate { create_new_window, render_paths, reset_selected_match_index: true, + has_any_remote_projects: false, } } + + pub fn set_workspaces(&mut self, workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>) { + self.workspaces = workspaces; + self.has_any_remote_projects = self + .workspaces + .iter() + .any(|(_, location)| matches!(location, SerializedWorkspaceLocation::Remote(_))); + } } impl EventEmitter for RecentProjectsDelegate {} impl PickerDelegate for RecentProjectsDelegate { @@ -210,12 +228,18 @@ impl PickerDelegate for RecentProjectsDelegate { .iter() .enumerate() .map(|(id, (_, location))| { - let combined_string = location - .paths() - .iter() - .map(|path| path.compact().to_string_lossy().into_owned()) - .collect::>() - .join(""); + let combined_string = match location { + SerializedWorkspaceLocation::Local(paths) => paths + .paths() + .iter() + .map(|path| path.compact().to_string_lossy().into_owned()) + .collect::>() + .join(""), + SerializedWorkspaceLocation::Remote(remote_project) => { + format!("{}{}", remote_project.dev_server_name, remote_project.path) + } + }; + StringMatchCandidate::new(id, combined_string) }) .collect::>(); @@ -261,30 +285,69 @@ impl PickerDelegate for RecentProjectsDelegate { if workspace.database_id() == *candidate_workspace_id { Task::ready(Ok(())) } else { - let candidate_paths = candidate_workspace_location.paths().as_ref().clone(); - if replace_current_window { - cx.spawn(move |workspace, mut cx| async move { - let continue_replacing = workspace - .update(&mut cx, |workspace, cx| { - workspace.prepare_to_close(true, cx) - })? - .await?; - if continue_replacing { - workspace - .update(&mut cx, |workspace, cx| { - workspace.open_workspace_for_paths( - true, - candidate_paths, - cx, - ) - })? - .await + match candidate_workspace_location { + SerializedWorkspaceLocation::Local(paths) => { + let paths = paths.paths().as_ref().clone(); + if replace_current_window { + cx.spawn(move |workspace, mut cx| async move { + let continue_replacing = workspace + .update(&mut cx, |workspace, cx| { + workspace.prepare_to_close(true, cx) + })? + .await?; + if continue_replacing { + workspace + .update(&mut cx, |workspace, cx| { + workspace + .open_workspace_for_paths(true, paths, cx) + })? + .await + } else { + Ok(()) + } + }) } else { - Ok(()) + workspace.open_workspace_for_paths(false, paths, cx) } - }) - } else { - workspace.open_workspace_for_paths(false, candidate_paths, cx) + } + //TODO support opening remote projects in the same window + SerializedWorkspaceLocation::Remote(remote_project) => { + let store = ::remote_projects::Store::global(cx).read(cx); + let Some(project_id) = store + .remote_project(remote_project.id) + .and_then(|p| p.project_id) + else { + let dev_server_name = remote_project.dev_server_name.clone(); + return cx.spawn(|workspace, mut cx| async move { + let response = + cx.prompt(gpui::PromptLevel::Warning, + "Dev Server is offline", + Some(format!("Cannot connect to {}. To debug open the remote project settings.", dev_server_name).as_str()), + &["Ok", "Open Settings"] + ).await?; + if response == 1 { + workspace.update(&mut cx, |workspace, cx| { + workspace.toggle_modal(cx, |cx| RemoteProjects::new(cx)) + })?; + } else { + workspace.update(&mut cx, |workspace, cx| { + RecentProjects::open(workspace, true, cx); + })?; + } + Ok(()) + }) + }; + if let Some(app_state) = AppState::global(cx).upgrade() { + let task = + workspace::join_remote_project(project_id, app_state, cx); + cx.spawn(|_, _| async move { + task.await?; + Ok(()) + }) + } else { + Task::ready(Err(anyhow::anyhow!("App state not found"))) + } + } } } }) @@ -295,6 +358,14 @@ impl PickerDelegate for RecentProjectsDelegate { fn dismissed(&mut self, _: &mut ViewContext>) {} + fn no_matches_text(&self, _cx: &mut WindowContext) -> SharedString { + if self.workspaces.is_empty() { + "Recently opened projects will show up here".into() + } else { + "No matches".into() + } + } + fn render_match( &self, ix: usize, @@ -308,9 +379,30 @@ impl PickerDelegate for RecentProjectsDelegate { let (workspace_id, location) = &self.workspaces[hit.candidate_id]; let is_current_workspace = self.is_current_workspace(*workspace_id, cx); + let is_remote = matches!(location, SerializedWorkspaceLocation::Remote(_)); + let dev_server_status = + if let SerializedWorkspaceLocation::Remote(remote_project) = location { + let store = ::remote_projects::Store::global(cx).read(cx); + Some( + store + .remote_project(remote_project.id) + .and_then(|p| store.dev_server(p.dev_server_id)) + .map(|s| s.status) + .unwrap_or_default(), + ) + } else { + None + }; + let mut path_start_offset = 0; - let (match_labels, paths): (Vec<_>, Vec<_>) = location - .paths() + let paths = match location { + SerializedWorkspaceLocation::Local(paths) => paths.paths(), + SerializedWorkspaceLocation::Remote(remote_project) => Arc::new(vec![PathBuf::from( + format!("{}:{}", remote_project.dev_server_name, remote_project.path), + )]), + }; + + let (match_labels, paths): (Vec<_>, Vec<_>) = paths .iter() .map(|path| { let path = path.compact(); @@ -323,22 +415,58 @@ impl PickerDelegate for RecentProjectsDelegate { .unzip(); let highlighted_match = HighlightedMatchWithPaths { - match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", "), + match_label: HighlightedText::join(match_labels.into_iter().flatten(), ", ").color( + if matches!(dev_server_status, Some(DevServerStatus::Offline)) { + Color::Disabled + } else { + Color::Default + }, + ), paths, }; Some( ListItem::new(ix) + .selected(selected) .inset(true) .spacing(ListItemSpacing::Sparse) - .selected(selected) - .child({ - let mut highlighted = highlighted_match.clone(); - if !self.render_paths { - highlighted.paths.clear(); - } - highlighted.render(cx) - }) + .child( + h_flex() + .flex_grow() + .gap_3() + .when(self.has_any_remote_projects, |this| { + this.child(if is_remote { + // if disabled, Color::Disabled + let indicator_color = match dev_server_status { + Some(DevServerStatus::Online) => Color::Created, + Some(DevServerStatus::Offline) => Color::Hidden, + _ => unreachable!(), + }; + IconWithIndicator::new( + Icon::new(IconName::Server).color(Color::Muted), + Some(Indicator::dot()), + ) + .indicator_color(indicator_color) + .indicator_border_color(if selected { + Some(cx.theme().colors().element_selected) + } else { + None + }) + .into_any_element() + } else { + Icon::new(IconName::Screen) + .color(Color::Muted) + .into_any_element() + }) + }) + .child({ + let mut highlighted = highlighted_match.clone(); + if !self.render_paths { + highlighted.paths.clear(); + } + highlighted.render(cx) + }), + ) .when(!is_current_workspace, |el| { let delete_button = div() .child( @@ -369,6 +497,39 @@ impl PickerDelegate for RecentProjectsDelegate { }), ) } + + fn render_footer(&self, cx: &mut ViewContext>) -> Option { + if !cx.has_flag::() { + return None; + } + Some( + h_flex() + .border_t_1() + .py_2() + .pr_2() + .border_color(cx.theme().colors().border) + .justify_end() + .gap_4() + .child( + ButtonLike::new("remote") + .when_some(KeyBinding::for_action(&OpenRemote, cx), |button, key| { + button.child(key) + }) + .child(Label::new("Connect remote…").color(Color::Muted)) + .on_click(|_, cx| cx.dispatch_action(OpenRemote.boxed_clone())), + ) + .child( + ButtonLike::new("local") + .when_some( + KeyBinding::for_action(&workspace::Open, cx), + |button, key| button.child(key), + ) + .child(Label::new("Open folder…").color(Color::Muted)) + .on_click(|_, cx| cx.dispatch_action(workspace::Open.boxed_clone())), + ) + .into_any(), + ) + } } // Compute the highlighted text for the name and path @@ -406,6 +567,7 @@ fn highlights_for_path( text: text.to_string(), highlight_positions, char_count, + color: Color::Default, } }); @@ -415,6 +577,7 @@ fn highlights_for_path( text: path_string.to_string(), highlight_positions: path_positions, char_count: path_char_count, + color: Color::Default, }, ) } @@ -430,7 +593,7 @@ impl RecentProjectsDelegate { .await .unwrap_or_default(); this.update(&mut cx, move |picker, cx| { - picker.delegate.workspaces = workspaces; + picker.delegate.set_workspaces(workspaces); picker.delegate.set_selected_index(ix - 1, cx); picker.delegate.reset_selected_match_index = false; picker.update_matches(picker.query(cx), cx) @@ -475,7 +638,7 @@ mod tests { use gpui::{TestAppContext, WindowHandle}; use project::Project; use serde_json::json; - use workspace::{open_paths, AppState}; + use workspace::{open_paths, AppState, LocalPaths}; use super::*; @@ -539,10 +702,10 @@ mod tests { positions: Vec::new(), string: "fake candidate".to_string(), }]; - delegate.workspaces = vec![( + delegate.set_workspaces(vec![( WorkspaceId::default(), - WorkspaceLocation::new(vec!["/test/path/"]), - )]; + LocalPaths::new(vec!["/test/path/"]).into(), + )]); }); }) .unwrap(); diff --git a/crates/recent_projects/src/remote_projects.rs b/crates/recent_projects/src/remote_projects.rs new file mode 100644 index 0000000000..2a2f13d945 --- /dev/null +++ b/crates/recent_projects/src/remote_projects.rs @@ -0,0 +1,749 @@ +use std::time::Duration; + +use feature_flags::FeatureFlagViewExt; +use gpui::{ + percentage, Action, Animation, AnimationExt, AppContext, ClipboardItem, DismissEvent, + EventEmitter, FocusHandle, FocusableView, Model, ScrollHandle, Transformation, View, + ViewContext, +}; +use remote_projects::{DevServer, DevServerId, RemoteProject, RemoteProjectId}; +use rpc::{ + proto::{self, CreateDevServerResponse, DevServerStatus}, + ErrorCode, ErrorExt, +}; +use settings::Settings; +use theme::ThemeSettings; +use ui::{prelude::*, Indicator, List, ListHeader, ListItem, ModalContent, ModalHeader, Tooltip}; +use ui_text_field::{FieldLabelLayout, TextField}; +use util::ResultExt; +use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace}; + +use crate::OpenRemote; + +pub struct RemoteProjects { + mode: Mode, + focus_handle: FocusHandle, + scroll_handle: ScrollHandle, + remote_project_store: Model, + remote_project_path_input: View, + dev_server_name_input: View, + _subscription: gpui::Subscription, +} + +#[derive(Default)] +struct CreateDevServer { + creating: bool, + dev_server: Option, +} + +struct CreateRemoteProject { + dev_server_id: DevServerId, + creating: bool, + remote_project: Option, +} + +enum Mode { + Default, + CreateRemoteProject(CreateRemoteProject), + CreateDevServer(CreateDevServer), +} + +impl RemoteProjects { + pub fn register(_: &mut Workspace, cx: &mut ViewContext) { + cx.observe_flag::(|enabled, workspace, _| { + if enabled { + workspace.register_action(|workspace, _: &OpenRemote, cx| { + workspace.toggle_modal(cx, |cx| Self::new(cx)) + }); + } + }) + .detach(); + } + + pub fn new(cx: &mut ViewContext) -> Self { + let remote_project_path_input = cx.new_view(|cx| TextField::new(cx, "", "Project path")); + let dev_server_name_input = + cx.new_view(|cx| TextField::new(cx, "Name", "").with_label(FieldLabelLayout::Stacked)); + + let focus_handle = cx.focus_handle(); + let remote_project_store = remote_projects::Store::global(cx); + + let subscription = cx.observe(&remote_project_store, |_, _, cx| { + cx.notify(); + }); + + Self { + mode: Mode::Default, + focus_handle, + scroll_handle: ScrollHandle::new(), + remote_project_store, + remote_project_path_input, + dev_server_name_input, + _subscription: subscription, + } + } + + pub fn create_remote_project( + &mut self, + dev_server_id: DevServerId, + cx: &mut ViewContext, + ) { + let path = self + .remote_project_path_input + .read(cx) + .editor() + .read(cx) + .text(cx) + .trim() + .to_string(); + + if path == "" { + return; + } + + if self + .remote_project_store + .read(cx) + .remote_projects_for_server(dev_server_id) + .iter() + .any(|p| p.path == path) + { + cx.spawn(|_, mut cx| async move { + cx.prompt( + gpui::PromptLevel::Critical, + "Failed to create project", + Some(&format!( + "Project {} already exists for this dev server.", + path + )), + &["Ok"], + ) + .await + }) + .detach_and_log_err(cx); + return; + } + + let create = { + let path = path.clone(); + self.remote_project_store.update(cx, |store, cx| { + store.create_remote_project(dev_server_id, path, cx) + }) + }; + + cx.spawn(|this, mut cx| async move { + let result = create.await; + let remote_project = result.as_ref().ok().and_then(|r| r.remote_project.clone()); + this.update(&mut cx, |this, _| { + this.mode = Mode::CreateRemoteProject(CreateRemoteProject { + dev_server_id, + creating: false, + remote_project, + }); + }) + .log_err(); + result + }) + .detach_and_prompt_err("Failed to create project", cx, move |e, _| { + match e.error_code() { + ErrorCode::DevServerOffline => Some( + "The dev server is offline. Please log in and check it is connected." + .to_string(), + ), + ErrorCode::RemoteProjectPathDoesNotExist => { + Some(format!("The path `{}` does not exist on the server.", path)) + } + _ => None, + } + }); + + self.remote_project_path_input.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("", cx); + }); + }); + + self.mode = Mode::CreateRemoteProject(CreateRemoteProject { + dev_server_id, + creating: true, + remote_project: None, + }); + } + + pub fn create_dev_server(&mut self, cx: &mut ViewContext) { + let name = self + .dev_server_name_input + .read(cx) + .editor() + .read(cx) + .text(cx) + .trim() + .to_string(); + + if name == "" { + return; + } + + let dev_server = self + .remote_project_store + .update(cx, |store, cx| store.create_dev_server(name.clone(), cx)); + + cx.spawn(|this, mut cx| async move { + let result = dev_server.await; + + this.update(&mut cx, |this, _| match &result { + Ok(dev_server) => { + this.mode = Mode::CreateDevServer(CreateDevServer { + creating: false, + dev_server: Some(dev_server.clone()), + }); + } + Err(_) => { + this.mode = Mode::CreateDevServer(Default::default()); + } + }) + .log_err(); + result + }) + .detach_and_prompt_err("Failed to create server", cx, |_, _| None); + + self.mode = Mode::CreateDevServer(CreateDevServer { + creating: true, + dev_server: None, + }); + cx.notify() + } + + fn delete_dev_server(&mut self, id: DevServerId, cx: &mut ViewContext) { + let answer = cx.prompt( + gpui::PromptLevel::Info, + "Are you sure?", + Some("This will delete the dev server and all of its remote projects."), + &["Delete", "Cancel"], + ); + + cx.spawn(|this, mut cx| async move { + let answer = answer.await?; + + if answer != 0 { + return Ok(()); + } + + this.update(&mut cx, |this, cx| { + this.remote_project_store + .update(cx, |store, cx| store.delete_dev_server(id, cx)) + })? + .await + }) + .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None); + } + + fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + match self.mode { + Mode::Default => {} + Mode::CreateRemoteProject(CreateRemoteProject { dev_server_id, .. }) => { + self.create_remote_project(dev_server_id, cx); + } + Mode::CreateDevServer(_) => { + self.create_dev_server(cx); + } + } + } + + fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { + match self.mode { + Mode::Default => cx.emit(DismissEvent), + Mode::CreateRemoteProject(_) | Mode::CreateDevServer(_) => { + self.mode = Mode::Default; + self.focus_handle(cx).focus(cx); + cx.notify(); + } + } + } + + fn render_dev_server( + &mut self, + dev_server: &DevServer, + cx: &mut ViewContext, + ) -> impl IntoElement { + let dev_server_id = dev_server.id; + let status = dev_server.status; + + v_flex() + .w_full() + .child( + h_flex() + .group("dev-server") + .justify_between() + .child( + h_flex() + .gap_2() + .child( + div() + .id(("status", dev_server.id.0)) + .relative() + .child(Icon::new(IconName::Server).size(IconSize::Small)) + .child( + div().absolute().bottom_0().left(rems_from_px(8.0)).child( + Indicator::dot().color(match status { + DevServerStatus::Online => Color::Created, + DevServerStatus::Offline => Color::Hidden, + }), + ), + ) + .tooltip(move |cx| { + Tooltip::text( + match status { + DevServerStatus::Online => "Online", + DevServerStatus::Offline => "Offline", + }, + cx, + ) + }), + ) + .child(dev_server.name.clone()) + .child( + h_flex() + .visible_on_hover("dev-server") + .gap_1() + .child( + IconButton::new("edit-dev-server", IconName::Pencil) + .disabled(true) //TODO implement this on the collab side + .tooltip(|cx| { + Tooltip::text("Coming Soon - Edit dev server", cx) + }), + ) + .child({ + let dev_server_id = dev_server.id; + IconButton::new("remove-dev-server", IconName::Trash) + .on_click(cx.listener(move |this, _, cx| { + this.delete_dev_server(dev_server_id, cx) + })) + .tooltip(|cx| Tooltip::text("Remove dev server", cx)) + }), + ), + ) + .child( + h_flex().gap_1().child( + IconButton::new( + ("add-remote-project", dev_server_id.0), + IconName::Plus, + ) + .tooltip(|cx| Tooltip::text("Add a remote project", cx)) + .on_click(cx.listener( + move |this, _, cx| { + this.mode = Mode::CreateRemoteProject(CreateRemoteProject { + dev_server_id, + creating: false, + remote_project: None, + }); + this.remote_project_path_input + .read(cx) + .focus_handle(cx) + .focus(cx); + cx.notify(); + }, + )), + ), + ), + ) + .child( + v_flex() + .w_full() + .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct + .border() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .my_1() + .py_0p5() + .px_3() + .child( + List::new().empty_message("No projects.").children( + self.remote_project_store + .read(cx) + .remote_projects_for_server(dev_server.id) + .iter() + .map(|p| self.render_remote_project(p, cx)), + ), + ), + ) + } + + fn render_remote_project( + &mut self, + project: &RemoteProject, + cx: &mut ViewContext, + ) -> impl IntoElement { + let remote_project_id = project.id; + let project_id = project.project_id; + let is_online = project_id.is_some(); + + ListItem::new(("remote-project", remote_project_id.0)) + .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted))) + .child( + Label::new(project.path.clone()) + ) + .on_click(cx.listener(move |_, _, cx| { + if let Some(project_id) = project_id { + if let Some(app_state) = AppState::global(cx).upgrade() { + workspace::join_remote_project(project_id, app_state, cx) + .detach_and_prompt_err("Could not join project", cx, |_, _| None) + } + } else { + cx.spawn(|_, mut cx| async move { + cx.prompt(gpui::PromptLevel::Critical, "This project is offline", Some("The `zed` instance running on this dev server is not connected. You will have to restart it."), &["Ok"]).await.log_err(); + }).detach(); + } + })) + } + + fn render_create_dev_server(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let Mode::CreateDevServer(CreateDevServer { + creating, + dev_server, + }) = &self.mode + else { + unreachable!() + }; + + self.dev_server_name_input.update(cx, |input, cx| { + input.set_disabled(*creating || dev_server.is_some(), cx); + }); + + v_flex() + .id("scroll-container") + .h_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .px_1() + .pt_0p5() + .gap_px() + .child( + ModalHeader::new("remote-projects") + .show_back_button(true) + .child(Headline::new("New dev server").size(HeadlineSize::Small)), + ) + .child( + ModalContent::new().child( + v_flex() + .w_full() + .child( + h_flex() + .pb_2() + .items_end() + .w_full() + .px_2() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + div() + .pl_2() + .max_w(rems(16.)) + .child(self.dev_server_name_input.clone()), + ) + .child( + div() + .pl_1() + .pb(px(3.)) + .when(!*creating && dev_server.is_none(), |div| { + div.child(Button::new("create-dev-server", "Create").on_click( + cx.listener(move |this, _, cx| { + this.create_dev_server(cx); + }), + )) + }) + .when(*creating && dev_server.is_none(), |div| { + div.child( + Button::new("create-dev-server", "Creating...") + .disabled(true), + ) + }), + ) + ) + .when(dev_server.is_none(), |div| { + div.px_2().child(Label::new("Once you have created a dev server, you will be given a command to run on the server to register it.").color(Color::Muted)) + }) + .when_some(dev_server.clone(), |div, dev_server| { + let status = self + .remote_project_store + .read(cx) + .dev_server_status(DevServerId(dev_server.dev_server_id)); + + let instructions = SharedString::from(format!( + "zed --dev-server-token {}", + dev_server.access_token + )); + div.child( + v_flex() + .pl_2() + .pt_2() + .gap_2() + .child( + h_flex().justify_between().w_full() + .child(Label::new(format!( + "Please log into `{}` and run:", + dev_server.name + ))) + .child( + Button::new("copy-access-token", "Copy Instructions") + .icon(Some(IconName::Copy)) + .icon_size(IconSize::Small) + .on_click({ + let instructions = instructions.clone(); + cx.listener(move |_, _, cx| { + cx.write_to_clipboard(ClipboardItem::new( + instructions.to_string(), + )) + })}) + ) + ) + .child( + v_flex() + .w_full() + .bg(cx.theme().colors().title_bar_background) // todo: this should be distinct + .border() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .my_1() + .py_0p5() + .px_3() + .font(ThemeSettings::get_global(cx).buffer_font.family.clone()) + .child(Label::new(instructions)) + ) + .when(status == DevServerStatus::Offline, |this| { + this.child( + + h_flex() + .gap_2() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Medium) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate(percentage(delta))) + }, + ), + ) + .child( + Label::new("Waiting for connection…"), + ) + ) + }) + .when(status == DevServerStatus::Online, |this| { + this.child(Label::new("🎊 Connection established!")) + .child( + h_flex().justify_end().child( + Button::new("done", "Done").on_click(cx.listener( + |_, _, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()) + }, + )) + ), + ) + }), + ) + }), + ) + ) + } + + fn render_default(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let dev_servers = self.remote_project_store.read(cx).dev_servers(); + + v_flex() + .id("scroll-container") + .h_full() + .overflow_y_scroll() + .track_scroll(&self.scroll_handle) + .px_1() + .pt_0p5() + .gap_px() + .child( + ModalHeader::new("remote-projects") + .show_dismiss_button(true) + .child(Headline::new("Remote Projects").size(HeadlineSize::Small)), + ) + .child( + ModalContent::new().child( + List::new() + .empty_message("No dev servers registered.") + .header(Some( + ListHeader::new("Dev Servers").end_slot( + Button::new("register-dev-server-button", "New Server") + .icon(IconName::Plus) + .icon_position(IconPosition::Start) + .tooltip(|cx| Tooltip::text("Register a new dev server", cx)) + .on_click(cx.listener(|this, _, cx| { + this.mode = Mode::CreateDevServer(Default::default()); + + this.dev_server_name_input.update(cx, |input, cx| { + input.editor().update(cx, |editor, cx| { + editor.set_text("", cx); + }); + input.focus_handle(cx).focus(cx) + }); + + cx.notify(); + })), + ), + )) + .children(dev_servers.iter().map(|dev_server| { + self.render_dev_server(dev_server, cx).into_any_element() + })), + ), + ) + } + + fn render_create_remote_project(&self, cx: &mut ViewContext) -> impl IntoElement { + let Mode::CreateRemoteProject(CreateRemoteProject { + dev_server_id, + creating, + remote_project, + }) = &self.mode + else { + unreachable!() + }; + + let dev_server = self + .remote_project_store + .read(cx) + .dev_server(*dev_server_id) + .cloned(); + + let (dev_server_name, dev_server_status) = dev_server + .map(|server| (server.name, server.status)) + .unwrap_or((SharedString::from(""), DevServerStatus::Offline)); + + v_flex() + .px_1() + .pt_0p5() + .gap_px() + .child( + v_flex().py_0p5().px_1().child( + h_flex() + .px_1() + .py_0p5() + .child( + IconButton::new("back", IconName::ArrowLeft) + .style(ButtonStyle::Transparent) + .on_click(cx.listener(|_, _: &gpui::ClickEvent, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()) + })), + ) + .child(Headline::new("Add remote project").size(HeadlineSize::Small)), + ), + ) + .child( + h_flex() + .ml_5() + .gap_2() + .child( + div() + .id(("status", dev_server_id.0)) + .relative() + .child(Icon::new(IconName::Server)) + .child(div().absolute().bottom_0().left(rems_from_px(12.0)).child( + Indicator::dot().color(match dev_server_status { + DevServerStatus::Online => Color::Created, + DevServerStatus::Offline => Color::Hidden, + }), + )) + .tooltip(move |cx| { + Tooltip::text( + match dev_server_status { + DevServerStatus::Online => "Online", + DevServerStatus::Offline => "Offline", + }, + cx, + ) + }), + ) + .child(dev_server_name.clone()), + ) + .child( + h_flex() + .ml_5() + .gap_2() + .child(self.remote_project_path_input.clone()) + .when(!*creating && remote_project.is_none(), |div| { + div.child(Button::new("create-remote-server", "Create").on_click({ + let dev_server_id = *dev_server_id; + cx.listener(move |this, _, cx| { + this.create_remote_project(dev_server_id, cx) + }) + })) + }) + .when(*creating, |div| { + div.child(Button::new("create-dev-server", "Creating...").disabled(true)) + }), + ) + .when_some(remote_project.clone(), |div, remote_project| { + let status = self + .remote_project_store + .read(cx) + .remote_project(RemoteProjectId(remote_project.id)) + .map(|project| { + if project.project_id.is_some() { + DevServerStatus::Online + } else { + DevServerStatus::Offline + } + }) + .unwrap_or(DevServerStatus::Offline); + div.child( + v_flex() + .ml_5() + .ml_8() + .gap_2() + .when(status == DevServerStatus::Offline, |this| { + this.child(Label::new("Waiting for project...")) + }) + .when(status == DevServerStatus::Online, |this| { + this.child(Label::new("Project online! 🎊")).child( + Button::new("done", "Done").on_click(cx.listener(|_, _, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()) + })), + ) + }), + ) + }) + } +} +impl ModalView for RemoteProjects {} + +impl FocusableView for RemoteProjects { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl EventEmitter for RemoteProjects {} + +impl Render for RemoteProjects { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .track_focus(&self.focus_handle) + .elevation_3(cx) + .key_context("DevServerModal") + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(Self::confirm)) + .on_mouse_down_out(cx.listener(|this, _, cx| { + if matches!(this.mode, Mode::Default) { + cx.emit(DismissEvent) + } + })) + .pb_4() + .w(rems(34.)) + .min_h(rems(20.)) + .max_h(rems(40.)) + .child(match &self.mode { + Mode::Default => self.render_default(cx).into_any_element(), + Mode::CreateRemoteProject(_) => { + self.render_create_remote_project(cx).into_any_element() + } + Mode::CreateDevServer(_) => self.render_create_dev_server(cx).into_any_element(), + }) + } +} diff --git a/crates/remote_projects/Cargo.toml b/crates/remote_projects/Cargo.toml new file mode 100644 index 0000000000..2e904f3326 --- /dev/null +++ b/crates/remote_projects/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "remote_projects" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/remote_projects.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +gpui.workspace = true +serde.workspace = true +client.workspace = true +rpc.workspace = true + +[dev-dependencies] +serde_json.workspace = true diff --git a/crates/remote_projects/src/remote_projects.rs b/crates/remote_projects/src/remote_projects.rs new file mode 100644 index 0000000000..5e62d5c32e --- /dev/null +++ b/crates/remote_projects/src/remote_projects.rs @@ -0,0 +1,186 @@ +use anyhow::Result; +use gpui::{AppContext, AsyncAppContext, Context, Global, Model, ModelContext, SharedString, Task}; +use rpc::{ + proto::{self, DevServerStatus}, + TypedEnvelope, +}; +use std::{collections::HashMap, sync::Arc}; + +use client::{Client, ProjectId}; +pub use client::{DevServerId, RemoteProjectId}; + +pub struct Store { + remote_projects: HashMap, + dev_servers: HashMap, + _subscriptions: Vec, + client: Arc, +} + +#[derive(Debug, Clone)] +pub struct RemoteProject { + pub id: RemoteProjectId, + pub project_id: Option, + pub path: SharedString, + pub dev_server_id: DevServerId, +} + +impl From for RemoteProject { + fn from(project: proto::RemoteProject) -> Self { + Self { + id: RemoteProjectId(project.id), + project_id: project.project_id.map(|id| ProjectId(id)), + path: project.path.into(), + dev_server_id: DevServerId(project.dev_server_id), + } + } +} + +#[derive(Debug, Clone)] +pub struct DevServer { + pub id: DevServerId, + pub name: SharedString, + pub status: DevServerStatus, +} + +impl From for DevServer { + fn from(dev_server: proto::DevServer) -> Self { + Self { + id: DevServerId(dev_server.dev_server_id), + status: dev_server.status(), + name: dev_server.name.into(), + } + } +} + +struct GlobalStore(Model); + +impl Global for GlobalStore {} + +pub fn init(client: Arc, cx: &mut AppContext) { + let store = cx.new_model(|cx| Store::new(client, cx)); + cx.set_global(GlobalStore(store)); +} + +impl Store { + pub fn global(cx: &AppContext) -> Model { + cx.global::().0.clone() + } + + pub fn new(client: Arc, cx: &ModelContext) -> Self { + Self { + remote_projects: Default::default(), + dev_servers: Default::default(), + _subscriptions: vec![ + client.add_message_handler(cx.weak_model(), Self::handle_remote_projects_update) + ], + client, + } + } + + pub fn remote_projects_for_server(&self, id: DevServerId) -> Vec { + let mut projects: Vec = self + .remote_projects + .values() + .filter(|project| project.dev_server_id == id) + .cloned() + .collect(); + projects.sort_by_key(|p| (p.path.clone(), p.id)); + projects + } + + pub fn dev_servers(&self) -> Vec { + let mut dev_servers: Vec = self.dev_servers.values().cloned().collect(); + dev_servers.sort_by_key(|d| (d.status == DevServerStatus::Offline, d.name.clone(), d.id)); + dev_servers + } + + pub fn dev_server(&self, id: DevServerId) -> Option<&DevServer> { + self.dev_servers.get(&id) + } + + pub fn dev_server_status(&self, id: DevServerId) -> DevServerStatus { + self.dev_server(id) + .map(|server| server.status) + .unwrap_or(DevServerStatus::Offline) + } + + pub fn remote_projects(&self) -> Vec { + let mut projects: Vec = self.remote_projects.values().cloned().collect(); + projects.sort_by_key(|p| (p.path.clone(), p.id)); + projects + } + + pub fn remote_project(&self, id: RemoteProjectId) -> Option<&RemoteProject> { + self.remote_projects.get(&id) + } + + async fn handle_remote_projects_update( + this: Model, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + this.dev_servers = envelope + .payload + .dev_servers + .into_iter() + .map(|dev_server| (DevServerId(dev_server.dev_server_id), dev_server.into())) + .collect(); + this.remote_projects = envelope + .payload + .remote_projects + .into_iter() + .map(|project| (RemoteProjectId(project.id), project.into())) + .collect(); + + cx.notify(); + })?; + Ok(()) + } + + pub fn create_remote_project( + &mut self, + dev_server_id: DevServerId, + path: String, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + cx.background_executor().spawn(async move { + client + .request(proto::CreateRemoteProject { + dev_server_id: dev_server_id.0, + path, + }) + .await + }) + } + + pub fn create_dev_server( + &mut self, + name: String, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + cx.background_executor().spawn(async move { + let result = client.request(proto::CreateDevServer { name }).await?; + Ok(result) + }) + } + + pub fn delete_dev_server( + &mut self, + id: DevServerId, + cx: &mut ModelContext, + ) -> Task> { + let client = self.client.clone(); + cx.background_executor().spawn(async move { + client + .request(proto::DeleteDevServer { + dev_server_id: id.0, + }) + .await?; + Ok(()) + }) + } +} diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index c2c0363efa..7832af4b04 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -233,6 +233,10 @@ message Envelope { JoinRemoteProject join_remote_project = 185; RejoinRemoteProjects rejoin_remote_projects = 186; RejoinRemoteProjectsResponse rejoin_remote_projects_response = 187; + + RemoteProjectsUpdate remote_projects_update = 193; + ValidateRemoteProjectRequest validate_remote_project_request = 194; // Current max + DeleteDevServer delete_dev_server = 195; } reserved 158 to 161; @@ -269,6 +273,8 @@ enum ErrorCode { UnsharedItem = 12; NoSuchProject = 13; DevServerAlreadyOnline = 14; + DevServerOffline = 15; + RemoteProjectPathDoesNotExist = 16; reserved 6; } @@ -433,6 +439,7 @@ message LiveKitConnectionInfo { message ShareProject { uint64 room_id = 1; repeated WorktreeMetadata worktrees = 2; + optional uint64 remote_project_id = 3; } message ShareProjectResponse { @@ -457,8 +464,8 @@ message JoinHostedProject { } message CreateRemoteProject { - uint64 channel_id = 1; - string name = 2; + reserved 1; + reserved 2; uint64 dev_server_id = 3; string path = 4; } @@ -466,14 +473,18 @@ message CreateRemoteProjectResponse { RemoteProject remote_project = 1; } +message ValidateRemoteProjectRequest { + string path = 1; +} + message CreateDevServer { - uint64 channel_id = 1; + reserved 1; string name = 2; } message CreateDevServerResponse { uint64 dev_server_id = 1; - uint64 channel_id = 2; + reserved 2; string access_token = 3; string name = 4; } @@ -481,6 +492,10 @@ message CreateDevServerResponse { message ShutdownDevServer { } +message DeleteDevServer { + uint64 dev_server_id = 1; +} + message ReconnectDevServer { repeated UpdateProject reshared_projects = 1; } @@ -493,6 +508,11 @@ message DevServerInstructions { repeated RemoteProject projects = 1; } +message RemoteProjectsUpdate { + repeated DevServer dev_servers = 1; + repeated RemoteProject remote_projects = 2; +} + message ShareRemoteProject { uint64 remote_project_id = 1; repeated WorktreeMetadata worktrees = 2; @@ -509,6 +529,7 @@ message JoinProjectResponse { repeated Collaborator collaborators = 3; repeated LanguageServer language_servers = 4; ChannelRole role = 6; + optional uint64 remote_project_id = 7; } message LeaveProject { @@ -1131,11 +1152,10 @@ message UpdateChannels { repeated HostedProject hosted_projects = 10; repeated uint64 deleted_hosted_projects = 11; - repeated DevServer dev_servers = 12; - repeated uint64 deleted_dev_servers = 13; - - repeated RemoteProject remote_projects = 14; - repeated uint64 deleted_remote_projects = 15; + reserved 12; + reserved 13; + reserved 14; + reserved 15; } message UpdateUserChannels { @@ -1174,14 +1194,14 @@ message HostedProject { message RemoteProject { uint64 id = 1; optional uint64 project_id = 2; - uint64 channel_id = 3; - string name = 4; + reserved 3; + reserved 4; uint64 dev_server_id = 5; string path = 6; } message DevServer { - uint64 channel_id = 1; + reserved 1; uint64 dev_server_id = 2; string name = 3; DevServerStatus status = 4; diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 48160b2fe4..25074083f3 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -303,7 +303,7 @@ messages!( (SetRoomParticipantRole, Foreground), (BlameBuffer, Foreground), (BlameBufferResponse, Foreground), - (CreateRemoteProject, Foreground), + (CreateRemoteProject, Background), (CreateRemoteProjectResponse, Foreground), (CreateDevServer, Foreground), (CreateDevServerResponse, Foreground), @@ -317,6 +317,9 @@ messages!( (RejoinRemoteProjectsResponse, Foreground), (MultiLspQuery, Background), (MultiLspQueryResponse, Background), + (RemoteProjectsUpdate, Foreground), + (ValidateRemoteProjectRequest, Background), + (DeleteDevServer, Foreground) ); request_messages!( @@ -417,7 +420,9 @@ request_messages!( (JoinRemoteProject, JoinProjectResponse), (RejoinRemoteProjects, RejoinRemoteProjectsResponse), (ReconnectDevServer, ReconnectDevServerResponse), + (ValidateRemoteProjectRequest, Ack), (MultiLspQuery, MultiLspQueryResponse), + (DeleteDevServer, Ack), ); entity_messages!( diff --git a/crates/sqlez/src/connection.rs b/crates/sqlez/src/connection.rs index 55ba5f8294..f88d37c7e0 100644 --- a/crates/sqlez/src/connection.rs +++ b/crates/sqlez/src/connection.rs @@ -105,51 +105,50 @@ impl Connection { let mut raw_statement = ptr::null_mut::(); let mut remaining_sql_ptr = ptr::null(); - let (res, offset, message, _conn) = if let Some(table_to_alter) = alter_table { - // ALTER TABLE is a weird statement. When preparing the statement the table's - // existence is checked *before* syntax checking any other part of the statement. - // Therefore, we need to make sure that the table has been created before calling - // prepare. As we don't want to trash whatever database this is connected to, we - // create a new in-memory DB to test. + let (res, offset, message, _conn) = + if let Some((table_to_alter, column)) = alter_table { + // ALTER TABLE is a weird statement. When preparing the statement the table's + // existence is checked *before* syntax checking any other part of the statement. + // Therefore, we need to make sure that the table has been created before calling + // prepare. As we don't want to trash whatever database this is connected to, we + // create a new in-memory DB to test. - let temp_connection = Connection::open_memory(None); - //This should always succeed, if it doesn't then you really should know about it - temp_connection - .exec(&format!( - "CREATE TABLE {table_to_alter}(__place_holder_column_for_syntax_checking)" - )) - .unwrap()() - .unwrap(); + let temp_connection = Connection::open_memory(None); + //This should always succeed, if it doesn't then you really should know about it + temp_connection + .exec(&format!("CREATE TABLE {table_to_alter}({column})")) + .unwrap()() + .unwrap(); - sqlite3_prepare_v2( - temp_connection.sqlite3, - remaining_sql.as_ptr(), - -1, - &mut raw_statement, - &mut remaining_sql_ptr, - ); + sqlite3_prepare_v2( + temp_connection.sqlite3, + remaining_sql.as_ptr(), + -1, + &mut raw_statement, + &mut remaining_sql_ptr, + ); - ( - sqlite3_errcode(temp_connection.sqlite3), - sqlite3_error_offset(temp_connection.sqlite3), - sqlite3_errmsg(temp_connection.sqlite3), - Some(temp_connection), - ) - } else { - sqlite3_prepare_v2( - self.sqlite3, - remaining_sql.as_ptr(), - -1, - &mut raw_statement, - &mut remaining_sql_ptr, - ); - ( - sqlite3_errcode(self.sqlite3), - sqlite3_error_offset(self.sqlite3), - sqlite3_errmsg(self.sqlite3), - None, - ) - }; + ( + sqlite3_errcode(temp_connection.sqlite3), + sqlite3_error_offset(temp_connection.sqlite3), + sqlite3_errmsg(temp_connection.sqlite3), + Some(temp_connection), + ) + } else { + sqlite3_prepare_v2( + self.sqlite3, + remaining_sql.as_ptr(), + -1, + &mut raw_statement, + &mut remaining_sql_ptr, + ); + ( + sqlite3_errcode(self.sqlite3), + sqlite3_error_offset(self.sqlite3), + sqlite3_errmsg(self.sqlite3), + None, + ) + }; sqlite3_finalize(raw_statement); @@ -203,7 +202,7 @@ impl Connection { } } -fn parse_alter_table(remaining_sql_str: &str) -> Option { +fn parse_alter_table(remaining_sql_str: &str) -> Option<(String, String)> { let remaining_sql_str = remaining_sql_str.to_lowercase(); if remaining_sql_str.starts_with("alter") { if let Some(table_offset) = remaining_sql_str.find("table") { @@ -215,7 +214,19 @@ fn parse_alter_table(remaining_sql_str: &str) -> Option { .take_while(|c| !c.is_whitespace()) .collect::(); if !table_to_alter.is_empty() { - return Some(table_to_alter); + let column_name = + if let Some(rename_offset) = remaining_sql_str.find("rename column") { + let after_rename_offset = rename_offset + "rename column".len(); + remaining_sql_str + .chars() + .skip(after_rename_offset) + .skip_while(|c| c.is_whitespace()) + .take_while(|c| !c.is_whitespace()) + .collect::() + } else { + "__place_holder_column_for_syntax_checking".to_string() + }; + return Some((table_to_alter, column_name)); } } } diff --git a/crates/sqlez/src/statement.rs b/crates/sqlez/src/statement.rs index 122b6d0c58..462f902239 100644 --- a/crates/sqlez/src/statement.rs +++ b/crates/sqlez/src/statement.rs @@ -320,6 +320,7 @@ impl<'a> Statement<'a> { this: &mut Statement, callback: impl FnOnce(&mut Statement) -> Result, ) -> Result { + println!("{:?}", std::any::type_name::()); if this.step()? != StepResult::Row { return Err(anyhow!("single called with query that returns no rows.")); } diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs index ab04cd5909..d63b56b015 100644 --- a/crates/tasks_ui/src/modal.rs +++ b/crates/tasks_ui/src/modal.rs @@ -330,6 +330,7 @@ impl PickerDelegate for TasksModalDelegate { text: hit.string.clone(), highlight_positions: hit.positions.clone(), char_count: hit.string.chars().count(), + color: Color::Default, }; let icon = match source_kind { TaskSourceKind::UserInput => Some(Icon::new(IconName::Terminal)), diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index f31db36204..ef53ab109e 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -1,7 +1,7 @@ -use gpui::{svg, IntoElement, Rems, Transformation}; +use gpui::{svg, Hsla, IntoElement, Rems, Transformation}; use strum::EnumIter; -use crate::prelude::*; +use crate::{prelude::*, Indicator}; #[derive(Default, PartialEq, Copy, Clone)] pub enum IconSize { @@ -283,3 +283,63 @@ impl RenderOnce for Icon { .text_color(self.color.color(cx)) } } + +#[derive(IntoElement)] +pub struct IconWithIndicator { + icon: Icon, + indicator: Option, + indicator_border_color: Option, +} + +impl IconWithIndicator { + pub fn new(icon: Icon, indicator: Option) -> Self { + Self { + icon, + indicator, + indicator_border_color: None, + } + } + + pub fn indicator(mut self, indicator: Option) -> Self { + self.indicator = indicator; + self + } + + pub fn indicator_color(mut self, color: Color) -> Self { + if let Some(indicator) = self.indicator.as_mut() { + indicator.color = color; + } + self + } + + pub fn indicator_border_color(mut self, color: Option) -> Self { + self.indicator_border_color = color; + self + } +} + +impl RenderOnce for IconWithIndicator { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let indicator_border_color = self + .indicator_border_color + .unwrap_or_else(|| cx.theme().colors().elevated_surface_background); + + div() + .relative() + .child(self.icon) + .when_some(self.indicator, |this, indicator| { + this.child( + div() + .absolute() + .w_2() + .h_2() + .border() + .border_color(indicator_border_color) + .rounded_full() + .neg_bottom_0p5() + .neg_right_1() + .child(indicator), + ) + }) + } +} diff --git a/crates/ui/src/components/modal.rs b/crates/ui/src/components/modal.rs index 7ce9707b0a..107f61d5b4 100644 --- a/crates/ui/src/components/modal.rs +++ b/crates/ui/src/components/modal.rs @@ -1,12 +1,16 @@ -use gpui::*; +use gpui::{prelude::FluentBuilder, *}; use smallvec::SmallVec; -use crate::{h_flex, IconButton, IconButtonShape, IconName, Label, LabelCommon, LabelSize}; +use crate::{ + h_flex, Clickable, IconButton, IconButtonShape, IconName, Label, LabelCommon, LabelSize, +}; #[derive(IntoElement)] pub struct ModalHeader { id: ElementId, children: SmallVec<[AnyElement; 2]>, + show_dismiss_button: bool, + show_back_button: bool, } impl ModalHeader { @@ -14,8 +18,20 @@ impl ModalHeader { Self { id: id.into(), children: SmallVec::new(), + show_dismiss_button: false, + show_back_button: false, } } + + pub fn show_dismiss_button(mut self, show: bool) -> Self { + self.show_dismiss_button = show; + self + } + + pub fn show_back_button(mut self, show: bool) -> Self { + self.show_back_button = show; + self + } } impl ParentElement for ModalHeader { @@ -31,9 +47,28 @@ impl RenderOnce for ModalHeader { .w_full() .px_2() .py_1p5() + .when(self.show_back_button, |this| { + this.child( + div().pr_1().child( + IconButton::new("back", IconName::ArrowLeft) + .shape(IconButtonShape::Square) + .on_click(|_, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()); + }), + ), + ) + }) .child(div().flex_1().children(self.children)) .justify_between() - .child(IconButton::new("dismiss", IconName::Close).shape(IconButtonShape::Square)) + .when(self.show_dismiss_button, |this| { + this.child( + IconButton::new("dismiss", IconName::Close) + .shape(IconButtonShape::Square) + .on_click(|_, cx| { + cx.dispatch_action(menu::Cancel.boxed_clone()); + }), + ) + }) } } diff --git a/crates/ui_text_field/src/ui_text_field.rs b/crates/ui_text_field/src/ui_text_field.rs index 3e5a347a6d..f5addf3a9f 100644 --- a/crates/ui_text_field/src/ui_text_field.rs +++ b/crates/ui_text_field/src/ui_text_field.rs @@ -44,6 +44,8 @@ pub struct TextField { start_icon: Option, /// The layout of the label relative to the text field. with_label: FieldLabelLayout, + /// Whether the text field is disabled. + disabled: bool, } impl FocusableView for TextField { @@ -72,6 +74,7 @@ impl TextField { editor, start_icon: None, with_label: FieldLabelLayout::Hidden, + disabled: false, } } @@ -84,6 +87,16 @@ impl TextField { self.with_label = layout; self } + + pub fn set_disabled(&mut self, disabled: bool, cx: &mut ViewContext) { + self.disabled = disabled; + self.editor + .update(cx, |editor, _| editor.set_read_only(disabled)) + } + + pub fn editor(&self) -> &View { + &self.editor + } } impl Render for TextField { @@ -91,17 +104,17 @@ impl Render for TextField { let settings = ThemeSettings::get_global(cx); let theme_color = cx.theme().colors(); - let style = TextFieldStyle { + let mut style = TextFieldStyle { text_color: theme_color.text, background_color: theme_color.ghost_element_background, border_color: theme_color.border, }; - // if self.disabled { - // style.text_color = theme_color.text_disabled; - // style.background_color = theme_color.ghost_element_disabled; - // style.border_color = theme_color.border_disabled; - // } + if self.disabled { + style.text_color = theme_color.text_disabled; + style.background_color = theme_color.ghost_element_disabled; + style.border_color = theme_color.border_disabled; + } // if self.error_message.is_some() { // style.text_color = cx.theme().status().error; @@ -131,7 +144,15 @@ impl Render for TextField { .group("text-field") .w_full() .when(self.with_label == FieldLabelLayout::Stacked, |this| { - this.child(Label::new(self.label.clone()).size(LabelSize::Default)) + this.child( + Label::new(self.label.clone()) + .size(LabelSize::Default) + .color(if self.disabled { + Color::Disabled + } else { + Color::Muted + }), + ) }) .child( v_flex().w_full().child( diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 608efb23c7..3b5a6be2b5 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -45,6 +45,7 @@ node_runtime.workspace = true parking_lot.workspace = true postage.workspace = true project.workspace = true +remote_projects.workspace = true task.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 130f9b038e..15184a9d3b 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -513,8 +513,9 @@ impl ItemHandle for View { })); } - let mut event_subscription = - Some(cx.subscribe(self, move |workspace, item, event, cx| { + let mut event_subscription = Some(cx.subscribe( + self, + move |workspace, item: View, event, cx| { let pane = if let Some(pane) = workspace .panes_by_item .get(&item.item_id()) @@ -575,7 +576,8 @@ impl ItemHandle for View { _ => {} }); - })); + }, + )); cx.on_blur(&self.focus_handle(cx), move |workspace, cx| { if WorkspaceSettings::get_global(cx).autosave == AutosaveSetting::OnFocusChange { diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 3c4efa7669..2e507dc1bb 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -3,6 +3,7 @@ pub mod model; use std::path::Path; use anyhow::{anyhow, bail, Context, Result}; +use client::RemoteProjectId; use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql}; use gpui::{point, size, Axis, Bounds}; @@ -17,11 +18,11 @@ use uuid::Uuid; use crate::WorkspaceId; use model::{ - GroupId, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup, SerializedWorkspace, - WorkspaceLocation, + GroupId, LocalPaths, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup, + SerializedWorkspace, }; -use self::model::DockStructure; +use self::model::{DockStructure, SerializedRemoteProject, SerializedWorkspaceLocation}; #[derive(Copy, Clone, Debug, PartialEq)] pub(crate) struct SerializedAxis(pub(crate) gpui::Axis); @@ -125,7 +126,7 @@ define_connection! { // // workspaces( // workspace_id: usize, // Primary key for workspaces - // workspace_location: Bincode>, + // local_paths: Bincode>, // dock_visible: bool, // Deprecated // dock_anchor: DockAnchor, // Deprecated // dock_pane: Option, // Deprecated @@ -289,6 +290,15 @@ define_connection! { sql!( ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool ), + sql!( + CREATE TABLE remote_projects ( + remote_project_id INTEGER NOT NULL UNIQUE, + path TEXT, + dev_server_name TEXT + ); + ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER; + ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths; + ), ]; } @@ -300,13 +310,23 @@ impl WorkspaceDb { &self, worktree_roots: &[P], ) -> Option { - let workspace_location: WorkspaceLocation = worktree_roots.into(); + let local_paths = LocalPaths::new(worktree_roots); // Note that we re-assign the workspace_id here in case it's empty // and we've grabbed the most recent workspace - let (workspace_id, workspace_location, bounds, display, fullscreen, centered_layout, docks): ( + let ( + workspace_id, + local_paths, + remote_project_id, + bounds, + display, + fullscreen, + centered_layout, + docks, + ): ( WorkspaceId, - WorkspaceLocation, + Option, + Option, Option, Option, Option, @@ -316,7 +336,8 @@ impl WorkspaceDb { .select_row_bound(sql! { SELECT workspace_id, - workspace_location, + local_paths, + remote_project_id, window_state, window_x, window_y, @@ -335,16 +356,34 @@ impl WorkspaceDb { bottom_dock_active_panel, bottom_dock_zoom FROM workspaces - WHERE workspace_location = ? + WHERE local_paths = ? }) - .and_then(|mut prepared_statement| (prepared_statement)(&workspace_location)) + .and_then(|mut prepared_statement| (prepared_statement)(&local_paths)) .context("No workspaces found") .warn_on_err() .flatten()?; + let location = if let Some(remote_project_id) = remote_project_id { + let remote_project: SerializedRemoteProject = self + .select_row_bound(sql! { + SELECT remote_project_id, path, dev_server_name + FROM remote_projects + WHERE remote_project_id = ? + }) + .and_then(|mut prepared_statement| (prepared_statement)(remote_project_id)) + .context("No remote project found") + .warn_on_err() + .flatten()?; + SerializedWorkspaceLocation::Remote(remote_project) + } else if let Some(local_paths) = local_paths { + SerializedWorkspaceLocation::Local(local_paths) + } else { + return None; + }; + Some(SerializedWorkspace { id: workspace_id, - location: workspace_location.clone(), + location, center_group: self .get_center_pane_group(workspace_id) .context("Getting center group") @@ -368,43 +407,102 @@ impl WorkspaceDb { DELETE FROM panes WHERE workspace_id = ?1;))?(workspace.id) .context("Clearing old panes")?; - conn.exec_bound(sql!( - DELETE FROM workspaces WHERE workspace_location = ? AND workspace_id != ? - ))?((&workspace.location, workspace.id)) - .context("clearing out old locations")?; + match workspace.location { + SerializedWorkspaceLocation::Local(local_paths) => { + conn.exec_bound(sql!( + DELETE FROM workspaces WHERE local_paths = ? AND workspace_id != ? + ))?((&local_paths, workspace.id)) + .context("clearing out old locations")?; - // Upsert - conn.exec_bound(sql!( - INSERT INTO workspaces( - workspace_id, - workspace_location, - left_dock_visible, - left_dock_active_panel, - left_dock_zoom, - right_dock_visible, - right_dock_active_panel, - right_dock_zoom, - bottom_dock_visible, - bottom_dock_active_panel, - bottom_dock_zoom, - timestamp - ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP) - ON CONFLICT DO - UPDATE SET - workspace_location = ?2, - left_dock_visible = ?3, - left_dock_active_panel = ?4, - left_dock_zoom = ?5, - right_dock_visible = ?6, - right_dock_active_panel = ?7, - right_dock_zoom = ?8, - bottom_dock_visible = ?9, - bottom_dock_active_panel = ?10, - bottom_dock_zoom = ?11, - timestamp = CURRENT_TIMESTAMP - ))?((workspace.id, &workspace.location, workspace.docks)) - .context("Updating workspace")?; + // Upsert + conn.exec_bound(sql!( + INSERT INTO workspaces( + workspace_id, + local_paths, + left_dock_visible, + left_dock_active_panel, + left_dock_zoom, + right_dock_visible, + right_dock_active_panel, + right_dock_zoom, + bottom_dock_visible, + bottom_dock_active_panel, + bottom_dock_zoom, + timestamp + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP) + ON CONFLICT DO + UPDATE SET + local_paths = ?2, + left_dock_visible = ?3, + left_dock_active_panel = ?4, + left_dock_zoom = ?5, + right_dock_visible = ?6, + right_dock_active_panel = ?7, + right_dock_zoom = ?8, + bottom_dock_visible = ?9, + bottom_dock_active_panel = ?10, + bottom_dock_zoom = ?11, + timestamp = CURRENT_TIMESTAMP + ))?((workspace.id, &local_paths, workspace.docks)) + .context("Updating workspace")?; + } + SerializedWorkspaceLocation::Remote(remote_project) => { + conn.exec_bound(sql!( + DELETE FROM workspaces WHERE remote_project_id = ? AND workspace_id != ? + ))?((remote_project.id.0, workspace.id)) + .context("clearing out old locations")?; + + conn.exec_bound(sql!( + INSERT INTO remote_projects( + remote_project_id, + path, + dev_server_name + ) VALUES (?1, ?2, ?3) + ON CONFLICT DO + UPDATE SET + path = ?2, + dev_server_name = ?3 + ))?(&remote_project)?; + + // Upsert + conn.exec_bound(sql!( + INSERT INTO workspaces( + workspace_id, + remote_project_id, + left_dock_visible, + left_dock_active_panel, + left_dock_zoom, + right_dock_visible, + right_dock_active_panel, + right_dock_zoom, + bottom_dock_visible, + bottom_dock_active_panel, + bottom_dock_zoom, + timestamp + ) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP) + ON CONFLICT DO + UPDATE SET + remote_project_id = ?2, + left_dock_visible = ?3, + left_dock_active_panel = ?4, + left_dock_zoom = ?5, + right_dock_visible = ?6, + right_dock_active_panel = ?7, + right_dock_zoom = ?8, + bottom_dock_visible = ?9, + bottom_dock_active_panel = ?10, + bottom_dock_zoom = ?11, + timestamp = CURRENT_TIMESTAMP + ))?(( + workspace.id, + remote_project.id.0, + workspace.docks, + )) + .context("Updating workspace")?; + } + } // Save center pane group Self::save_pane_group(conn, workspace.id, &workspace.center_group, None) @@ -424,24 +522,43 @@ impl WorkspaceDb { } query! { - fn recent_workspaces() -> Result> { - SELECT workspace_id, workspace_location + fn recent_workspaces() -> Result)>> { + SELECT workspace_id, local_paths, remote_project_id FROM workspaces - WHERE workspace_location IS NOT NULL + WHERE local_paths IS NOT NULL OR remote_project_id IS NOT NULL ORDER BY timestamp DESC } } query! { - pub fn last_window() -> Result<(Option, Option, Option)> { - SELECT display, window_state, window_x, window_y, window_width, window_height, fullscreen - FROM workspaces - WHERE workspace_location IS NOT NULL - ORDER BY timestamp DESC - LIMIT 1 + fn remote_projects() -> Result> { + SELECT remote_project_id, path, dev_server_name + FROM remote_projects } } + pub(crate) fn last_window( + &self, + ) -> anyhow::Result<(Option, Option, Option)> { + let mut prepared_query = + self.select::<(Option, Option, Option)>(sql!( + SELECT + display, + window_state, window_x, window_y, window_width, window_height, + fullscreen + FROM workspaces + WHERE local_paths + IS NOT NULL + ORDER BY timestamp DESC + LIMIT 1 + ))?; + let result = prepared_query()?; + Ok(result + .into_iter() + .next() + .unwrap_or_else(|| (None, None, None))) + } + query! { pub async fn delete_workspace_by_id(id: WorkspaceId) -> Result<()> { DELETE FROM workspaces @@ -451,14 +568,29 @@ impl WorkspaceDb { // Returns the recent locations which are still valid on disk and deletes ones which no longer // exist. - pub async fn recent_workspaces_on_disk(&self) -> Result> { + pub async fn recent_workspaces_on_disk( + &self, + ) -> Result> { let mut result = Vec::new(); let mut delete_tasks = Vec::new(); - for (id, location) in self.recent_workspaces()? { + let remote_projects = self.remote_projects()?; + + for (id, location, remote_project_id) in self.recent_workspaces()? { + if let Some(remote_project_id) = remote_project_id.map(RemoteProjectId) { + if let Some(remote_project) = + remote_projects.iter().find(|rp| rp.id == remote_project_id) + { + result.push((id, remote_project.clone().into())); + } else { + delete_tasks.push(self.delete_workspace_by_id(id)); + } + continue; + } + if location.paths().iter().all(|path| path.exists()) && location.paths().iter().any(|path| path.is_dir()) { - result.push((id, location)); + result.push((id, location.into())); } else { delete_tasks.push(self.delete_workspace_by_id(id)); } @@ -468,13 +600,16 @@ impl WorkspaceDb { Ok(result) } - pub async fn last_workspace(&self) -> Result> { + pub async fn last_workspace(&self) -> Result> { Ok(self .recent_workspaces_on_disk() .await? .into_iter() - .next() - .map(|(_, location)| location)) + .filter_map(|(_, location)| match location { + SerializedWorkspaceLocation::Local(local_paths) => Some(local_paths), + SerializedWorkspaceLocation::Remote(_) => None, + }) + .next()) } fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result { @@ -774,7 +909,7 @@ mod tests { let mut workspace_1 = SerializedWorkspace { id: WorkspaceId(1), - location: (["/tmp", "/tmp2"]).into(), + location: LocalPaths::new(["/tmp", "/tmp2"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -785,7 +920,7 @@ mod tests { let workspace_2 = SerializedWorkspace { id: WorkspaceId(2), - location: (["/tmp"]).into(), + location: LocalPaths::new(["/tmp"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -812,7 +947,7 @@ mod tests { }) .await; - workspace_1.location = (["/tmp", "/tmp3"]).into(); + workspace_1.location = LocalPaths::new(["/tmp", "/tmp3"]).into(); db.save_workspace(workspace_1.clone()).await; db.save_workspace(workspace_1).await; db.save_workspace(workspace_2).await; @@ -885,7 +1020,7 @@ mod tests { let workspace = SerializedWorkspace { id: WorkspaceId(5), - location: (["/tmp", "/tmp2"]).into(), + location: LocalPaths::new(["/tmp", "/tmp2"]).into(), center_group, bounds: Default::default(), display: Default::default(), @@ -915,7 +1050,7 @@ mod tests { let workspace_1 = SerializedWorkspace { id: WorkspaceId(1), - location: (["/tmp", "/tmp2"]).into(), + location: LocalPaths::new(["/tmp", "/tmp2"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -926,7 +1061,7 @@ mod tests { let mut workspace_2 = SerializedWorkspace { id: WorkspaceId(2), - location: (["/tmp"]).into(), + location: LocalPaths::new(["/tmp"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -953,7 +1088,7 @@ mod tests { assert_eq!(db.workspace_for_roots(&["/tmp3", "/tmp2", "/tmp4"]), None); // Test 'mutate' case of updating a pre-existing id - workspace_2.location = (["/tmp", "/tmp2"]).into(); + workspace_2.location = LocalPaths::new(["/tmp", "/tmp2"]).into(); db.save_workspace(workspace_2.clone()).await; assert_eq!( @@ -964,7 +1099,7 @@ mod tests { // Test other mechanism for mutating let mut workspace_3 = SerializedWorkspace { id: WorkspaceId(3), - location: (&["/tmp", "/tmp2"]).into(), + location: LocalPaths::new(&["/tmp", "/tmp2"]).into(), center_group: Default::default(), bounds: Default::default(), display: Default::default(), @@ -980,7 +1115,7 @@ mod tests { ); // Make sure that updating paths differently also works - workspace_3.location = (["/tmp3", "/tmp4", "/tmp2"]).into(); + workspace_3.location = LocalPaths::new(["/tmp3", "/tmp4", "/tmp2"]).into(); db.save_workspace(workspace_3.clone()).await; assert_eq!(db.workspace_for_roots(&["/tmp2", "tmp"]), None); assert_eq!( @@ -999,7 +1134,7 @@ mod tests { ) -> SerializedWorkspace { SerializedWorkspace { id: WorkspaceId(4), - location: workspace_id.into(), + location: LocalPaths::new(workspace_id).into(), center_group: center_group.clone(), bounds: Default::default(), display: Default::default(), diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index b8f35447dc..eb64c31c1f 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -2,12 +2,14 @@ use super::SerializedAxis; use crate::{item::ItemHandle, ItemDeserializers, Member, Pane, PaneAxis, Workspace, WorkspaceId}; use anyhow::{Context, Result}; use async_recursion::async_recursion; +use client::RemoteProjectId; use db::sqlez::{ bindable::{Bind, Column, StaticColumnCount}, statement::Statement, }; use gpui::{AsyncWindowContext, Bounds, DevicePixels, Model, Task, View, WeakView}; use project::Project; +use serde::{Deserialize, Serialize}; use std::{ path::{Path, PathBuf}, sync::Arc, @@ -15,59 +17,98 @@ use std::{ use util::ResultExt; use uuid::Uuid; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WorkspaceLocation(Arc>); +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct SerializedRemoteProject { + pub id: RemoteProjectId, + pub dev_server_name: String, + pub path: String, +} + +#[derive(Debug, PartialEq, Clone)] +pub struct LocalPaths(Arc>); + +impl LocalPaths { + pub fn new>(paths: impl IntoIterator) -> Self { + let mut paths: Vec = paths + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + paths.sort(); + Self(Arc::new(paths)) + } -impl WorkspaceLocation { pub fn paths(&self) -> Arc> { self.0.clone() } +} - #[cfg(any(test, feature = "test-support"))] - pub fn new>(paths: Vec

) -> Self { - Self(Arc::new( - paths - .into_iter() - .map(|p| p.as_ref().to_path_buf()) - .collect(), - )) +impl From for SerializedWorkspaceLocation { + fn from(local_paths: LocalPaths) -> Self { + Self::Local(local_paths) } } -impl, T: IntoIterator> From for WorkspaceLocation { - fn from(iterator: T) -> Self { - let mut roots = iterator - .into_iter() - .map(|p| p.as_ref().to_path_buf()) - .collect::>(); - roots.sort(); - Self(Arc::new(roots)) - } -} - -impl StaticColumnCount for WorkspaceLocation {} -impl Bind for &WorkspaceLocation { +impl StaticColumnCount for LocalPaths {} +impl Bind for &LocalPaths { fn bind(&self, statement: &Statement, start_index: i32) -> Result { - bincode::serialize(&self.0) - .expect("Bincode serialization of paths should not fail") - .bind(statement, start_index) + statement.bind(&bincode::serialize(&self.0)?, start_index) } } -impl Column for WorkspaceLocation { +impl Column for LocalPaths { fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { - let blob = statement.column_blob(start_index)?; + let path_blob = statement.column_blob(start_index)?; + let paths: Arc> = if path_blob.is_empty() { + Default::default() + } else { + bincode::deserialize(path_blob).context("Bincode deserialization of paths failed")? + }; + + Ok((Self(paths), start_index + 1)) + } +} + +impl From for SerializedWorkspaceLocation { + fn from(remote_project: SerializedRemoteProject) -> Self { + Self::Remote(remote_project) + } +} + +impl StaticColumnCount for SerializedRemoteProject {} +impl Bind for &SerializedRemoteProject { + fn bind(&self, statement: &Statement, start_index: i32) -> Result { + let next_index = statement.bind(&self.id.0, start_index)?; + let next_index = statement.bind(&self.dev_server_name, next_index)?; + statement.bind(&self.path, next_index) + } +} + +impl Column for SerializedRemoteProject { + fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { + let id = statement.column_int64(start_index)?; + let dev_server_name = statement.column_text(start_index + 1)?.to_string(); + let path = statement.column_text(start_index + 2)?.to_string(); Ok(( - WorkspaceLocation(bincode::deserialize(blob).context("Bincode failed")?), - start_index + 1, + Self { + id: RemoteProjectId(id as u64), + dev_server_name, + path, + }, + start_index + 3, )) } } +#[derive(Debug, PartialEq, Clone)] +pub enum SerializedWorkspaceLocation { + Local(LocalPaths), + Remote(SerializedRemoteProject), +} + #[derive(Debug, PartialEq, Clone)] pub(crate) struct SerializedWorkspace { pub(crate) id: WorkspaceId, - pub(crate) location: WorkspaceLocation, + pub(crate) location: SerializedWorkspaceLocation, pub(crate) center_group: SerializedPaneGroup, pub(crate) bounds: Option>, pub(crate) fullscreen: bool, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 5f11c39446..2a6ae60701 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -46,7 +46,7 @@ pub use pane::*; pub use pane_group::*; use persistence::{model::SerializedWorkspace, SerializedWindowsBounds, DB}; pub use persistence::{ - model::{ItemId, WorkspaceLocation}, + model::{ItemId, LocalPaths, SerializedRemoteProject, SerializedWorkspaceLocation}, WorkspaceDb, DB as WORKSPACE_DB, }; use postage::stream::Stream; @@ -82,7 +82,7 @@ use ui::{ InteractiveElement as _, IntoElement, Label, ParentElement as _, Pixels, SharedString, Styled as _, ViewContext, VisualContext as _, WindowContext, }; -use util::ResultExt; +use util::{maybe, ResultExt}; use uuid::Uuid; pub use workspace_settings::{ AutosaveSetting, RestoreOnStartupBehaviour, TabBarSettings, WorkspaceSettings, @@ -3392,17 +3392,16 @@ impl Workspace { self.database_id } - fn location(&self, cx: &AppContext) -> Option { + fn local_paths(&self, cx: &AppContext) -> Option { let project = self.project().read(cx); if project.is_local() { - Some( + Some(LocalPaths::new( project .visible_worktrees(cx) .map(|worktree| worktree.read(cx).abs_path()) - .collect::>() - .into(), - ) + .collect::>(), + )) } else { None } @@ -3540,25 +3539,44 @@ impl Workspace { } } - if let Some(location) = self.location(cx) { - // Load bearing special case: - // - with_local_workspace() relies on this to not have other stuff open - // when you open your log - if !location.paths().is_empty() { - let center_group = build_serialized_pane_group(&self.center.root, cx); - let docks = build_serialized_docks(self, cx); - let serialized_workspace = SerializedWorkspace { - id: self.database_id, - location, - center_group, - bounds: Default::default(), - display: Default::default(), - docks, - fullscreen: cx.is_fullscreen(), - centered_layout: self.centered_layout, - }; - return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace)); + let location = if let Some(local_paths) = self.local_paths(cx) { + if !local_paths.paths().is_empty() { + Some(SerializedWorkspaceLocation::Local(local_paths)) + } else { + None } + } else if let Some(remote_project_id) = self.project().read(cx).remote_project_id() { + let store = remote_projects::Store::global(cx).read(cx); + maybe!({ + let project = store.remote_project(remote_project_id)?; + let dev_server = store.dev_server(project.dev_server_id)?; + + let remote_project = SerializedRemoteProject { + id: remote_project_id, + dev_server_name: dev_server.name.to_string(), + path: project.path.to_string(), + }; + Some(SerializedWorkspaceLocation::Remote(remote_project)) + }) + } else { + None + }; + + // don't save workspace state for the empty workspace. + if let Some(location) = location { + let center_group = build_serialized_pane_group(&self.center.root, cx); + let docks = build_serialized_docks(self, cx); + let serialized_workspace = SerializedWorkspace { + id: self.database_id, + location, + center_group, + bounds: Default::default(), + display: Default::default(), + docks, + fullscreen: cx.is_fullscreen(), + centered_layout: self.centered_layout, + }; + return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace)); } Task::ready(()) } @@ -4303,7 +4321,7 @@ pub fn activate_workspace_for_project( None } -pub async fn last_opened_workspace_paths() -> Option { +pub async fn last_opened_workspace_paths() -> Option { DB.last_workspace().await.log_err().flatten() } @@ -4410,7 +4428,6 @@ async fn join_channel_internal( if let Some((project, host)) = room.most_active_project(cx) { return Some(join_in_room_project(project, host, app_state.clone(), cx)); } - // if you are the first to join a channel, share your project if room.remote_participants().len() == 0 && !room.local_participant_is_guest() { if let Some(workspace) = requesting_window { @@ -4419,7 +4436,7 @@ async fn join_channel_internal( return None; } let project = workspace.project.read(cx); - if project.is_local() + if (project.is_local() || project.remote_project_id().is_some()) && project.visible_worktrees(cx).any(|tree| { tree.read(cx) .root_entry() diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 8b4ad7134f..d005138dba 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -71,6 +71,7 @@ project_panel.workspace = true project_symbols.workspace = true quick_action_bar.workspace = true recent_projects.workspace = true +remote_projects.workspace = true release_channel.workspace = true rope.workspace = true search.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index d591b0525d..3bc06f9ac6 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -286,6 +286,7 @@ fn init_ui(args: Args) { ThemeRegistry::global(cx), cx, ); + remote_projects::init(client.clone(), cx); load_user_themes_in_background(fs.clone(), cx); watch_themes(fs.clone(), cx); diff --git a/script/zed-local b/script/zed-local index af15ec8755..0ab6f0d0d1 100755 --- a/script/zed-local +++ b/script/zed-local @@ -42,6 +42,7 @@ let instanceCount = 1; let isReleaseMode = false; let isTop = false; let othersOnStable = false; +let isStateful = false; const args = process.argv.slice(2); while (args.length > 0) { @@ -52,6 +53,8 @@ while (args.length > 0) { instanceCount = parseInt(digitMatch[1]); } else if (arg === "--release") { isReleaseMode = true; + } else if (arg == "--stateful") { + isStateful = true; } else if (arg === "--top") { isTop = true; } else if (arg === "--help") { @@ -147,7 +150,7 @@ setTimeout(() => { env: { ZED_IMPERSONATE: users[i], ZED_WINDOW_POSITION: position, - ZED_STATELESS: "1", + ZED_STATELESS: isStateful && i == 0 ? "1" : "", ZED_ALWAYS_ACTIVE: "1", ZED_SERVER_URL: "http://localhost:3000", ZED_RPC_URL: "http://localhost:8080/rpc",