From 37010aac6b340e058ef9c5a3c783802af90e5b9f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 7 May 2025 16:50:57 -0700 Subject: [PATCH] Allow opening the FS root dir as a remote project (#30190) ### Todo * [x] Allow opening `ssh://username@host:/` from the CLI * [x] Allow selecting `/` in the `open path` picker * [x] Allow selecting the home directory in the `open path` picker Release Notes: - Changed the initial state of the SSH project picker to show the full path to your home directory on the remote machine, instead of `~`. - Added the ability to open `/` as a project folder over SSH --------- Co-authored-by: Agus Zubiaga --- crates/file_finder/src/open_path_prompt.rs | 32 +++++++--- crates/project/src/project.rs | 13 ++-- crates/project/src/worktree_store.rs | 2 +- crates/project_panel/src/project_panel.rs | 8 +-- crates/recent_projects/src/remote_servers.rs | 66 +++++++++++--------- crates/worktree/src/worktree.rs | 2 +- crates/zed/src/zed/open_listener.rs | 29 ++++++++- 7 files changed, 98 insertions(+), 54 deletions(-) diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 2d18d60d2d..08deb8815a 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -1,7 +1,7 @@ use futures::channel::oneshot; use fuzzy::{StringMatch, StringMatchCandidate}; use picker::{Picker, PickerDelegate}; -use project::DirectoryLister; +use project::{DirectoryItem, DirectoryLister}; use std::{ path::{MAIN_SEPARATOR_STR, Path, PathBuf}, sync::{ @@ -137,6 +137,7 @@ impl PickerDelegate for OpenPathDelegate { } else { (query, String::new()) }; + if dir == "" { #[cfg(not(target_os = "windows"))] { @@ -171,6 +172,13 @@ impl PickerDelegate for OpenPathDelegate { this.update(cx, |this, _| { this.delegate.directory_state = Some(match paths { Ok(mut paths) => { + if dir == "/" { + paths.push(DirectoryItem { + is_dir: true, + path: Default::default(), + }); + } + paths.sort_by(|a, b| compare_paths((&a.path, true), (&b.path, true))); let match_candidates = paths .iter() @@ -309,12 +317,16 @@ impl PickerDelegate for OpenPathDelegate { let Some(candidate) = directory_state.match_candidates.get(*m) else { return; }; - let result = Path::new( - self.lister - .resolve_tilde(&directory_state.path, cx) - .as_ref(), - ) - .join(&candidate.path.string); + let result = if directory_state.path == "/" && candidate.path.string.is_empty() { + PathBuf::from("/") + } else { + Path::new( + self.lister + .resolve_tilde(&directory_state.path, cx) + .as_ref(), + ) + .join(&candidate.path.string) + }; if let Some(tx) = self.tx.take() { tx.send(Some(vec![result])).ok(); } @@ -355,7 +367,11 @@ impl PickerDelegate for OpenPathDelegate { .inset(true) .toggle_state(selected) .child(HighlightedLabel::new( - candidate.path.string.clone(), + if directory_state.path == "/" { + format!("/{}", candidate.path.string) + } else { + candidate.path.string.clone() + }, highlight_positions, )), ) diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 9a5be5e09a..1b357648e6 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -3904,11 +3904,7 @@ impl Project { }) } - pub fn resolve_abs_path( - &self, - path: &str, - cx: &mut Context, - ) -> Task> { + pub fn resolve_abs_path(&self, path: &str, cx: &App) -> Task> { if self.is_local() { let expanded = PathBuf::from(shellexpand::tilde(&path).into_owned()); let fs = self.fs.clone(); @@ -5124,6 +5120,13 @@ impl ResolvedPath { } } + pub fn into_abs_path(self) -> Option { + match self { + Self::AbsPath { path, .. } => Some(path), + _ => None, + } + } + pub fn project_path(&self) -> Option<&ProjectPath> { match self { Self::ProjectPath { project_path, .. } => Some(&project_path), diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index ea66cd84cc..3db5ba0842 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -262,7 +262,7 @@ impl WorktreeStore { if abs_path.starts_with("/~") { abs_path = abs_path[1..].to_string(); } - if abs_path.is_empty() || abs_path == "/" { + if abs_path.is_empty() { abs_path = "~/".to_string(); } cx.spawn(async move |this, cx| { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index acf7c7973c..9a65a8a069 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -2855,13 +2855,7 @@ impl ProjectPanel { } let worktree_abs_path = worktree.read(cx).abs_path(); let (depth, path) = if Some(entry.entry) == worktree.read(cx).root_entry() { - let Some(path_name) = worktree_abs_path - .file_name() - .with_context(|| { - format!("Worktree abs path has no file name, root entry: {entry:?}") - }) - .log_err() - else { + let Some(path_name) = worktree_abs_path.file_name() else { continue; }; let path = ArcCow::Borrowed(Path::new(path_name)); diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 7a4355ce43..37600748df 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -124,20 +124,20 @@ impl ProjectPicker { ix: usize, connection: SshConnectionOptions, project: Entity, + home_dir: PathBuf, workspace: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Entity { let (tx, rx) = oneshot::channel(); let lister = project::DirectoryLister::Project(project.clone()); - let query = lister.default_query(cx); let delegate = file_finder::OpenPathDelegate::new(tx, lister); let picker = cx.new(|cx| { let picker = Picker::uniform_list(delegate, window, cx) .width(rems(34.)) .modal(false); - picker.set_query(query, window, cx); + picker.set_query(home_dir.to_string_lossy().to_string(), window, cx); picker }); let connection_string = connection.connection_string().into(); @@ -345,6 +345,7 @@ impl RemoteServerProjects { ix: usize, connection_options: remote::SshConnectionOptions, project: Entity, + home_dir: PathBuf, window: &mut Window, cx: &mut Context, workspace: WeakEntity, @@ -354,6 +355,7 @@ impl RemoteServerProjects { ix, connection_options, project, + home_dir, workspace, window, cx, @@ -467,6 +469,7 @@ impl RemoteServerProjects { let connection_options = ssh_connection.into(); workspace.update(cx, |_, cx| { cx.defer_in(window, move |workspace, window, cx| { + let app_state = workspace.app_state().clone(); workspace.toggle_modal(window, cx, |window, cx| { SshConnectionModal::new(&connection_options, Vec::new(), window, cx) }); @@ -489,44 +492,48 @@ impl RemoteServerProjects { cx.spawn_in(window, async move |workspace, cx| { let session = connect.await; - workspace - .update(cx, |workspace, cx| { - if let Some(prompt) = workspace.active_modal::(cx) { - prompt.update(cx, |prompt, cx| prompt.finished(cx)) - } - }) - .ok(); + workspace.update(cx, |workspace, cx| { + if let Some(prompt) = workspace.active_modal::(cx) { + prompt.update(cx, |prompt, cx| prompt.finished(cx)) + } + })?; let Some(Some(session)) = session else { - workspace - .update_in(cx, |workspace, window, cx| { - let weak = cx.entity().downgrade(); - workspace.toggle_modal(window, cx, |window, cx| { - RemoteServerProjects::new(window, cx, weak) - }); - }) - .log_err(); - return; + return workspace.update_in(cx, |workspace, window, cx| { + let weak = cx.entity().downgrade(); + workspace.toggle_modal(window, cx, |window, cx| { + RemoteServerProjects::new(window, cx, weak) + }); + }); }; + let project = cx.update(|_, cx| { + project::Project::ssh( + session, + app_state.client.clone(), + app_state.node_runtime.clone(), + app_state.user_store.clone(), + app_state.languages.clone(), + app_state.fs.clone(), + cx, + ) + })?; + + let home_dir = project + .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))? + .await + .and_then(|path| path.into_abs_path()) + .unwrap_or(PathBuf::from("/")); + workspace .update_in(cx, |workspace, window, cx| { - let app_state = workspace.app_state().clone(); let weak = cx.entity().downgrade(); - let project = project::Project::ssh( - session, - app_state.client.clone(), - app_state.node_runtime.clone(), - app_state.user_store.clone(), - app_state.languages.clone(), - app_state.fs.clone(), - cx, - ); workspace.toggle_modal(window, cx, |window, cx| { RemoteServerProjects::project_picker( ix, connection_options, project, + home_dir, window, cx, weak, @@ -534,8 +541,9 @@ impl RemoteServerProjects { }); }) .ok(); + Ok(()) }) - .detach() + .detach(); }) }) } diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index e990dcf33a..495525fba4 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -2648,7 +2648,7 @@ impl Snapshot { } pub fn root_entry(&self) -> Option<&Entry> { - self.entry_for_path("") + self.entries_by_path.first() } /// TODO: what's the difference between `root_dir` and `abs_path`? diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 3d033beffc..ab2375baf1 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -530,8 +530,8 @@ pub async fn derive_paths_with_position( #[cfg(test)] mod tests { - use std::sync::Arc; - + use super::*; + use crate::zed::{open_listener::open_local_workspace, tests::init_test}; use cli::{ CliResponse, ipc::{self}, @@ -539,10 +539,33 @@ mod tests { use editor::Editor; use gpui::TestAppContext; use serde_json::json; + use std::sync::Arc; use util::path; use workspace::{AppState, Workspace}; - use crate::zed::{open_listener::open_local_workspace, tests::init_test}; + #[gpui::test] + fn test_parse_ssh_url(cx: &mut TestAppContext) { + let _app_state = init_test(cx); + cx.update(|cx| { + SshSettings::register(cx); + }); + let request = + cx.update(|cx| OpenRequest::parse(vec!["ssh://me@localhost:/".into()], cx).unwrap()); + assert_eq!( + request.ssh_connection.unwrap(), + SshConnectionOptions { + host: "localhost".into(), + username: Some("me".into()), + port: None, + password: None, + args: None, + port_forwards: None, + nickname: None, + upload_binary_over_ssh: false, + } + ); + assert_eq!(request.open_paths, vec!["/"]); + } #[gpui::test] async fn test_open_workspace_with_directory(cx: &mut TestAppContext) {