remote projects per user (#10594)

Release Notes:

- Made remote projects per-user instead of per-channel. If you'd like to
be part of the remote development alpha, please email hi@zed.dev.

---------

Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com>
Co-authored-by: Bennet <bennetbo@gmx.de>
Co-authored-by: Nate Butler <1714999+iamnbutler@users.noreply.github.com>
Co-authored-by: Nate Butler <iamnbutler@gmail.com>
This commit is contained in:
Conrad Irwin 2024-04-23 15:33:09 -06:00 committed by GitHub
parent 8ae4c3277f
commit e0c83a1d32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
56 changed files with 2807 additions and 1625 deletions

23
Cargo.lock generated
View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 692 B

After

Width:  |  Height:  |  Size: 413 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
channel_ids: &Vec<ChannelId>,
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,
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?;
Ok(servers)
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(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
}

View File

@ -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,27 +976,44 @@ impl Database {
PrincipalId::UserId(user_id) => user_id,
};
let role = if let Some(remote_project) = remote_project {
let channel = channel::Entity::find_by_id(remote_project.channel_id)
.one(tx)
.await?
.ok_or_else(|| anyhow!("no such channel"))?;
self.check_user_is_channel_participant(&channel, user_id, &tx)
.await?
} else if let Some(room_id) = project.room_id {
// what's the users role?
let current_participant = room_participant::Entity::find()
let role_from_room = if let Some(room_id) = project.room_id {
room_participant::Entity::find()
.filter(room_participant::Column::RoomId.eq(room_id))
.filter(room_participant::Column::AnsweringConnectionId.eq(connection_id.id))
.one(tx)
.await?
.ok_or_else(|| anyhow!("no such room"))?;
current_participant.role.unwrap_or(ChannelRole::Guest)
.and_then(|participant| participant.role)
} else {
return Err(anyhow!("not authorized to read projects"))?;
None
};
let role_from_remote_project = if let Some(remote_project) = remote_project {
let dev_server = dev_server::Entity::find_by_id(remote_project.dev_server_id)
.one(tx)
.await?
.ok_or_else(|| anyhow!("no such channel"))?;
if user_id == dev_server.user_id {
// If the user left the room "uncleanly" they may rejoin the
// remote project before leave_room runs. IN that case kick
// the project out of the room pre-emptively.
if role_from_room.is_none() {
project = project::Entity::update(project::ActiveModel {
room_id: ActiveValue::Set(None),
..project.into_active_model()
})
.exec(tx)
.await?;
}
Some(ChannelRole::Admin)
} else {
None
}
} else {
None
};
let role = role_from_remote_project
.or(role_from_room)
.unwrap_or(ChannelRole::Banned);
match capability {
Capability::ReadWrite => {

View File

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

View File

@ -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
.iter_mut()
.find(|project| project.id == db_project.id.to_proto())
{
project
} else {
participant.projects.push(proto::ParticipantProject {
id: db_project.id.to_proto(),
worktree_root_names: Default::default(),
});
participant.projects.last_mut().unwrap()
};
participant.projects.push(proto::ParticipantProject {
id: db_project.id.to_proto(),
worktree_root_names: Default::default(),
});
let project = participant.projects.last_mut().unwrap();
if let Some(db_worktree) = db_worktree {
for db_worktree in db_worktrees {
if db_worktree.visible {
project.worktree_root_names.push(db_worktree.root_name);
}
}
} else if let Some(remote_project_id) = db_project.remote_project_id {
let host = self.owner_for_remote_project(remote_project_id, tx).await?;
if let Some((_, participant)) = participants
.iter_mut()
.find(|(_, v)| v.user_id == host.to_proto())
{
participant.projects.push(proto::ParticipantProject {
id: db_project.id.to_proto(),
worktree_root_names: Default::default(),
});
let project = participant.projects.last_mut().unwrap();
for db_worktree in db_worktrees {
if db_worktree.visible {
project.worktree_root_names.push(db_worktree.root_name);
}
}
}
}
}
drop(db_projects);
let mut db_followers = db_room.find_related(follower::Entity).stream(tx).await?;
let mut followers = Vec::new();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 (res, offset, message, _conn) =
if let Some((table_to_alter, column)) = alter_table {
// ALTER TABLE is a weird statement. When preparing the statement the table's
// existence is checked *before* syntax checking any other part of the statement.
// Therefore, we need to make sure that the table has been created before calling
// prepare. As we don't want to trash whatever database this is connected to, we
// create a new in-memory DB to test.
let temp_connection = Connection::open_memory(None);
//This should always succeed, if it doesn't then you really should know about it
temp_connection
.exec(&format!(
"CREATE TABLE {table_to_alter}(__place_holder_column_for_syntax_checking)"
))
.unwrap()()
.unwrap();
let temp_connection = Connection::open_memory(None);
//This should always succeed, if it doesn't then you really should know about it
temp_connection
.exec(&format!("CREATE TABLE {table_to_alter}({column})"))
.unwrap()()
.unwrap();
sqlite3_prepare_v2(
temp_connection.sqlite3,
remaining_sql.as_ptr(),
-1,
&mut raw_statement,
&mut remaining_sql_ptr,
);
sqlite3_prepare_v2(
temp_connection.sqlite3,
remaining_sql.as_ptr(),
-1,
&mut raw_statement,
&mut remaining_sql_ptr,
);
(
sqlite3_errcode(temp_connection.sqlite3),
sqlite3_error_offset(temp_connection.sqlite3),
sqlite3_errmsg(temp_connection.sqlite3),
Some(temp_connection),
)
} else {
sqlite3_prepare_v2(
self.sqlite3,
remaining_sql.as_ptr(),
-1,
&mut raw_statement,
&mut remaining_sql_ptr,
);
(
sqlite3_errcode(self.sqlite3),
sqlite3_error_offset(self.sqlite3),
sqlite3_errmsg(self.sqlite3),
None,
)
};
(
sqlite3_errcode(temp_connection.sqlite3),
sqlite3_error_offset(temp_connection.sqlite3),
sqlite3_errmsg(temp_connection.sqlite3),
Some(temp_connection),
)
} else {
sqlite3_prepare_v2(
self.sqlite3,
remaining_sql.as_ptr(),
-1,
&mut raw_statement,
&mut remaining_sql_ptr,
);
(
sqlite3_errcode(self.sqlite3),
sqlite3_error_offset(self.sqlite3),
sqlite3_errmsg(self.sqlite3),
None,
)
};
sqlite3_finalize(raw_statement);
@ -203,7 +202,7 @@ impl Connection {
}
}
fn parse_alter_table(remaining_sql_str: &str) -> Option<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));
}
}
}

View File

@ -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."));
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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")?;
match workspace.location {
SerializedWorkspaceLocation::Local(local_paths) => {
conn.exec_bound(sql!(
DELETE FROM workspaces WHERE local_paths = ? AND workspace_id != ?
))?((&local_paths, workspace.id))
.context("clearing out old locations")?;
// Upsert
conn.exec_bound(sql!(
INSERT INTO workspaces(
workspace_id,
workspace_location,
left_dock_visible,
left_dock_active_panel,
left_dock_zoom,
right_dock_visible,
right_dock_active_panel,
right_dock_zoom,
bottom_dock_visible,
bottom_dock_active_panel,
bottom_dock_zoom,
timestamp
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP)
ON CONFLICT DO
UPDATE SET
workspace_location = ?2,
left_dock_visible = ?3,
left_dock_active_panel = ?4,
left_dock_zoom = ?5,
right_dock_visible = ?6,
right_dock_active_panel = ?7,
right_dock_zoom = ?8,
bottom_dock_visible = ?9,
bottom_dock_active_panel = ?10,
bottom_dock_zoom = ?11,
timestamp = CURRENT_TIMESTAMP
))?((workspace.id, &workspace.location, workspace.docks))
.context("Updating workspace")?;
// Upsert
conn.exec_bound(sql!(
INSERT INTO workspaces(
workspace_id,
local_paths,
left_dock_visible,
left_dock_active_panel,
left_dock_zoom,
right_dock_visible,
right_dock_active_panel,
right_dock_zoom,
bottom_dock_visible,
bottom_dock_active_panel,
bottom_dock_zoom,
timestamp
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP)
ON CONFLICT DO
UPDATE SET
local_paths = ?2,
left_dock_visible = ?3,
left_dock_active_panel = ?4,
left_dock_zoom = ?5,
right_dock_visible = ?6,
right_dock_active_panel = ?7,
right_dock_zoom = ?8,
bottom_dock_visible = ?9,
bottom_dock_active_panel = ?10,
bottom_dock_zoom = ?11,
timestamp = CURRENT_TIMESTAMP
))?((workspace.id, &local_paths, workspace.docks))
.context("Updating workspace")?;
}
SerializedWorkspaceLocation::Remote(remote_project) => {
conn.exec_bound(sql!(
DELETE FROM workspaces WHERE remote_project_id = ? AND workspace_id != ?
))?((remote_project.id.0, workspace.id))
.context("clearing out old locations")?;
conn.exec_bound(sql!(
INSERT INTO remote_projects(
remote_project_id,
path,
dev_server_name
) VALUES (?1, ?2, ?3)
ON CONFLICT DO
UPDATE SET
path = ?2,
dev_server_name = ?3
))?(&remote_project)?;
// Upsert
conn.exec_bound(sql!(
INSERT INTO workspaces(
workspace_id,
remote_project_id,
left_dock_visible,
left_dock_active_panel,
left_dock_zoom,
right_dock_visible,
right_dock_active_panel,
right_dock_zoom,
bottom_dock_visible,
bottom_dock_active_panel,
bottom_dock_zoom,
timestamp
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, CURRENT_TIMESTAMP)
ON CONFLICT DO
UPDATE SET
remote_project_id = ?2,
left_dock_visible = ?3,
left_dock_active_panel = ?4,
left_dock_zoom = ?5,
right_dock_visible = ?6,
right_dock_active_panel = ?7,
right_dock_zoom = ?8,
bottom_dock_visible = ?9,
bottom_dock_active_panel = ?10,
bottom_dock_zoom = ?11,
timestamp = CURRENT_TIMESTAMP
))?((
workspace.id,
remote_project.id.0,
workspace.docks,
))
.context("Updating workspace")?;
}
}
// Save center pane group
Self::save_pane_group(conn, workspace.id, &workspace.center_group, None)
@ -424,24 +522,43 @@ impl WorkspaceDb {
}
query! {
fn recent_workspaces() -> Result<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(),

View File

@ -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 WorkspaceLocation {}
impl Bind for &WorkspaceLocation {
impl StaticColumnCount for LocalPaths {}
impl Bind for &LocalPaths {
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)
statement.bind(&bincode::serialize(&self.0)?, start_index)
}
}
impl Column for WorkspaceLocation {
impl Column for LocalPaths {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let blob = statement.column_blob(start_index)?;
let path_blob = statement.column_blob(start_index)?;
let paths: Arc<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> {
let next_index = statement.bind(&self.id.0, start_index)?;
let next_index = statement.bind(&self.dev_server_name, next_index)?;
statement.bind(&self.path, next_index)
}
}
impl Column for SerializedRemoteProject {
fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
let id = statement.column_int64(start_index)?;
let dev_server_name = statement.column_text(start_index + 1)?.to_string();
let path = statement.column_text(start_index + 2)?.to_string();
Ok((
WorkspaceLocation(bincode::deserialize(blob).context("Bincode failed")?),
start_index + 1,
Self {
id: RemoteProjectId(id as u64),
dev_server_name,
path,
},
start_index + 3,
))
}
}
#[derive(Debug, PartialEq, Clone)]
pub enum SerializedWorkspaceLocation {
Local(LocalPaths),
Remote(SerializedRemoteProject),
}
#[derive(Debug, PartialEq, Clone)]
pub(crate) struct SerializedWorkspace {
pub(crate) id: WorkspaceId,
pub(crate) location: WorkspaceLocation,
pub(crate) location: SerializedWorkspaceLocation,
pub(crate) center_group: SerializedPaneGroup,
pub(crate) bounds: Option<Bounds<DevicePixels>>,
pub(crate) fullscreen: bool,

View File

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

View File

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

View File

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

View File

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