Detailed changes
@@ -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",
@@ -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" }
@@ -1,5 +1,16 @@
-<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
-<path d="M7.99993 6.85713C11.1558 6.85713 13.7142 5.83379 13.7142 4.57142C13.7142 3.30905 11.1558 2.28571 7.99993 2.28571C4.84402 2.28571 2.28564 3.30905 2.28564 4.57142C2.28564 5.83379 4.84402 6.85713 7.99993 6.85713Z" fill="black" stroke="black" stroke-width="1.5"/>
-<path d="M13.7142 4.57141V11.4286C13.7142 12.691 11.1558 13.7143 7.99993 13.7143C4.84402 13.7143 2.28564 12.691 2.28564 11.4286V4.57141" stroke="black" stroke-width="1.5"/>
-<path d="M13.7142 8C13.7142 9.26237 11.1558 10.2857 7.99993 10.2857C4.84402 10.2857 2.28564 9.26237 2.28564 8" stroke="black" stroke-width="1.5"/>
+<svg
+ xmlns="http://www.w3.org/2000/svg"
+ width="24"
+ height="24"
+ viewBox="0 0 24 24"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+>
+ <rect width="20" height="8" x="2" y="2" rx="2" ry="2" />
+ <rect width="20" height="8" x="2" y="14" rx="2" ry="2" />
+ <line x1="6" x2="6.01" y1="6" y2="6" />
+ <line x1="6" x2="6.01" y1="18" y2="18" />
</svg>
@@ -1203,14 +1203,24 @@ impl Room {
project: Model<Project>,
cx: &mut ModelContext<Self>,
) -> Task<Result<u64>> {
- 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?;
@@ -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;
@@ -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<proto::HostedProject> for HostedProject {
}
}
}
-
-#[derive(Debug, Clone)]
-pub struct RemoteProject {
- pub id: RemoteProjectId,
- pub project_id: Option<ProjectId>,
- pub channel_id: ChannelId,
- pub name: SharedString,
- pub path: SharedString,
- pub dev_server_id: DevServerId,
-}
-
-impl From<proto::RemoteProject> 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<proto::DevServer> 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<Arc<Channel>>,
channel_participants: HashMap<ChannelId, Vec<Arc<User>>>,
channel_states: HashMap<ChannelId, ChannelState>,
hosted_projects: HashMap<ProjectId, HostedProject>,
- remote_projects: HashMap<RemoteProjectId, RemoteProject>,
- dev_servers: HashMap<DevServerId, DevServer>,
outgoing_invites: HashSet<(ChannelId, UserId)>,
update_channels_tx: mpsc::UnboundedSender<proto::UpdateChannels>,
@@ -133,8 +85,6 @@ pub struct ChannelState {
observed_chat_message: Option<u64>,
role: Option<ChannelRole>,
projects: HashSet<ProjectId>,
- dev_servers: HashSet<DevServerId>,
- remote_projects: HashSet<RemoteProjectId>,
}
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<DevServer> {
- let mut dev_servers: Vec<DevServer> = 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<RemoteProject> {
- let mut remote_projects: Vec<RemoteProject> = 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<Self>,
- ) -> Task<Result<proto::CreateRemoteProjectResponse>> {
- 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<Self>,
- ) -> Task<Result<proto::CreateDevServerResponse>> {
- 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);
- }
}
@@ -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)]
@@ -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
@@ -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
);
@@ -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;
@@ -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);
@@ -655,8 +655,6 @@ pub struct ChannelsForUser {
pub channel_memberships: Vec<channel_member::Model>,
pub channel_participants: HashMap<ChannelId, Vec<UserId>>,
pub hosted_projects: Vec<proto::HostedProject>,
- pub dev_servers: Vec<dev_server::Model>,
- pub remote_projects: Vec<proto::RemoteProject>,
pub observed_buffer_versions: Vec<proto::ChannelBufferVersion>,
pub observed_channel_messages: Vec<proto::ChannelMessageId>,
@@ -764,6 +762,7 @@ pub struct Project {
pub collaborators: Vec<ProjectCollaborator>,
pub worktrees: BTreeMap<u64, Worktree>,
pub language_servers: Vec<proto::LanguageServer>,
+ pub remote_project_id: Option<RemoteProjectId>,
}
pub struct ProjectCollaborator {
@@ -786,8 +785,7 @@ impl ProjectCollaborator {
#[derive(Debug)]
pub struct LeftProject {
pub id: ProjectId,
- pub host_user_id: Option<UserId>,
- pub host_connection_id: Option<ConnectionId>,
+ pub should_unshare: bool,
pub connection_ids: Vec<ConnectionId>,
}
@@ -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,
@@ -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<Vec<dev_server::Model>> {
+ 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,
+ user_id: UserId,
+ ) -> crate::Result<proto::RemoteProjectsUpdate> {
+ self.transaction(
+ |tx| async move { self.remote_projects_update_internal(user_id, &tx).await },
+ )
+ .await
+ }
+
+ pub async fn remote_projects_update_internal(
&self,
- channel_ids: &Vec<ChannelId>,
+ user_id: UserId,
tx: &DatabaseTransaction,
- ) -> crate::Result<Vec<dev_server::Model>> {
- let servers = dev_server::Entity::find()
- .filter(dev_server::Column::ChannelId.is_in(channel_ids.iter().map(|id| id.0)))
+ ) -> crate::Result<proto::RemoteProjectsUpdate> {
+ let dev_servers = dev_server::Entity::find()
+ .filter(dev_server::Column::UserId.eq(user_id))
+ .all(tx)
+ .await?;
+
+ let remote_projects = remote_project::Entity::find()
+ .filter(
+ remote_project::Column::DevServerId
+ .is_in(dev_servers.iter().map(|d| d.id).collect::<Vec<_>>()),
+ )
+ .find_also_related(super::project::Entity)
.all(tx)
.await?;
- Ok(servers)
+
+ 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<proto::RemoteProjectsUpdate> {
+ 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
}
@@ -30,6 +30,7 @@ impl Database {
room_id: RoomId,
connection: ConnectionId,
worktrees: &[proto::WorktreeMetadata],
+ remote_project_id: Option<RemoteProjectId>,
) -> Result<TransactionGuard<(ProjectId, proto::Room)>> {
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<UserId>,
) -> Result<TransactionGuard<(Option<proto::Room>, Vec<ConnectionId>)>> {
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<ConnectionId> = 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,28 +976,45 @@ 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 {
+ 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 {
- return Err(anyhow!("not authorized to read projects"))?;
+ None
};
+ let role = role_from_remote_project
+ .or(role_from_room)
+ .unwrap_or(ChannelRole::Banned);
+
match capability {
Capability::ReadWrite => {
if !role.can_edit_projects() {
@@ -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<ChannelId>,
- tx: &DatabaseTransaction,
- ) -> crate::Result<Vec<proto::RemoteProject>> {
- 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<Vec<RemoteProjectId>> {
+ 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<UserId> {
+ 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<proto::RemoteProject> {
+ ) -> 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
}
@@ -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::<HashSet<_>>();
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
+ 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);
+ }
+ }
+ } 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(|project| project.id == db_project.id.to_proto())
+ .find(|(_, v)| v.user_id == host.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()
- };
+ let project = participant.projects.last_mut().unwrap();
- if let Some(db_worktree) = db_worktree {
- if db_worktree.visible {
- project.worktree_root_names.push(db_worktree.root_name);
+ 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();
@@ -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<super::remote_project::Entity> 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,
}
@@ -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<super::project::Entity> for Entity {
@@ -28,14 +32,18 @@ impl Related<super::project::Entity> for Entity {
}
}
+impl Related<super::dev_server::Entity> for Entity {
+ fn to() -> RelationDef {
+ Relation::DevServer.def()
+ }
+}
+
impl Model {
pub fn to_proto(&self, project: Option<project::Model>) -> 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(),
}
}
@@ -535,18 +535,18 @@ async fn test_project_count(db: &Arc<Database>) {
.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);
@@ -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<UserId>,
+ 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<proto::ShareRemoteProject>,
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::<Vec<_>>();
+ 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<proto::CreateRemoteProject>,
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<proto::DeleteDevServer>,
+ 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<proto::RejoinRemoteProjects>,
@@ -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<db::Channel>,
- 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(
@@ -13,6 +13,7 @@ pub struct ConnectionPool {
connected_users: BTreeMap<UserId, ConnectedPrincipal>,
connected_dev_servers: BTreeMap<DevServerId, ConnectionId>,
channels: ChannelPool,
+ offline_dev_servers: HashSet<DevServerId>,
}
#[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<Item = &Connection> {
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
@@ -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(),
@@ -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::<Editor>(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::<Editor>(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::<Editor>(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<AppState>,
+ cx: &mut TestAppContext,
+ cx_devserver: &mut TestAppContext,
+) -> (TestClient, WindowHandle<Workspace>) {
+ 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
+ ));
+}
@@ -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);
@@ -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();
});
@@ -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
@@ -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 {
@@ -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<Contact>,
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::<feature_flags::Remoting>() {
- 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<Self>,
- ) -> 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::<feature_flags::Remoting>(), |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<Self>) {
- 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<Self>) {
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<Self>) {
- 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<Self>) {
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,
@@ -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<ChannelStore>,
- channel_id: ChannelId,
- remote_project_name_editor: View<Editor>,
- remote_project_path_editor: View<Editor>,
- dev_server_name_editor: View<Editor>,
- _subscriptions: [gpui::Subscription; 2],
-}
-
-#[derive(Default)]
-struct CreateDevServer {
- creating: Option<Task<()>>,
- dev_server: Option<CreateDevServerResponse>,
-}
-
-struct CreateRemoteProject {
- dev_server_id: DevServerId,
- creating: Option<Task<()>>,
- remote_project: Option<proto::RemoteProject>,
-}
-
-enum Mode {
- Default,
- CreateRemoteProject(CreateRemoteProject),
- CreateDevServer(CreateDevServer),
-}
-
-impl DevServerModal {
- pub fn new(
- channel_store: Model<ChannelStore>,
- channel_id: ChannelId,
- cx: &mut ViewContext<Self>,
- ) -> 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<Self>,
- ) {
- 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<Self>) {
- 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<Self>) {
- 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<Self>,
- ) -> 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<Self>,
- ) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<DismissEvent> for DevServerModal {}
-
-impl Render for DevServerModal {
- fn render(&mut self, cx: &mut ViewContext<Self>) -> 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(),
- })
- }
-}
@@ -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<Self>) -> impl Element {
+ pub fn render_project_name(&self, cx: &mut ViewContext<Self>) -> 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<Self>) -> Option<impl Element> {
@@ -607,17 +622,6 @@ impl CollabTitlebarItem {
Some(view)
}
- pub fn render_project_popover(
- workspace: WeakView<Workspace>,
- cx: &mut WindowContext<'_>,
- ) -> View<RecentProjects> {
- 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,
@@ -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::<Self>();
editors.find(|editor| {
@@ -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<Client>,
app_state: AppState,
+ remote_shutdown: bool,
projects: HashMap<RemoteProjectId, Model<Project>>,
_subscriptions: Vec<client::Subscription>,
_maintain_connection: Task<Option<()>>,
@@ -35,6 +40,15 @@ pub fn init(client: Arc<Client>, 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::<WorktreeSettings>(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<Client>, 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<Self>) -> impl Future<Output = ()> {
- 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<Self>,
+ envelope: TypedEnvelope<proto::ValidateRemoteProjectRequest>,
+ _: Arc<Client>,
+ cx: AsyncAppContext,
+ ) -> Result<proto::Ack> {
+ 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<Self>,
+ _envelope: TypedEnvelope<proto::ShutdownDevServer>,
+ _: Arc<Client>,
+ 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,
@@ -11,6 +11,7 @@ pub struct HighlightedText {
pub text: String,
pub highlight_positions: Vec<usize>,
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)
}
}
@@ -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<PathBuf, PrettierInstance>,
tasks: Model<Inventory>,
hosted_project_id: Option<ProjectId>,
+ remote_project_id: Option<client::RemoteProjectId>,
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<RemoteProjectId> {
+ 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<Self>) -> 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,
}
}
@@ -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
@@ -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>) {
workspace.register_action(|workspace, open_recent: &OpenRecent, cx| {
let Some(recent_projects) = workspace.active_modal::<Self>(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<Workspace>,
- ) -> Option<Task<Result<()>>> {
- 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<Workspace>, cx: &mut WindowContext<'_>) -> View<Self> {
@@ -143,13 +151,14 @@ impl Render for RecentProjects {
pub struct RecentProjectsDelegate {
workspace: WeakView<Workspace>,
- workspaces: Vec<(WorkspaceId, WorkspaceLocation)>,
+ workspaces: Vec<(WorkspaceId, SerializedWorkspaceLocation)>,
selected_match_index: usize,
matches: Vec<StringMatch>,
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<DismissEvent> 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::<Vec<_>>()
- .join("");
+ let combined_string = match location {
+ SerializedWorkspaceLocation::Local(paths) => paths
+ .paths()
+ .iter()
+ .map(|path| path.compact().to_string_lossy().into_owned())
+ .collect::<Vec<_>>()
+ .join(""),
+ SerializedWorkspaceLocation::Remote(remote_project) => {
+ format!("{}{}", remote_project.dev_server_name, remote_project.path)
+ }
+ };
+
StringMatchCandidate::new(id, combined_string)
})
.collect::<Vec<_>>();
@@ -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<Picker<Self>>) {}
+ 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<Picker<Self>>) -> Option<AnyElement> {
+ if !cx.has_flag::<feature_flags::Remoting>() {
+ 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();
@@ -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_projects::Store>,
+ remote_project_path_input: View<TextField>,
+ dev_server_name_input: View<TextField>,
+ _subscription: gpui::Subscription,
+}
+
+#[derive(Default)]
+struct CreateDevServer {
+ creating: bool,
+ dev_server: Option<CreateDevServerResponse>,
+}
+
+struct CreateRemoteProject {
+ dev_server_id: DevServerId,
+ creating: bool,
+ remote_project: Option<proto::RemoteProject>,
+}
+
+enum Mode {
+ Default,
+ CreateRemoteProject(CreateRemoteProject),
+ CreateDevServer(CreateDevServer),
+}
+
+impl RemoteProjects {
+ pub fn register(_: &mut Workspace, cx: &mut ViewContext<Workspace>) {
+ cx.observe_flag::<feature_flags::Remoting, _>(|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>) -> 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<Self>,
+ ) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>) {
+ 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<Self>,
+ ) -> 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<Self>,
+ ) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<DismissEvent> for RemoteProjects {}
+
+impl Render for RemoteProjects {
+ fn render(&mut self, cx: &mut ViewContext<Self>) -> 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(),
+ })
+ }
+}
@@ -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
@@ -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<RemoteProjectId, RemoteProject>,
+ dev_servers: HashMap<DevServerId, DevServer>,
+ _subscriptions: Vec<client::Subscription>,
+ client: Arc<Client>,
+}
+
+#[derive(Debug, Clone)]
+pub struct RemoteProject {
+ pub id: RemoteProjectId,
+ pub project_id: Option<ProjectId>,
+ pub path: SharedString,
+ pub dev_server_id: DevServerId,
+}
+
+impl From<proto::RemoteProject> 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<proto::DevServer> 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<Store>);
+
+impl Global for GlobalStore {}
+
+pub fn init(client: Arc<Client>, 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<Store> {
+ cx.global::<GlobalStore>().0.clone()
+ }
+
+ pub fn new(client: Arc<Client>, cx: &ModelContext<Self>) -> 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<RemoteProject> {
+ let mut projects: Vec<RemoteProject> = 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<DevServer> {
+ let mut dev_servers: Vec<DevServer> = 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<RemoteProject> {
+ let mut projects: Vec<RemoteProject> = 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<Self>,
+ envelope: TypedEnvelope<proto::RemoteProjectsUpdate>,
+ _: Arc<Client>,
+ 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<Self>,
+ ) -> Task<Result<proto::CreateRemoteProjectResponse>> {
+ 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<Self>,
+ ) -> Task<Result<proto::CreateDevServerResponse>> {
+ 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<Self>,
+ ) -> Task<Result<()>> {
+ let client = self.client.clone();
+ cx.background_executor().spawn(async move {
+ client
+ .request(proto::DeleteDevServer {
+ dev_server_id: id.0,
+ })
+ .await?;
+ Ok(())
+ })
+ }
+}
@@ -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;
@@ -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!(
@@ -105,51 +105,50 @@ impl Connection {
let mut raw_statement = ptr::null_mut::<sqlite3_stmt>();
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 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();
-
- 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,
- )
- };
+ 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}({column})"))
+ .unwrap()()
+ .unwrap();
+
+ 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_finalize(raw_statement);
@@ -203,7 +202,7 @@ impl Connection {
}
}
-fn parse_alter_table(remaining_sql_str: &str) -> Option<String> {
+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<String> {
.take_while(|c| !c.is_whitespace())
.collect::<String>();
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::<String>()
+ } else {
+ "__place_holder_column_for_syntax_checking".to_string()
+ };
+ return Some((table_to_alter, column_name));
}
}
}
@@ -320,6 +320,7 @@ impl<'a> Statement<'a> {
this: &mut Statement,
callback: impl FnOnce(&mut Statement) -> Result<R>,
) -> Result<R> {
+ println!("{:?}", std::any::type_name::<R>());
if this.step()? != StepResult::Row {
return Err(anyhow!("single called with query that returns no rows."));
}
@@ -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)),
@@ -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>,
+ indicator_border_color: Option<Hsla>,
+}
+
+impl IconWithIndicator {
+ pub fn new(icon: Icon, indicator: Option<Indicator>) -> Self {
+ Self {
+ icon,
+ indicator,
+ indicator_border_color: None,
+ }
+ }
+
+ pub fn indicator(mut self, indicator: Option<Indicator>) -> 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<Hsla>) -> 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),
+ )
+ })
+ }
+}
@@ -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());
+ }),
+ )
+ })
}
}
@@ -44,6 +44,8 @@ pub struct TextField {
start_icon: Option<IconName>,
/// 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>) {
+ self.disabled = disabled;
+ self.editor
+ .update(cx, |editor, _| editor.set_read_only(disabled))
+ }
+
+ pub fn editor(&self) -> &View<Editor> {
+ &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(
@@ -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
@@ -513,8 +513,9 @@ impl<T: Item> ItemHandle for View<T> {
}));
}
- 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<T>, event, cx| {
let pane = if let Some(pane) = workspace
.panes_by_item
.get(&item.item_id())
@@ -575,7 +576,8 @@ impl<T: Item> ItemHandle for View<T> {
_ => {}
});
- }));
+ },
+ ));
cx.on_blur(&self.focus_handle(cx), move |workspace, cx| {
if WorkspaceSettings::get_global(cx).autosave == AutosaveSetting::OnFocusChange {
@@ -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<Vec<PathBuf>>,
+ // local_paths: Bincode<Vec<PathBuf>>,
// dock_visible: bool, // Deprecated
// dock_anchor: DockAnchor, // Deprecated
// dock_pane: Option<usize>, // 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<SerializedWorkspace> {
- 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<LocalPaths>,
+ Option<u64>,
Option<SerializedWindowsBounds>,
Option<Uuid>,
Option<bool>,
@@ -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")?;
-
- // 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")?;
+ 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,
+ 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<Vec<(WorkspaceId, WorkspaceLocation)>> {
- SELECT workspace_id, workspace_location
+ fn recent_workspaces() -> Result<Vec<(WorkspaceId, LocalPaths, Option<u64>)>> {
+ 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<Uuid>, Option<SerializedWindowsBounds>, Option<bool>)> {
- 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<Vec<SerializedRemoteProject>> {
+ SELECT remote_project_id, path, dev_server_name
+ FROM remote_projects
}
}
+ pub(crate) fn last_window(
+ &self,
+ ) -> anyhow::Result<(Option<Uuid>, Option<SerializedWindowsBounds>, Option<bool>)> {
+ let mut prepared_query =
+ self.select::<(Option<Uuid>, Option<SerializedWindowsBounds>, Option<bool>)>(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<Vec<(WorkspaceId, WorkspaceLocation)>> {
+ pub async fn recent_workspaces_on_disk(
+ &self,
+ ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation)>> {
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<Option<WorkspaceLocation>> {
+ pub async fn last_workspace(&self) -> Result<Option<LocalPaths>> {
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<SerializedPaneGroup> {
@@ -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(),
@@ -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<Vec<PathBuf>>);
+#[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<Vec<PathBuf>>);
+
+impl LocalPaths {
+ pub fn new<P: AsRef<Path>>(paths: impl IntoIterator<Item = P>) -> Self {
+ let mut paths: Vec<PathBuf> = 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<Vec<PathBuf>> {
self.0.clone()
}
+}
- #[cfg(any(test, feature = "test-support"))]
- pub fn new<P: AsRef<Path>>(paths: Vec<P>) -> Self {
- Self(Arc::new(
- paths
- .into_iter()
- .map(|p| p.as_ref().to_path_buf())
- .collect(),
- ))
+impl From<LocalPaths> for SerializedWorkspaceLocation {
+ fn from(local_paths: LocalPaths) -> Self {
+ Self::Local(local_paths)
}
}
-impl<P: AsRef<Path>, T: IntoIterator<Item = P>> From<T> for WorkspaceLocation {
- fn from(iterator: T) -> Self {
- let mut roots = iterator
- .into_iter()
- .map(|p| p.as_ref().to_path_buf())
- .collect::<Vec<_>>();
- roots.sort();
- Self(Arc::new(roots))
+impl StaticColumnCount for LocalPaths {}
+impl Bind for &LocalPaths {
+ fn bind(&self, statement: &Statement, start_index: i32) -> Result<i32> {
+ statement.bind(&bincode::serialize(&self.0)?, start_index)
}
}
-impl StaticColumnCount for WorkspaceLocation {}
-impl Bind for &WorkspaceLocation {
+impl Column for LocalPaths {
+ fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
+ let path_blob = statement.column_blob(start_index)?;
+ let paths: Arc<Vec<PathBuf>> = 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<SerializedRemoteProject> 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<i32> {
- bincode::serialize(&self.0)
- .expect("Bincode serialization of paths should not fail")
- .bind(statement, start_index)
+ 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 WorkspaceLocation {
+impl Column for SerializedRemoteProject {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
- let blob = statement.column_blob(start_index)?;
+ 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<Bounds<DevicePixels>>,
pub(crate) fullscreen: bool,
@@ -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<WorkspaceLocation> {
+ fn local_paths(&self, cx: &AppContext) -> Option<LocalPaths> {
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::<Vec<_>>()
- .into(),
- )
+ .collect::<Vec<_>>(),
+ ))
} 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<WorkspaceLocation> {
+pub async fn last_opened_workspace_paths() -> Option<LocalPaths> {
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()
@@ -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
@@ -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);
@@ -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",