From 62ab6e1a11cef2d8f118cebf1e883f0cd14368b3 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 16 Jul 2024 12:01:59 -0600 Subject: [PATCH] remoting: Allow Add/Remove remote folder (#14532) Release Notes: - remoting (alpha only): Allow add/remove folders to projects --------- Co-authored-by: Max --- .../20221109000000_test_schema.sql | 2 +- ...0_add_worktrees_to_dev_server_projects.sql | 4 + .../src/db/queries/dev_server_projects.rs | 45 ++- crates/collab/src/db/queries/dev_servers.rs | 22 ++ .../src/db/tables/dev_server_project.rs | 15 +- crates/collab/src/rpc.rs | 65 +++++ crates/collab/src/rpc/connection_pool.rs | 16 ++ crates/collab/src/tests/dev_server_tests.rs | 4 +- crates/collab/src/tests/integration_tests.rs | 8 +- .../random_project_collaboration_tests.rs | 2 +- crates/collab/src/tests/test_server.rs | 4 +- .../src/dev_server_projects.rs | 8 +- crates/editor/src/items.rs | 2 +- .../src/test/editor_lsp_test_context.rs | 2 +- crates/extensions_ui/src/extensions_ui.rs | 17 +- crates/file_finder/src/file_finder.rs | 2 +- crates/file_finder/src/file_finder_tests.rs | 4 +- crates/file_finder/src/open_path_prompt.rs | 50 ++-- crates/headless/src/headless.rs | 145 +++++++--- crates/project/src/project.rs | 261 +++++++++++++----- crates/project/src/project_tests.rs | 4 +- crates/project/src/terminals.rs | 8 +- crates/project_panel/src/project_panel.rs | 17 +- crates/proto/proto/zed.proto | 20 +- crates/proto/src/proto.rs | 5 + crates/recent_projects/src/dev_servers.rs | 21 +- crates/recent_projects/src/recent_projects.rs | 22 +- crates/terminal_view/src/terminal_view.rs | 2 +- crates/workspace/src/persistence/model.rs | 16 +- crates/workspace/src/workspace.rs | 46 +-- crates/zed/src/zed.rs | 2 +- docs/src/remote-development.md | 12 +- 32 files changed, 613 insertions(+), 240 deletions(-) create mode 100644 crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 45c424ea224130cd7a2cbcf2dfe5fd2ba37fb939..8e3068645cc34ecfc915c810e1796351635253e7 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -414,5 +414,5 @@ CREATE TABLE dev_servers ( CREATE TABLE dev_server_projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, dev_server_id INTEGER NOT NULL REFERENCES dev_servers(id), - path TEXT NOT NULL + paths TEXT NOT NULL ); diff --git a/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql b/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql new file mode 100644 index 0000000000000000000000000000000000000000..675df4885bb531722cf80a76bf93eac58add5b8c --- /dev/null +++ b/crates/collab/migrations/20240715230940_add_worktrees_to_dev_server_projects.sql @@ -0,0 +1,4 @@ +ALTER TABLE dev_server_projects ADD COLUMN paths JSONB NULL; +UPDATE dev_server_projects SET paths = to_json(ARRAY[path]); +ALTER TABLE dev_server_projects ALTER COLUMN paths SET NOT NULL; +ALTER TABLE dev_server_projects ALTER COLUMN path DROP NOT NULL; diff --git a/crates/collab/src/db/queries/dev_server_projects.rs b/crates/collab/src/db/queries/dev_server_projects.rs index 3c71693a530b9799a66883ca50e4cc2556e84482..9312811335a126dad08e97cfdcf24afccfad61dc 100644 --- a/crates/collab/src/db/queries/dev_server_projects.rs +++ b/crates/collab/src/db/queries/dev_server_projects.rs @@ -5,7 +5,7 @@ use rpc::{ }; use sea_orm::{ ActiveModelTrait, ActiveValue, ColumnTrait, Condition, DatabaseTransaction, EntityTrait, - ModelTrait, QueryFilter, + IntoActiveModel, ModelTrait, QueryFilter, }; use crate::db::ProjectId; @@ -56,12 +56,7 @@ impl Database { .await?; Ok(servers .into_iter() - .map(|(dev_server_project, project)| proto::DevServerProject { - id: dev_server_project.id.to_proto(), - project_id: project.map(|p| p.id.to_proto()), - dev_server_id: dev_server_project.dev_server_id.to_proto(), - path: dev_server_project.path, - }) + .map(|(dev_server_project, project)| dev_server_project.to_proto(project)) .collect()) } @@ -134,7 +129,7 @@ impl Database { let project = dev_server_project::Entity::insert(dev_server_project::ActiveModel { id: ActiveValue::NotSet, dev_server_id: ActiveValue::Set(dev_server_id), - path: ActiveValue::Set(path.to_string()), + paths: ActiveValue::Set(dev_server_project::JSONPaths(vec![path.to_string()])), }) .exec_with_returning(&*tx) .await?; @@ -148,6 +143,38 @@ impl Database { .await } + pub async fn update_dev_server_project( + &self, + id: DevServerProjectId, + paths: &Vec, + user_id: UserId, + ) -> crate::Result<(dev_server_project::Model, proto::DevServerProjectsUpdate)> { + self.transaction(move |tx| async move { + let paths = paths.clone(); + let Some((project, Some(dev_server))) = dev_server_project::Entity::find_by_id(id) + .find_also_related(dev_server::Entity) + .one(&*tx) + .await? + else { + return Err(anyhow!("no such dev server project"))?; + }; + + if dev_server.user_id != user_id { + return Err(anyhow!("not your dev server"))?; + } + let mut project = project.into_active_model(); + project.paths = ActiveValue::Set(dev_server_project::JSONPaths(paths)); + let project = project.update(&*tx).await?; + + let status = self + .dev_server_projects_update_internal(user_id, &tx) + .await?; + + Ok((project, status)) + }) + .await + } + pub async fn delete_dev_server_project( &self, dev_server_project_id: DevServerProjectId, @@ -258,7 +285,6 @@ impl Database { dev_server_id: DevServerId, connection: ConnectionId, ) -> crate::Result> { - // todo!() project_transaction? (maybe we can make the lock per-dev-server instead of per-project?) self.transaction(|tx| async move { let mut ret = Vec::new(); for reshared_project in reshared_projects { @@ -322,7 +348,6 @@ impl Database { user_id: UserId, connection_id: ConnectionId, ) -> crate::Result> { - // todo!() project_transaction? (maybe we can make the lock per-dev-server instead of per-project?) self.transaction(|tx| async move { let mut ret = Vec::new(); for rejoined_project in rejoined_projects { diff --git a/crates/collab/src/db/queries/dev_servers.rs b/crates/collab/src/db/queries/dev_servers.rs index fd5bcc8c478102d64184e93a104aade4b2051379..16cbfedee33e504130f77195012af976bf3d7435 100644 --- a/crates/collab/src/db/queries/dev_servers.rs +++ b/crates/collab/src/db/queries/dev_servers.rs @@ -19,6 +19,28 @@ impl Database { .await } + pub async fn get_dev_server_for_user( + &self, + dev_server_id: DevServerId, + user_id: UserId, + ) -> crate::Result { + self.transaction(|tx| async move { + let server = dev_server::Entity::find_by_id(dev_server_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow::anyhow!("no dev server with id {}", dev_server_id))?; + if server.user_id != user_id { + return Err(anyhow::anyhow!( + "dev server {} is not owned by user {}", + dev_server_id, + user_id + ))?; + } + Ok(server) + }) + .await + } + pub async fn get_dev_servers(&self, user_id: UserId) -> crate::Result> { self.transaction(|tx| async move { Ok(dev_server::Entity::find() diff --git a/crates/collab/src/db/tables/dev_server_project.rs b/crates/collab/src/db/tables/dev_server_project.rs index bf90d7092d5879f164443a0b5d7963cd1ef3858e..d3c2da63491fb58364718842621374d5993090f6 100644 --- a/crates/collab/src/db/tables/dev_server_project.rs +++ b/crates/collab/src/db/tables/dev_server_project.rs @@ -1,7 +1,8 @@ use super::project; use crate::db::{DevServerId, DevServerProjectId}; use rpc::proto; -use sea_orm::entity::prelude::*; +use sea_orm::{entity::prelude::*, FromJsonQueryResult}; +use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "dev_server_projects")] @@ -9,9 +10,12 @@ pub struct Model { #[sea_orm(primary_key)] pub id: DevServerProjectId, pub dev_server_id: DevServerId, - pub path: String, + pub paths: JSONPaths, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, FromJsonQueryResult)] +pub struct JSONPaths(pub Vec); + impl ActiveModelBehavior for ActiveModel {} #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] @@ -44,7 +48,12 @@ impl Model { id: self.id.to_proto(), project_id: project.map(|p| p.id.to_proto()), dev_server_id: self.dev_server_id.to_proto(), - path: self.path.clone(), + path: self.paths().get(0).cloned().unwrap_or_default(), + paths: self.paths().clone(), } } + + pub fn paths(&self) -> &Vec { + &self.paths.0 + } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 42e5c7e94ff0b24a60bac91535954796e730e28a..d9113898084a26c7399b984142562ed3ceb66221 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -431,11 +431,13 @@ impl Server { .add_request_handler(user_handler(join_hosted_project)) .add_request_handler(user_handler(rejoin_dev_server_projects)) .add_request_handler(user_handler(create_dev_server_project)) + .add_request_handler(user_handler(update_dev_server_project)) .add_request_handler(user_handler(delete_dev_server_project)) .add_request_handler(user_handler(create_dev_server)) .add_request_handler(user_handler(regenerate_dev_server_token)) .add_request_handler(user_handler(rename_dev_server)) .add_request_handler(user_handler(delete_dev_server)) + .add_request_handler(user_handler(list_remote_directory)) .add_request_handler(dev_server_handler(share_dev_server_project)) .add_request_handler(dev_server_handler(shutdown_dev_server)) .add_request_handler(dev_server_handler(reconnect_dev_server)) @@ -2313,6 +2315,69 @@ async fn join_hosted_project( join_project_internal(response, session, &mut project, &replica_id) } +async fn list_remote_directory( + request: proto::ListRemoteDirectory, + response: Response, + session: UserSession, +) -> Result<()> { + let dev_server_id = DevServerId(request.dev_server_id as i32); + let dev_server_connection_id = session + .connection_pool() + .await + .dev_server_connection_id_supporting(dev_server_id, ZedVersion::with_list_directory())?; + + session + .db() + .await + .get_dev_server_for_user(dev_server_id, session.user_id()) + .await?; + + response.send( + session + .peer + .forward_request(session.connection_id, dev_server_connection_id, request) + .await?, + )?; + Ok(()) +} + +async fn update_dev_server_project( + request: proto::UpdateDevServerProject, + response: Response, + session: UserSession, +) -> Result<()> { + let dev_server_project_id = DevServerProjectId(request.dev_server_project_id as i32); + + let (dev_server_project, update) = session + .db() + .await + .update_dev_server_project(dev_server_project_id, &request.paths, session.user_id()) + .await?; + + let projects = session + .db() + .await + .get_projects_for_dev_server(dev_server_project.dev_server_id) + .await?; + + let dev_server_connection_id = session + .connection_pool() + .await + .dev_server_connection_id_supporting( + dev_server_project.dev_server_id, + ZedVersion::with_list_directory(), + )?; + + session.peer.send( + dev_server_connection_id, + proto::DevServerInstructions { projects }, + )?; + + send_dev_server_projects_update(session.user_id(), update, &session).await; + + response.send(proto::Ack {}) +} + async fn create_dev_server_project( request: proto::CreateDevServerProject, response: Response, diff --git a/crates/collab/src/rpc/connection_pool.rs b/crates/collab/src/rpc/connection_pool.rs index c8d4c3ca81f674272a098bb0adbe4df9dce0f08c..6474b95f55e36a910f1bf195fb75f5087424cee7 100644 --- a/crates/collab/src/rpc/connection_pool.rs +++ b/crates/collab/src/rpc/connection_pool.rs @@ -38,6 +38,10 @@ impl ZedVersion { pub fn with_save_as() -> ZedVersion { ZedVersion(SemanticVersion::new(0, 134, 0)) } + + pub fn with_list_directory() -> ZedVersion { + ZedVersion(SemanticVersion::new(0, 145, 0)) + } } pub trait VersionedMessage { @@ -187,6 +191,18 @@ impl ConnectionPool { self.connected_dev_servers.get(&dev_server_id).copied() } + pub fn dev_server_connection_id_supporting( + &self, + dev_server_id: DevServerId, + required: ZedVersion, + ) -> Result { + match self.connected_dev_servers.get(&dev_server_id) { + Some(cid) if self.connections[cid].zed_version >= required => Ok(*cid), + Some(_) => Err(anyhow!(proto::ErrorCode::RemoteUpgradeRequired)), + None => Err(anyhow!(proto::ErrorCode::DevServerOffline)), + } + } + pub fn channel_user_ids( &self, channel_id: ChannelId, diff --git a/crates/collab/src/tests/dev_server_tests.rs b/crates/collab/src/tests/dev_server_tests.rs index f24e51a5cc96219c3444866cece3ae6476ef6ae0..d7576442de5430ca621dfd79a4c6868818afdf0d 100644 --- a/crates/collab/src/tests/dev_server_tests.rs +++ b/crates/collab/src/tests/dev_server_tests.rs @@ -66,7 +66,7 @@ async fn test_dev_server(cx: &mut gpui::TestAppContext, cx2: &mut gpui::TestAppC .update(cx, |store, cx| { let projects = store.dev_server_projects(); assert_eq!(projects.len(), 1); - assert_eq!(projects[0].path, "/remote"); + assert_eq!(projects[0].paths, vec!["/remote"]); workspace::join_dev_server_project( projects[0].id, projects[0].project_id.unwrap(), @@ -206,7 +206,7 @@ async fn create_dev_server_project( .update(cx, |store, cx| { let projects = store.dev_server_projects(); assert_eq!(projects.len(), 1); - assert_eq!(projects[0].path, "/remote"); + assert_eq!(projects[0].paths, vec!["/remote"]); workspace::join_dev_server_project( projects[0].id, projects[0].project_id.unwrap(), diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 01d5d1b8aceca77e1fae9f49181f5fcad0fbac70..f98cb5de0e1adc874a2f7302837ae0765517b373 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -1528,7 +1528,7 @@ async fn test_project_reconnect( }); let (worktree_a2, _) = project_a1 .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1/dir2", true, cx) + p.find_or_create_worktree("/root-1/dir2", true, cx) }) .await .unwrap(); @@ -1601,7 +1601,7 @@ async fn test_project_reconnect( }); let (worktree_a3, _) = project_a1 .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1/dir3", true, cx) + p.find_or_create_worktree("/root-1/dir3", true, cx) }) .await .unwrap(); @@ -1725,7 +1725,7 @@ async fn test_project_reconnect( // While client B is disconnected, add and remove worktrees from client A's project. let (worktree_a4, _) = project_a1 .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root-1/dir4", true, cx) + p.find_or_create_worktree("/root-1/dir4", true, cx) }) .await .unwrap(); @@ -4887,7 +4887,7 @@ async fn test_project_search( let (project_a, _) = client_a.build_local_project("/root/dir-1", cx_a).await; let (worktree_2, _) = project_a .update(cx_a, |p, cx| { - p.find_or_create_local_worktree("/root/dir-2", true, cx) + p.find_or_create_worktree("/root/dir-2", true, cx) }) .await .unwrap(); diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 97dd93e138ed2639ec560ab31ea21e038605a8d7..70721dea69d5df42252ed6af8bf3532eae54cedc 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -581,7 +581,7 @@ impl RandomizedTest for ProjectCollaborationTest { } project .update(cx, |project, cx| { - project.find_or_create_local_worktree(&new_root_path, true, cx) + project.find_or_create_worktree(&new_root_path, true, cx) }) .await .unwrap(); diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index e9e6ade1a1579b2efed2460828e0fddc13cfe5c8..3ef511fb04b6846d160c4b6c05240fafdc7be413 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -805,9 +805,7 @@ impl TestClient { ) -> (Model, WorktreeId) { let project = self.build_empty_local_project(cx); let (worktree, _) = project - .update(cx, |p, cx| { - p.find_or_create_local_worktree(root_path, true, cx) - }) + .update(cx, |p, cx| p.find_or_create_worktree(root_path, true, cx)) .await .unwrap(); worktree diff --git a/crates/dev_server_projects/src/dev_server_projects.rs b/crates/dev_server_projects/src/dev_server_projects.rs index 792d20df483ea48fb74150e955bcb864411c3f7e..d5e4b34039b61cbf7a3d5150b7d578389b6cb728 100644 --- a/crates/dev_server_projects/src/dev_server_projects.rs +++ b/crates/dev_server_projects/src/dev_server_projects.rs @@ -20,7 +20,7 @@ pub struct Store { pub struct DevServerProject { pub id: DevServerProjectId, pub project_id: Option, - pub path: SharedString, + pub paths: Vec, pub dev_server_id: DevServerId, } @@ -29,7 +29,7 @@ impl From for DevServerProject { Self { id: DevServerProjectId(project.id), project_id: project.project_id.map(|id| ProjectId(id)), - path: project.path.into(), + paths: project.paths.into_iter().map(|path| path.into()).collect(), dev_server_id: DevServerId(project.dev_server_id), } } @@ -85,7 +85,7 @@ impl Store { .filter(|project| project.dev_server_id == id) .cloned() .collect(); - projects.sort_by_key(|p| (p.path.clone(), p.id)); + projects.sort_by_key(|p| (p.paths.clone(), p.id)); projects } @@ -108,7 +108,7 @@ impl Store { pub fn dev_server_projects(&self) -> Vec { let mut projects: Vec = self.dev_server_projects.values().cloned().collect(); - projects.sort_by_key(|p| (p.path.clone(), p.id)); + projects.sort_by_key(|p| (p.paths.clone(), p.id)); projects } diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 57cf5c756033b9579bb16a7dce22e3dec95c9aa2..b923ec36e8a563fc1bcd066986262f7be694cbd4 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -935,7 +935,7 @@ impl Item for Editor { .context("No path stored for this editor")?; let (worktree, path) = project - .find_local_worktree(&path, cx) + .find_worktree(&path, cx) .with_context(|| format!("No worktree for path: {path:?}"))?; let project_path = ProjectPath { worktree_id: worktree.read(cx).id(), diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index f1f8fa676b03feb9f483bd9d4ae44ed942299eee..362bc8354b1f843e180489ee055110c335c978e1 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -79,7 +79,7 @@ impl EditorLspTestContext { let mut cx = VisualTestContext::from_window(*window.deref(), cx); project .update(&mut cx, |project, cx| { - project.find_or_create_local_worktree("/root", true, cx) + project.find_or_create_worktree("/root", true, cx) }) .await .unwrap(); diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 7af2f128d5d29bdf843d092b41722b51eb06de4f..2c90831677ee620fa8c90ee87920d23f17d9a009 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -19,6 +19,7 @@ use gpui::{ UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WhiteSpace, WindowContext, }; use num_format::{Locale, ToFormattedString}; +use project::DirectoryLister; use release_channel::ReleaseChannel; use settings::Settings; use theme::ThemeSettings; @@ -54,13 +55,17 @@ pub fn init(cx: &mut AppContext) { workspace.add_item_to_active_pane(Box::new(extensions_page), None, cx) } }) - .register_action(move |_, _: &InstallDevExtension, cx| { + .register_action(move |workspace, _: &InstallDevExtension, cx| { let store = ExtensionStore::global(cx); - let prompt = cx.prompt_for_paths(gpui::PathPromptOptions { - files: false, - directories: true, - multiple: false, - }); + let prompt = workspace.prompt_for_open_path( + gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + }, + DirectoryLister::Local(workspace.app_state().fs.clone()), + cx, + ); let workspace_handle = cx.view().downgrade(); cx.deref_mut() diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 2165fa9152344085fb9dafee4a2676175caf4a16..74337c18d8139c0263030cea802647d3608727de 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -679,7 +679,7 @@ impl FileFinderDelegate { let update_result = project .update(&mut cx, |project, cx| { if let Some((worktree, relative_path)) = - project.find_local_worktree(query_path, cx) + project.find_worktree(query_path, cx) { path_matches.push(ProjectPanelOrdMatch(PathMatch { score: 1.0, diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 27d2d6ccf3c74c6b07b6b8ca3fa276ca19c894c3..dffbdcc342de21acc74bbcb10f46894479604e0d 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -767,7 +767,7 @@ async fn test_external_files_history(cx: &mut gpui::TestAppContext) { let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await; cx.update(|cx| { project.update(cx, |project, cx| { - project.find_or_create_local_worktree("/external-src", false, cx) + project.find_or_create_worktree("/external-src", false, cx) }) }) .detach(); @@ -1513,7 +1513,7 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees( project .update(cx, |project, cx| { project - .find_or_create_local_worktree("/test/project_2", true, cx) + .find_or_create_worktree("/test/project_2", true, cx) .into_future() }) .await diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 75557c7fc5395d666405d9f570686666e4690409..97834c72c453e608138ee5b4de9934f781344344 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -1,8 +1,7 @@ use futures::channel::oneshot; use fuzzy::StringMatchCandidate; -use gpui::Model; use picker::{Picker, PickerDelegate}; -use project::{compare_paths, Project}; +use project::{compare_paths, DirectoryLister}; use std::{ path::{Path, PathBuf}, sync::{ @@ -19,7 +18,7 @@ pub(crate) struct OpenPathPrompt; pub struct OpenPathDelegate { tx: Option>>>, - project: Model, + lister: DirectoryLister, selected_index: usize, directory_state: Option, matches: Vec, @@ -35,23 +34,23 @@ struct DirectoryState { impl OpenPathPrompt { pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext) { - workspace.set_prompt_for_open_path(Box::new(|workspace, cx| { + workspace.set_prompt_for_open_path(Box::new(|workspace, lister, cx| { let (tx, rx) = futures::channel::oneshot::channel(); - Self::prompt_for_open_path(workspace, tx, cx); + Self::prompt_for_open_path(workspace, lister, tx, cx); rx })); } fn prompt_for_open_path( workspace: &mut Workspace, + lister: DirectoryLister, tx: oneshot::Sender>>, cx: &mut ViewContext, ) { - let project = workspace.project().clone(); workspace.toggle_modal(cx, |cx| { let delegate = OpenPathDelegate { tx: Some(tx), - project: project.clone(), + lister: lister.clone(), selected_index: 0, directory_state: None, matches: Vec::new(), @@ -60,11 +59,7 @@ impl OpenPathPrompt { }; let picker = Picker::uniform_list(delegate, cx).width(rems(34.)); - let query = if let Some(worktree) = project.read(cx).visible_worktrees(cx).next() { - worktree.read(cx).abs_path().to_string_lossy().to_string() - } else { - "~/".to_string() - }; + let query = lister.default_query(cx); picker.set_query(query, cx); picker }); @@ -92,7 +87,7 @@ impl PickerDelegate for OpenPathDelegate { query: String, cx: &mut ViewContext>, ) -> gpui::Task<()> { - let project = self.project.clone(); + let lister = self.lister.clone(); let (mut dir, suffix) = if let Some(index) = query.rfind('/') { (query[..index].to_string(), query[index + 1..].to_string()) } else { @@ -109,9 +104,7 @@ impl PickerDelegate for OpenPathDelegate { { None } else { - Some(project.update(cx, |project, cx| { - project.completions_for_open_path_query(dir.clone(), cx) - })) + Some(lister.list_directory(dir.clone(), cx)) }; self.cancel_flag.store(true, atomic::Ordering::Relaxed); self.cancel_flag = Arc::new(AtomicBool::new(false)); @@ -127,20 +120,12 @@ impl PickerDelegate for OpenPathDelegate { this.update(&mut cx, |this, _| { this.delegate.directory_state = Some(match paths { Ok(mut paths) => { - paths.sort_by(|a, b| { - compare_paths( - (a.strip_prefix(&dir).unwrap_or(Path::new("")), true), - (b.strip_prefix(&dir).unwrap_or(Path::new("")), true), - ) - }); + paths.sort_by(|a, b| compare_paths((a, true), (b, true))); let match_candidates = paths .iter() .enumerate() - .filter_map(|(ix, path)| { - Some(StringMatchCandidate::new( - ix, - path.file_name()?.to_string_lossy().into(), - )) + .map(|(ix, path)| { + StringMatchCandidate::new(ix, path.to_string_lossy().into()) }) .collect::>(); @@ -213,7 +198,16 @@ impl PickerDelegate for OpenPathDelegate { this.delegate .matches .extend(matches.into_iter().map(|m| m.candidate_id)); - this.delegate.matches.sort(); + this.delegate.matches.sort_by_key(|m| { + ( + this.delegate.directory_state.as_ref().and_then(|d| { + d.match_candidates + .get(*m) + .map(|c| !c.string.starts_with(&suffix)) + }), + *m, + ) + }); cx.notify(); }) .ok(); diff --git a/crates/headless/src/headless.rs b/crates/headless/src/headless.rs index 0a0e06ce1c4a78eb0d74d0b1d03d03d4dde5d76a..aede91a0961cf3af0bece6f84ff5671b701cbdb0 100644 --- a/crates/headless/src/headless.rs +++ b/crates/headless/src/headless.rs @@ -3,7 +3,7 @@ use client::DevServerProjectId; use client::{user::UserStore, Client, ClientSettings}; use extension::ExtensionStore; use fs::Fs; -use futures::Future; +use futures::{Future, StreamExt}; use gpui::{AppContext, AsyncAppContext, Context, Global, Model, ModelContext, Task, WeakModel}; use language::LanguageRegistry; use node_runtime::NodeRuntime; @@ -11,6 +11,7 @@ use postage::stream::Stream; use project::Project; use rpc::{proto, ErrorCode, TypedEnvelope}; use settings::{Settings, SettingsStore}; +use std::path::Path; use std::{collections::HashMap, sync::Arc}; use util::{ResultExt, TryFutureExt}; @@ -96,6 +97,7 @@ impl DevServer { cx.weak_model(), Self::handle_validate_dev_server_project_request, ), + client.add_request_handler(cx.weak_model(), Self::handle_list_remote_directory), client.add_message_handler(cx.weak_model(), Self::handle_shutdown), ], _maintain_connection: maintain_connection, @@ -127,34 +129,43 @@ impl DevServer { envelope: TypedEnvelope, mut cx: AsyncAppContext, ) -> Result<()> { - let (added_projects, removed_projects_ids) = this.read_with(&mut cx, |this, _| { - let removed_projects = this - .projects - .keys() - .filter(|dev_server_project_id| { - !envelope - .payload - .projects - .iter() - .any(|p| p.id == dev_server_project_id.0) - }) - .cloned() - .collect::>(); - - let added_projects = envelope - .payload - .projects - .into_iter() - .filter(|project| !this.projects.contains_key(&DevServerProjectId(project.id))) - .collect::>(); + let (added_projects, retained_projects, removed_projects_ids) = + this.read_with(&mut cx, |this, _| { + let removed_projects = this + .projects + .keys() + .filter(|dev_server_project_id| { + !envelope + .payload + .projects + .iter() + .any(|p| p.id == dev_server_project_id.0) + }) + .cloned() + .collect::>(); + + let mut added_projects = vec![]; + let mut retained_projects = vec![]; + + for project in envelope.payload.projects.iter() { + if this.projects.contains_key(&DevServerProjectId(project.id)) { + retained_projects.push(project.clone()); + } else { + added_projects.push(project.clone()); + } + } - (added_projects, removed_projects) - })?; + (added_projects, retained_projects, removed_projects) + })?; for dev_server_project in added_projects { DevServer::share_project(this.clone(), &dev_server_project, &mut cx).await?; } + for dev_server_project in retained_projects { + DevServer::update_project(this.clone(), &dev_server_project, &mut cx).await?; + } + this.update(&mut cx, |this, cx| { for old_project_id in &removed_projects_ids { this.unshare_project(old_project_id, cx)?; @@ -181,6 +192,24 @@ impl DevServer { Ok(proto::Ack {}) } + async fn handle_list_remote_directory( + this: Model, + envelope: TypedEnvelope, + cx: AsyncAppContext, + ) -> Result { + let expanded = shellexpand::tilde(&envelope.payload.path).to_string(); + let fs = cx.read_model(&this, |this, _| this.app_state.fs.clone())?; + + let mut entries = Vec::new(); + let mut response = fs.read_dir(Path::new(&expanded)).await?; + while let Some(path) = response.next().await { + if let Some(file_name) = path?.file_name() { + entries.push(file_name.to_string_lossy().to_string()); + } + } + Ok(proto::ListRemoteDirectoryResponse { entries }) + } + async fn handle_shutdown( this: Model, _envelope: TypedEnvelope, @@ -221,17 +250,19 @@ impl DevServer { (this.client.clone(), project) })?; - let path = shellexpand::tilde(&dev_server_project.path).to_string(); + for path in &dev_server_project.paths { + let path = shellexpand::tilde(path).to_string(); - let (worktree, _) = project - .update(cx, |project, cx| { - project.find_or_create_local_worktree(&path, true, cx) - })? - .await?; + let (worktree, _) = project + .update(cx, |project, cx| { + project.find_or_create_worktree(&path, true, cx) + })? + .await?; - worktree.update(cx, |worktree, cx| { - worktree.as_local_mut().unwrap().share_private_files(cx) - })?; + worktree.update(cx, |worktree, cx| { + worktree.as_local_mut().unwrap().share_private_files(cx) + })?; + } let worktrees = project.read_with(cx, |project, cx| project.worktree_metadata_protos(cx))?; @@ -252,6 +283,56 @@ impl DevServer { Ok(()) } + async fn update_project( + this: Model, + dev_server_project: &proto::DevServerProject, + cx: &mut AsyncAppContext, + ) -> Result<()> { + let tasks = this.update(cx, |this, cx| { + let Some(project) = this + .projects + .get(&DevServerProjectId(dev_server_project.id)) + else { + return vec![]; + }; + + let mut to_delete = vec![]; + let mut tasks = vec![]; + + project.update(cx, |project, cx| { + for worktree in project.visible_worktrees(cx) { + let mut delete = true; + for config in dev_server_project.paths.iter() { + if worktree.read(cx).abs_path().to_string_lossy() + == shellexpand::tilde(config) + { + delete = false; + } + } + if delete { + to_delete.push(worktree.read(cx).id()) + } + } + + for worktree_id in to_delete { + project.remove_worktree(worktree_id, cx) + } + + for config in dev_server_project.paths.iter() { + tasks.push(project.find_or_create_worktree( + &shellexpand::tilde(config).to_string(), + true, + cx, + )); + } + + tasks + }) + })?; + futures::future::join_all(tasks).await; + Ok(()) + } + async fn maintain_connection( this: WeakModel, client: Arc, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 1158fd4f09e2bd768697b6800d42830b37f71db8..fa0f7a695176fb71d887d5a8333f4988de84949c 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -29,7 +29,7 @@ use futures::{ future::{join_all, try_join_all, Shared}, select, stream::FuturesUnordered, - AsyncWriteExt, Future, FutureExt, StreamExt, TryFutureExt, + AsyncWriteExt, Future, FutureExt, StreamExt, }; use fuzzy::CharBag; use git::{blame::Blame, repository::GitRepository}; @@ -86,6 +86,7 @@ use snippet::Snippet; use snippet_provider::SnippetProvider; use std::{ borrow::Cow, + cell::RefCell, cmp::{self, Ordering}, convert::TryInto, env, @@ -202,7 +203,7 @@ pub struct Project { _subscriptions: Vec, shared_buffers: HashMap>, #[allow(clippy::type_complexity)] - loading_local_worktrees: + loading_worktrees: HashMap, Shared, Arc>>>>, buffer_snapshots: HashMap>>, // buffer_id -> server_id -> vec of snapshots buffers_being_formatted: HashSet, @@ -602,6 +603,52 @@ impl FormatTrigger { } } +#[derive(Clone)] +pub enum DirectoryLister { + Project(Model), + Local(Arc), +} + +impl DirectoryLister { + pub fn is_local(&self, cx: &AppContext) -> bool { + match self { + DirectoryLister::Local(_) => true, + DirectoryLister::Project(project) => project.read(cx).is_local(), + } + } + + pub fn default_query(&self, cx: &mut AppContext) -> String { + if let DirectoryLister::Project(project) = self { + if let Some(worktree) = project.read(cx).visible_worktrees(cx).next() { + return worktree.read(cx).abs_path().to_string_lossy().to_string(); + } + }; + "~/".to_string() + } + pub fn list_directory(&self, query: String, cx: &mut AppContext) -> Task>> { + match self { + DirectoryLister::Project(project) => { + project.update(cx, |project, cx| project.list_directory(query, cx)) + } + DirectoryLister::Local(fs) => { + let fs = fs.clone(); + cx.background_executor().spawn(async move { + let mut results = vec![]; + let expanded = shellexpand::tilde(&query); + let query = Path::new(expanded.as_ref()); + let mut response = fs.read_dir(query).await?; + while let Some(path) = response.next().await { + if let Some(file_name) = path?.file_name() { + results.push(PathBuf::from(file_name.to_os_string())); + } + } + Ok(results) + }) + } + } + } +} + #[derive(Clone, Debug, PartialEq)] enum SearchMatchCandidate { OpenBuffer { @@ -727,7 +774,7 @@ impl Project { collaborators: Default::default(), buffer_store, shared_buffers: Default::default(), - loading_local_worktrees: Default::default(), + loading_worktrees: Default::default(), buffer_snapshots: Default::default(), join_project_response_message_id: 0, client_state: ProjectClientState::Local, @@ -866,7 +913,7 @@ impl Project { buffer_ordered_messages_tx: tx, buffer_store, shared_buffers: Default::default(), - loading_local_worktrees: Default::default(), + loading_worktrees: Default::default(), active_entry: None, collaborators: Default::default(), join_project_response_message_id: response.message_id, @@ -1068,7 +1115,7 @@ impl Project { for path in root_paths { let (tree, _) = project .update(cx, |project, cx| { - project.find_or_create_local_worktree(path, true, cx) + project.find_or_create_worktree(path, true, cx) }) .unwrap() .await @@ -1106,7 +1153,7 @@ impl Project { for path in root_paths { let (tree, _) = project .update(cx, |project, cx| { - project.find_or_create_local_worktree(path, true, cx) + project.find_or_create_worktree(path, true, cx) }) .await .unwrap(); @@ -1909,7 +1956,7 @@ impl Project { abs_path: impl AsRef, cx: &mut ModelContext, ) -> Task>> { - if let Some((worktree, relative_path)) = self.find_local_worktree(abs_path.as_ref(), cx) { + if let Some((worktree, relative_path)) = self.find_worktree(abs_path.as_ref(), cx) { self.open_buffer((worktree.read(cx).id(), relative_path), cx) } else { Task::ready(Err(anyhow!("no such path"))) @@ -1976,7 +2023,7 @@ impl Project { }; let (worktree, relative_path) = if let Some(result) = this .update(&mut cx, |this, cx| { - this.find_local_worktree(&worktree_root_target, cx) + this.find_worktree(&worktree_root_target, cx) })? { let relative_path = known_relative_path.unwrap_or_else(|| Arc::::from(result.1)); @@ -1984,7 +2031,7 @@ impl Project { } else { let worktree = this .update(&mut cx, |this, cx| { - this.create_local_worktree(&worktree_root_target, false, cx) + this.create_worktree(&worktree_root_target, false, cx) })? .await?; this.update(&mut cx, |this, cx| { @@ -4572,7 +4619,7 @@ impl Project { cx: &mut ModelContext, ) -> Result<(), anyhow::Error> { let (worktree, relative_path) = self - .find_local_worktree(&abs_path, cx) + .find_worktree(&abs_path, cx) .ok_or_else(|| anyhow!("no worktree found for diagnostics path {abs_path:?}"))?; let project_path = ProjectPath { @@ -5440,9 +5487,7 @@ impl Project { let path; let worktree; - if let Some((tree, rel_path)) = - this.find_local_worktree(&abs_path, cx) - { + if let Some((tree, rel_path)) = this.find_worktree(&abs_path, cx) { worktree = tree; path = rel_path; } else { @@ -7516,23 +7561,23 @@ impl Project { Ok(()) } - pub fn find_or_create_local_worktree( + pub fn find_or_create_worktree( &mut self, abs_path: impl AsRef, visible: bool, cx: &mut ModelContext, ) -> Task, PathBuf)>> { let abs_path = abs_path.as_ref(); - if let Some((tree, relative_path)) = self.find_local_worktree(abs_path, cx) { + if let Some((tree, relative_path)) = self.find_worktree(abs_path, cx) { Task::ready(Ok((tree, relative_path))) } else { - let worktree = self.create_local_worktree(abs_path, visible, cx); + let worktree = self.create_worktree(abs_path, visible, cx); cx.background_executor() .spawn(async move { Ok((worktree.await?, PathBuf::new())) }) } } - pub fn find_local_worktree( + pub fn find_worktree( &self, abs_path: &Path, cx: &AppContext, @@ -7559,21 +7604,56 @@ impl Project { } } - pub fn completions_for_open_path_query( + pub fn list_directory( &self, query: String, cx: &mut ModelContext, ) -> Task>> { - let fs = self.fs.clone(); + if self.is_local() { + DirectoryLister::Local(self.fs.clone()).list_directory(query, cx) + } else if let Some(dev_server) = self.dev_server_project_id().and_then(|id| { + dev_server_projects::Store::global(cx) + .read(cx) + .dev_server_for_project(id) + }) { + let request = proto::ListRemoteDirectory { + dev_server_id: dev_server.id.0, + path: query, + }; + let response = self.client.request(request); + cx.background_executor().spawn(async move { + let response = response.await?; + Ok(response.entries.into_iter().map(PathBuf::from).collect()) + }) + } else { + Task::ready(Err(anyhow!("cannot list directory in remote project"))) + } + } + + fn create_worktree( + &mut self, + abs_path: impl AsRef, + visible: bool, + cx: &mut ModelContext, + ) -> Task>> { + let path: Arc = abs_path.as_ref().into(); + if !self.loading_worktrees.contains_key(&path) { + let task = if self.is_local() { + self.create_local_worktree(abs_path, visible, cx) + } else if self.dev_server_project_id.is_some() { + self.create_dev_server_worktree(abs_path, cx) + } else { + return Task::ready(Err(anyhow!("not a local project"))); + }; + self.loading_worktrees.insert(path.clone(), task.shared()); + } + let task = self.loading_worktrees.get(&path).unwrap().clone(); cx.background_executor().spawn(async move { - let mut results = vec![]; - let expanded = shellexpand::tilde(&query); - let query = Path::new(expanded.as_ref()); - let mut response = fs.read_dir(query).await?; - while let Some(path) = response.next().await { - results.push(path?); - } - Ok(results) + let result = match task.await { + Ok(worktree) => Ok(worktree), + Err(err) => Err(anyhow!("{}", err)), + }; + result }) } @@ -7582,51 +7662,102 @@ impl Project { abs_path: impl AsRef, visible: bool, cx: &mut ModelContext, - ) -> Task>> { + ) -> Task, Arc>> { let fs = self.fs.clone(); let next_entry_id = self.next_entry_id.clone(); let path: Arc = abs_path.as_ref().into(); - let task = self - .loading_local_worktrees - .entry(path.clone()) - .or_insert_with(|| { - cx.spawn(move |project, mut cx| { - async move { - let worktree = - Worktree::local(path.clone(), visible, fs, next_entry_id, &mut cx) - .await; - project.update(&mut cx, |project, _| { - project.loading_local_worktrees.remove(&path); - })?; + cx.spawn(move |project, mut cx| async move { + let worktree = Worktree::local(path.clone(), visible, fs, next_entry_id, &mut cx).await; - let worktree = worktree?; - project - .update(&mut cx, |project, cx| project.add_worktree(&worktree, cx))?; + project.update(&mut cx, |project, _| { + project.loading_worktrees.remove(&path); + })?; - if visible { - cx.update(|cx| { - cx.add_recent_document(&path); - }) - .log_err(); - } + let worktree = worktree?; + project.update(&mut cx, |project, cx| project.add_worktree(&worktree, cx))?; - Ok(worktree) - } - .map_err(Arc::new) + if visible { + cx.update(|cx| { + cx.add_recent_document(&path); }) - .shared() - }) - .clone(); - cx.background_executor().spawn(async move { - match task.await { - Ok(worktree) => Ok(worktree), - Err(err) => Err(anyhow!("{}", err)), + .log_err(); } + + Ok(worktree) + }) + } + + fn create_dev_server_worktree( + &mut self, + abs_path: impl AsRef, + cx: &mut ModelContext, + ) -> Task, Arc>> { + let client = self.client.clone(); + let path: Arc = abs_path.as_ref().into(); + let mut paths: Vec = self + .visible_worktrees(cx) + .map(|worktree| worktree.read(cx).abs_path().to_string_lossy().to_string()) + .collect(); + paths.push(path.to_string_lossy().to_string()); + let request = client.request(proto::UpdateDevServerProject { + dev_server_project_id: self.dev_server_project_id.unwrap().0, + paths, + }); + + let abs_path = abs_path.as_ref().to_path_buf(); + cx.spawn(move |project, mut cx| async move { + let (tx, rx) = futures::channel::oneshot::channel(); + let tx = RefCell::new(Some(tx)); + let Some(project) = project.upgrade() else { + return Err(anyhow!("project dropped"))?; + }; + let observer = cx.update(|cx| { + cx.observe(&project, move |project, cx| { + let abs_path = abs_path.clone(); + project.update(cx, |project, cx| { + if let Some((worktree, _)) = project.find_worktree(&abs_path, cx) { + if let Some(tx) = tx.borrow_mut().take() { + tx.send(worktree).ok(); + } + } + }) + }) + })?; + + request.await?; + let worktree = rx.await.map_err(|e| anyhow!(e))?; + drop(observer); + project.update(&mut cx, |project, _| { + project.loading_worktrees.remove(&path); + })?; + Ok(worktree) }) } pub fn remove_worktree(&mut self, id_to_remove: WorktreeId, cx: &mut ModelContext) { + if let Some(dev_server_project_id) = self.dev_server_project_id { + let paths: Vec = self + .visible_worktrees(cx) + .filter_map(|worktree| { + if worktree.read(cx).id() == id_to_remove { + None + } else { + Some(worktree.read(cx).abs_path().to_string_lossy().to_string()) + } + }) + .collect(); + if paths.len() > 0 { + let request = self.client.request(proto::UpdateDevServerProject { + dev_server_project_id: dev_server_project_id.0, + paths, + }); + cx.background_executor() + .spawn(request) + .detach_and_log_err(cx); + } + return; + } self.diagnostics.remove(&id_to_remove); self.diagnostic_summaries.remove(&id_to_remove); @@ -8278,18 +8409,6 @@ impl Project { }) } - pub fn project_path_for_absolute_path( - &self, - abs_path: &Path, - cx: &AppContext, - ) -> Option { - self.find_local_worktree(abs_path, cx) - .map(|(worktree, relative_path)| ProjectPath { - worktree_id: worktree.read(cx).id(), - path: relative_path.into(), - }) - } - pub fn get_workspace_root( &self, project_path: &ProjectPath, diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index d0961609d3a3472ed501bb27cf80e430789cd9f0..44ef1b95a250a098461b8409138371709c38ad90 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -987,7 +987,7 @@ async fn test_omitted_diagnostics(cx: &mut gpui::TestAppContext) { let project = Project::test(fs, ["/root/dir".as_ref()], cx).await; let (worktree, _) = project .update(cx, |project, cx| { - project.find_or_create_local_worktree("/root/dir", true, cx) + project.find_or_create_worktree("/root/dir", true, cx) }) .await .unwrap(); @@ -995,7 +995,7 @@ async fn test_omitted_diagnostics(cx: &mut gpui::TestAppContext) { let (worktree, _) = project .update(cx, |project, cx| { - project.find_or_create_local_worktree("/root/other.rs", false, cx) + project.find_or_create_worktree("/root/other.rs", false, cx) }) .await .unwrap(); diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 40423c26f741d75bb40d5076b20d088a9f8b7b2f..58968b2385b0cde9c800791d1ba473a940aa686f 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -55,7 +55,9 @@ impl Project { } else { projects_store .dev_server_project(dev_server_project_id)? - .path + .paths + .get(0) + .unwrap() .to_string() }; @@ -81,8 +83,8 @@ impl Project { .and_then(|cwd| cwd.local_path()); terminal_cwd - .and_then(|terminal_cwd| self.find_local_worktree(&terminal_cwd, cx)) - .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_local_worktree(&spawn_cwd, cx))) + .and_then(|terminal_cwd| self.find_worktree(&terminal_cwd, cx)) + .or_else(|| task_cwd.and_then(|spawn_cwd| self.find_worktree(&spawn_cwd, cx))) }; let settings_location = worktree.as_ref().map(|(worktree, path)| SettingsLocation { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 8f5ff60d6dd0098a2bb054d42f82053938b13713..00143487ae37256fe10df73f16d1db4455f1c958 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -460,9 +460,8 @@ impl ProjectPanel { let is_foldable = auto_fold_dirs && self.is_foldable(entry, worktree); let is_unfoldable = auto_fold_dirs && self.is_unfoldable(entry, worktree); let worktree_id = worktree.id(); - let is_local = project.is_local(); let is_read_only = project.is_read_only(); - let is_remote = project.is_remote(); + let is_remote = project.is_remote() && project.dev_server_project_id().is_none(); let context_menu = ContextMenu::build(cx, |menu, cx| { menu.context(self.focus_handle.clone()).when_else( @@ -526,14 +525,12 @@ impl ProjectPanel { menu.action("Trash", Box::new(Trash { skip_prompt: false })) .action("Delete", Box::new(Delete { skip_prompt: false })) }) - .when(is_local & is_root, |menu| { + .when(!is_remote & is_root, |menu| { menu.separator() - .when(!is_remote, |menu| { - menu.action( - "Add Folder to Project…", - Box::new(workspace::AddFolderToProject), - ) - }) + .action( + "Add Folder to Project…", + Box::new(workspace::AddFolderToProject), + ) .entry( "Remove from Project", None, @@ -544,7 +541,7 @@ impl ProjectPanel { }), ) }) - .when(is_local & is_root, |menu| { + .when(is_root, |menu| { menu.separator() .action("Collapse All", Box::new(CollapseAllEntries)) }) diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index f429c244c7268145cdc70c6762d809c0355769a4..5701e5ce61b93939b7c9aa1dfb6ce6372a29677c 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -265,7 +265,10 @@ message Envelope { SynchronizeContextsResponse synchronize_contexts_response = 216; GetSignatureHelp get_signature_help = 217; - GetSignatureHelpResponse get_signature_help_response = 218; // current max + GetSignatureHelpResponse get_signature_help_response = 218; + ListRemoteDirectory list_remote_directory = 219; + ListRemoteDirectoryResponse list_remote_directory_response = 220; + UpdateDevServerProject update_dev_server_project = 221; // current max } reserved 158 to 161; @@ -507,6 +510,20 @@ message ValidateDevServerProjectRequest { string path = 1; } +message ListRemoteDirectory { + uint64 dev_server_id = 1; + string path = 2; +} + +message ListRemoteDirectoryResponse { + repeated string entries = 1; +} + +message UpdateDevServerProject { + uint64 dev_server_project_id = 1; + repeated string paths = 2; +} + message CreateDevServer { reserved 1; string name = 2; @@ -1335,6 +1352,7 @@ message DevServerProject { reserved 4; uint64 dev_server_id = 5; string path = 6; + repeated string paths = 7; } message DevServer { diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index a90ea3edb26a33b8bf1df7ded716bed00be70691..b72deda9f7ecad8f94894d3d02eae3c6c626a9a0 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -377,6 +377,9 @@ messages!( (MultiLspQueryResponse, Background), (DevServerProjectsUpdate, Foreground), (ValidateDevServerProjectRequest, Background), + (ListRemoteDirectory, Background), + (ListRemoteDirectoryResponse, Background), + (UpdateDevServerProject, Background), (DeleteDevServer, Foreground), (DeleteDevServerProject, Foreground), (RegenerateDevServerToken, Foreground), @@ -434,6 +437,8 @@ request_messages!( (GetSupermavenApiKey, GetSupermavenApiKeyResponse), (GetTypeDefinition, GetTypeDefinitionResponse), (LinkedEditingRange, LinkedEditingRangeResponse), + (ListRemoteDirectory, ListRemoteDirectoryResponse), + (UpdateDevServerProject, Ack), (GetUsers, UsersResponse), (IncomingCall, Ack), (InlayHints, InlayHintsResponse), diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index 12c1d04028ea31471dd36c5d54bcb3f7e111d245..4ade9873b1f53e72fd7a2b83174597325123f17f 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -173,16 +173,13 @@ impl DevServerProjects { .read(cx) .projects_for_server(dev_server_id) .iter() - .any(|p| p.path == path) + .any(|p| p.paths.iter().any(|p| p == &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 - )), + Some(&format!("{} is already open on this dev server.", path)), &["Ok"], ) .await @@ -454,15 +451,10 @@ impl DevServerProjects { .detach_and_prompt_err("Failed to delete dev server", cx, |_, _| None); } - fn delete_dev_server_project( - &mut self, - id: DevServerProjectId, - path: &str, - cx: &mut ViewContext, - ) { + fn delete_dev_server_project(&mut self, id: DevServerProjectId, cx: &mut ViewContext) { let answer = cx.prompt( gpui::PromptLevel::Warning, - format!("Delete \"{}\"?", path).as_str(), + "Delete this project?", Some("This will delete the remote project. You can always re-add it later."), &["Delete", "Cancel"], ); @@ -702,12 +694,11 @@ impl DevServerProjects { let dev_server_project_id = project.id; let project_id = project.project_id; let is_online = project_id.is_some(); - let project_path = project.path.clone(); ListItem::new(("remote-project", dev_server_project_id.0)) .start_slot(Icon::new(IconName::FileTree).when(!is_online, |icon| icon.color(Color::Muted))) .child( - Label::new(project.path.clone()) + Label::new(project.paths.join(", ")) ) .on_click(cx.listener(move |_, _, cx| { if let Some(project_id) = project_id { @@ -723,7 +714,7 @@ impl DevServerProjects { })) .end_hover_slot::(Some(IconButton::new("remove-remote-project", IconName::Trash) .on_click(cx.listener(move |this, _, cx| { - this.delete_dev_server_project(dev_server_project_id, &project_path, cx) + this.delete_dev_server_project(dev_server_project_id, cx) })) .tooltip(|cx| Tooltip::text("Delete remote project", cx)).into_any_element())) } diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 9655c713048df2a3dfb7783c2847abddadb5fdcf..26a9661834d0fedb19ee1580371314e710e806ea 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -93,7 +93,7 @@ impl RecentProjects { } } - fn register(workspace: &mut Workspace, _: &mut ViewContext) { + fn register(workspace: &mut Workspace, cx: &mut ViewContext) { workspace.register_action(|workspace, open_recent: &OpenRecent, cx| { let Some(recent_projects) = workspace.active_modal::(cx) else { Self::open(workspace, open_recent.create_new_window, cx); @@ -106,6 +106,20 @@ impl RecentProjects { .update(cx, |picker, cx| picker.cycle_selection(cx)) }); }); + if workspace + .project() + .read(cx) + .dev_server_project_id() + .is_some() + { + workspace.register_action(|workspace, _: &workspace::Open, cx| { + if workspace.active_modal::(cx).is_some() { + cx.propagate(); + } else { + Self::open(workspace, true, cx); + } + }); + } } pub fn open( @@ -234,7 +248,8 @@ impl PickerDelegate for RecentProjectsDelegate { SerializedWorkspaceLocation::DevServer(dev_server_project) => { format!( "{}{}", - dev_server_project.dev_server_name, dev_server_project.path + dev_server_project.dev_server_name, + dev_server_project.paths.join("") ) } }; @@ -400,7 +415,8 @@ impl PickerDelegate for RecentProjectsDelegate { SerializedWorkspaceLocation::DevServer(dev_server_project) => { Arc::new(vec![PathBuf::from(format!( "{}:{}", - dev_server_project.dev_server_name, dev_server_project.path + dev_server_project.dev_server_name, + dev_server_project.paths.join(", ") ))]) } }; diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 788e057581358815781988291f24aa8226b35812..a82082e57f266ff4f2645f924b0c7eb6cad8a95e 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1465,7 +1465,7 @@ mod tests { ) -> (Model, Entry) { let (wt, _) = project .update(cx, |project, cx| { - project.find_or_create_local_worktree(path, true, cx) + project.find_or_create_worktree(path, true, cx) }) .await .unwrap(); diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 339d3b3b8ecf6db3e8fa0b99568c054eae9c6500..711585b596753cc6f2ead7f0cb923cf9dc8465f2 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -14,6 +14,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; +use ui::SharedString; use util::ResultExt; use uuid::Uuid; @@ -21,7 +22,7 @@ use uuid::Uuid; pub struct SerializedDevServerProject { pub id: DevServerProjectId, pub dev_server_name: String, - pub path: String, + pub paths: Vec, } #[derive(Debug, PartialEq, Clone)] @@ -119,7 +120,8 @@ impl Bind for &SerializedDevServerProject { fn bind(&self, statement: &Statement, start_index: i32) -> Result { let next_index = statement.bind(&self.id.0, start_index)?; let next_index = statement.bind(&self.dev_server_name, next_index)?; - statement.bind(&self.path, next_index) + let paths = serde_json::to_string(&self.paths)?; + statement.bind(&paths, next_index) } } @@ -127,12 +129,18 @@ impl Column for SerializedDevServerProject { 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(); + let paths = statement.column_text(start_index + 2)?.to_string(); + let paths: Vec = if paths.starts_with('[') { + serde_json::from_str(&paths).context("JSON deserialization of paths failed")? + } else { + vec![paths.into()] + }; + Ok(( Self { id: DevServerProjectId(id as u64), dev_server_name, - path, + paths, }, start_index + 3, )) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 3a2529f80dc312fb8b6b4886f5b26ec7ae561ec8..1581bb593a536f4f484322189c5db8fead2e5d35 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -53,7 +53,7 @@ pub use persistence::{ WorkspaceDb, DB as WORKSPACE_DB, }; use postage::stream::Stream; -use project::{Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId}; +use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId}; use serde::Deserialize; use settings::Settings; use shared_screen::SharedScreen; @@ -605,7 +605,11 @@ type PromptForNewPath = Box< >; type PromptForOpenPath = Box< - dyn Fn(&mut Workspace, &mut ViewContext) -> oneshot::Receiver>>, + dyn Fn( + &mut Workspace, + DirectoryLister, + &mut ViewContext, + ) -> oneshot::Receiver>>, >; /// Collects everything project-related for a certain window opened. @@ -1332,13 +1336,12 @@ impl Workspace { pub fn prompt_for_open_path( &mut self, path_prompt_options: PathPromptOptions, + lister: DirectoryLister, cx: &mut ViewContext, ) -> oneshot::Receiver>> { - if self.project.read(cx).is_remote() - || !WorkspaceSettings::get_global(cx).use_system_path_prompts - { + if !lister.is_local(cx) || !WorkspaceSettings::get_global(cx).use_system_path_prompts { let prompt = self.on_prompt_for_open_path.take().unwrap(); - let rx = prompt(self, cx); + let rx = prompt(self, lister, cx); self.on_prompt_for_open_path = Some(prompt); rx } else { @@ -1358,7 +1361,7 @@ impl Workspace { let rx = this.update(&mut cx, |this, cx| { this.show_portal_error(err.to_string(), cx); let prompt = this.on_prompt_for_open_path.take().unwrap(); - let rx = prompt(this, cx); + let rx = prompt(this, lister, cx); this.on_prompt_for_open_path = Some(prompt); rx })?; @@ -1419,7 +1422,7 @@ impl Workspace { let project_path = abs_path.and_then(|abs_path| { this.update(&mut cx, |this, cx| { this.project.update(cx, |project, cx| { - project.find_or_create_local_worktree(abs_path, true, cx) + project.find_or_create_worktree(abs_path, true, cx) }) }) .ok() @@ -1703,6 +1706,7 @@ impl Workspace { directories: true, multiple: true, }, + DirectoryLister::Local(self.app_state.fs.clone()), cx, ); @@ -1857,9 +1861,10 @@ impl Workspace { } fn add_folder_to_project(&mut self, _: &AddFolderToProject, cx: &mut ViewContext) { - if self.project.read(cx).is_remote() { + let project = self.project.read(cx); + if project.is_remote() && project.dev_server_project_id().is_none() { self.show_error( - &anyhow!("Folders cannot yet be added to remote projects"), + &anyhow!("You cannot add folders to someone else's project"), cx, ); return; @@ -1870,6 +1875,7 @@ impl Workspace { directories: true, multiple: true, }, + DirectoryLister::Project(self.project.clone()), cx, ); cx.spawn(|this, mut cx| async move { @@ -1895,7 +1901,7 @@ impl Workspace { cx: &mut AppContext, ) -> Task, ProjectPath)>> { let entry = project.update(cx, |project, cx| { - project.find_or_create_local_worktree(abs_path, visible, cx) + project.find_or_create_worktree(abs_path, visible, cx) }); cx.spawn(|mut cx| async move { let (worktree, path) = entry.await?; @@ -3852,7 +3858,7 @@ impl Workspace { let dev_server_project = SerializedDevServerProject { id: dev_server_project_id, dev_server_name: dev_server.name.to_string(), - path: project.path.to_string(), + paths: project.paths.iter().map(|path| path.clone()).collect(), }; Some(SerializedWorkspaceLocation::DevServer(dev_server_project)) }) @@ -3978,6 +3984,9 @@ impl Workspace { .on_action(cx.listener(Self::send_keystrokes)) .on_action(cx.listener(Self::add_folder_to_project)) .on_action(cx.listener(Self::follow_next_collaborator)) + .on_action(cx.listener(Self::open)) + .on_action(cx.listener(Self::close_window)) + .on_action(cx.listener(Self::activate_pane_at_index)) .on_action(cx.listener(|workspace, _: &Unfollow, cx| { let pane = workspace.active_pane().clone(); workspace.unfollow_in_pane(&pane, cx); @@ -4034,9 +4043,6 @@ impl Workspace { workspace.clear_all_notifications(cx); }), ) - .on_action(cx.listener(Workspace::open)) - .on_action(cx.listener(Workspace::close_window)) - .on_action(cx.listener(Workspace::activate_pane_at_index)) .on_action( cx.listener(|workspace: &mut Workspace, _: &ReopenClosedItem, cx| { workspace.reopen_closed_item(cx).detach(); @@ -4083,13 +4089,7 @@ impl Workspace { self } - fn add_workspace_actions_listeners(&self, div: Div, cx: &mut ViewContext) -> Div { - let mut div = div - .on_action(cx.listener(Self::close_inactive_items_and_panes)) - .on_action(cx.listener(Self::close_all_items_and_panes)) - .on_action(cx.listener(Self::add_folder_to_project)) - .on_action(cx.listener(Self::save_all)) - .on_action(cx.listener(Self::open)); + fn add_workspace_actions_listeners(&self, mut div: Div, cx: &mut ViewContext) -> Div { for action in self.workspace_actions.iter() { div = (action)(div, cx) } @@ -5506,7 +5506,7 @@ mod tests { // Add a project folder project .update(cx, |project, cx| { - project.find_or_create_local_worktree("root2", true, cx) + project.find_or_create_worktree("root2", true, cx) }) .await .unwrap(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 28eee430ee25b153be665534a13eae8a4952cbc6..a6a23e6ba489fe17ee91bfef4970744db1683056 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -942,7 +942,7 @@ fn open_settings_file( let worktree_creation_task = workspace.project().update(cx, |project, cx| { // Set up a dedicated worktree for settings, since otherwise we're dropping and re-starting LSP servers for each file inside on every settings file close/open // TODO: Do note that all other external files (e.g. drag and drop from OS) still have their worktrees released on file close, causing LSP servers' restarts. - project.find_or_create_local_worktree(paths::config_dir().as_path(), false, cx) + project.find_or_create_worktree(paths::config_dir().as_path(), false, cx) }); let settings_open_task = create_and_open_local_file(&abs_path, cx, default_content); (worktree_creation_task, settings_open_task) diff --git a/docs/src/remote-development.md b/docs/src/remote-development.md index a71526bd20e9f5d47165f1013e5708f36f2f3f5a..e86f2560f585736dc9727e17cb113682a3b0ab70 100644 --- a/docs/src/remote-development.md +++ b/docs/src/remote-development.md @@ -1,19 +1,17 @@ # Remote Development -Remote Development is in the early stages of development. If you'd like to try it please email [alpha@zed.dev](mailto:alpha@zed.dev). - Remote Development allows you to code at the speed of thought, even when your codebase is not on your local machine. You use Zed locally so the UI is immediately responsive, but offload heavy computation to the development server so that you can work effectively. +> **Note:** Remoting is still "alpha". We have several changes we would like to make before it is fully released. + ## Overview Remote development requires running two instances of Zed. A headless instance on the remote machine, and the editor interface on your local computer. All configuration is done on your local computer. -Currently the two instances connect via Zed's servers, but we intend to build peer to peer communication in the future. +Currently the two instances connect via Zed's servers, but we intend to build peer to peer communication before the feature is fully released. ## Setup -> **Note:** You must be in the alpha program to see this UI. The instructions will likely change as the feature gets closer to launch. - 1. Download and install the latest [Zed Preview](https://zed.dev/releases/preview). 1. Open the remote projects dialogue with `cmd-shift-p remote`. 2. Click "New Server". @@ -27,7 +25,7 @@ Currently the two instances connect via Zed's servers, but we intend to build pe ### UI is not showing up -This can happen either if you were just added to the alpha, in which case you need to restart Zed. Or, if you lost connection to the Zed server, in which case you just need to click "Sign In" in the top right. +You need to be on a relatively recent Zed (v0.145.0 or later). ### SSH connections @@ -86,8 +84,8 @@ If you'd like to install language-server extensions, you can add them to the lis ## Known Limitations - You can't use the Terminal or Tasks if you choose "Manual Connection" -- You can't yet open additional files on the machine in the current project. - You can't run `zed` in headless mode and in GUI mode at the same time on the same machine. +- You can't open files from the remote Terminal by typing the `zed` command. ## Feedback