windows: Add support for SSH (#29145)

Closes #19892

This PR builds on top of #20587 and improves upon it.

Release Notes:

- N/A

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
张小白 2025-07-08 22:34:57 +08:00 committed by GitHub
parent 8bd739d869
commit 0ca0914cca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1435 additions and 354 deletions

View file

@ -15,16 +15,14 @@ use std::{
};
use ui::{Context, LabelLike, ListItem, Window};
use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
use util::{maybe, paths::compare_paths};
use util::{
maybe,
paths::{PathStyle, compare_paths},
};
use workspace::Workspace;
pub(crate) struct OpenPathPrompt;
#[cfg(target_os = "windows")]
const PROMPT_ROOT: &str = "C:\\";
#[cfg(not(target_os = "windows"))]
const PROMPT_ROOT: &str = "/";
#[derive(Debug)]
pub struct OpenPathDelegate {
tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
@ -34,6 +32,8 @@ pub struct OpenPathDelegate {
string_matches: Vec<StringMatch>,
cancel_flag: Arc<AtomicBool>,
should_dismiss: bool,
prompt_root: String,
path_style: PathStyle,
replace_prompt: Task<()>,
}
@ -42,6 +42,7 @@ impl OpenPathDelegate {
tx: oneshot::Sender<Option<Vec<PathBuf>>>,
lister: DirectoryLister,
creating_path: bool,
path_style: PathStyle,
) -> Self {
Self {
tx: Some(tx),
@ -53,6 +54,11 @@ impl OpenPathDelegate {
string_matches: Vec::new(),
cancel_flag: Arc::new(AtomicBool::new(false)),
should_dismiss: true,
prompt_root: match path_style {
PathStyle::Posix => "/".to_string(),
PathStyle::Windows => "C:\\".to_string(),
},
path_style,
replace_prompt: Task::ready(()),
}
}
@ -185,7 +191,8 @@ impl OpenPathPrompt {
cx: &mut Context<Workspace>,
) {
workspace.toggle_modal(window, cx, |window, cx| {
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
let delegate =
OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::current());
let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
let query = lister.default_query(cx);
picker.set_query(query, window, cx);
@ -226,18 +233,7 @@ impl PickerDelegate for OpenPathDelegate {
cx: &mut Context<Picker<Self>>,
) -> Task<()> {
let lister = &self.lister;
let last_item = Path::new(&query)
.file_name()
.unwrap_or_default()
.to_string_lossy();
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
(dir.to_string(), last_item.into_owned())
} else {
(query, String::new())
};
if dir == "" {
dir = PROMPT_ROOT.to_string();
}
let (dir, suffix) = get_dir_and_suffix(query, self.path_style);
let query = match &self.directory_state {
DirectoryState::List { parent_path, .. } => {
@ -266,6 +262,7 @@ impl PickerDelegate for OpenPathDelegate {
self.cancel_flag = Arc::new(AtomicBool::new(false));
let cancel_flag = self.cancel_flag.clone();
let parent_path_is_root = self.prompt_root == dir;
cx.spawn_in(window, async move |this, cx| {
if let Some(query) = query {
let paths = query.await;
@ -279,7 +276,7 @@ impl PickerDelegate for OpenPathDelegate {
DirectoryState::None { create: false }
| DirectoryState::List { .. } => match paths {
Ok(paths) => DirectoryState::List {
entries: path_candidates(&dir, paths),
entries: path_candidates(parent_path_is_root, paths),
parent_path: dir.clone(),
error: None,
},
@ -292,7 +289,7 @@ impl PickerDelegate for OpenPathDelegate {
DirectoryState::None { create: true }
| DirectoryState::Create { .. } => match paths {
Ok(paths) => {
let mut entries = path_candidates(&dir, paths);
let mut entries = path_candidates(parent_path_is_root, paths);
let mut exists = false;
let mut is_dir = false;
let mut new_id = None;
@ -488,6 +485,7 @@ impl PickerDelegate for OpenPathDelegate {
_: &mut Context<Picker<Self>>,
) -> Option<String> {
let candidate = self.get_entry(self.selected_index)?;
let path_style = self.path_style;
Some(
maybe!({
match &self.directory_state {
@ -496,7 +494,7 @@ impl PickerDelegate for OpenPathDelegate {
parent_path,
candidate.path.string,
if candidate.is_dir {
MAIN_SEPARATOR_STR
path_style.separator()
} else {
""
}
@ -506,7 +504,7 @@ impl PickerDelegate for OpenPathDelegate {
parent_path,
candidate.path.string,
if candidate.is_dir {
MAIN_SEPARATOR_STR
path_style.separator()
} else {
""
}
@ -527,8 +525,8 @@ impl PickerDelegate for OpenPathDelegate {
DirectoryState::None { .. } => return,
DirectoryState::List { parent_path, .. } => {
let confirmed_path =
if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
PathBuf::from(PROMPT_ROOT)
if parent_path == &self.prompt_root && candidate.path.string.is_empty() {
PathBuf::from(&self.prompt_root)
} else {
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
.join(&candidate.path.string)
@ -548,8 +546,8 @@ impl PickerDelegate for OpenPathDelegate {
return;
}
let prompted_path =
if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() {
PathBuf::from(PROMPT_ROOT)
if parent_path == &self.prompt_root && user_input.file.string.is_empty() {
PathBuf::from(&self.prompt_root)
} else {
Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
.join(&user_input.file.string)
@ -652,8 +650,8 @@ impl PickerDelegate for OpenPathDelegate {
.inset(true)
.toggle_state(selected)
.child(HighlightedLabel::new(
if parent_path == PROMPT_ROOT {
format!("{}{}", PROMPT_ROOT, candidate.path.string)
if parent_path == &self.prompt_root {
format!("{}{}", self.prompt_root, candidate.path.string)
} else {
candidate.path.string.clone()
},
@ -665,10 +663,10 @@ impl PickerDelegate for OpenPathDelegate {
user_input,
..
} => {
let (label, delta) = if parent_path == PROMPT_ROOT {
let (label, delta) = if parent_path == &self.prompt_root {
(
format!("{}{}", PROMPT_ROOT, candidate.path.string),
PROMPT_ROOT.len(),
format!("{}{}", self.prompt_root, candidate.path.string),
self.prompt_root.len(),
)
} else {
(candidate.path.string.clone(), 0)
@ -751,8 +749,11 @@ impl PickerDelegate for OpenPathDelegate {
}
}
fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> {
if *parent_path == PROMPT_ROOT {
fn path_candidates(
parent_path_is_root: bool,
mut children: Vec<DirectoryItem>,
) -> Vec<CandidateInfo> {
if parent_path_is_root {
children.push(DirectoryItem {
is_dir: true,
path: PathBuf::default(),
@ -769,3 +770,128 @@ fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Ve
})
.collect()
}
#[cfg(target_os = "windows")]
fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) {
let last_item = Path::new(&query)
.file_name()
.unwrap_or_default()
.to_string_lossy();
let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
(dir.to_string(), last_item.into_owned())
} else {
(query.to_string(), String::new())
};
match path_style {
PathStyle::Posix => {
if dir.is_empty() {
dir = "/".to_string();
}
}
PathStyle::Windows => {
if dir.len() < 3 {
dir = "C:\\".to_string();
}
}
}
(dir, suffix)
}
#[cfg(not(target_os = "windows"))]
fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) {
match path_style {
PathStyle::Posix => {
let (mut dir, suffix) = if let Some(index) = query.rfind('/') {
(query[..index].to_string(), query[index + 1..].to_string())
} else {
(query, String::new())
};
if !dir.ends_with('/') {
dir.push('/');
}
(dir, suffix)
}
PathStyle::Windows => {
let (mut dir, suffix) = if let Some(index) = query.rfind('\\') {
(query[..index].to_string(), query[index + 1..].to_string())
} else {
(query, String::new())
};
if dir.len() < 3 {
dir = "C:\\".to_string();
}
if !dir.ends_with('\\') {
dir.push('\\');
}
(dir, suffix)
}
}
}
#[cfg(test)]
mod tests {
use util::paths::PathStyle;
use crate::open_path_prompt::get_dir_and_suffix;
#[test]
fn test_get_dir_and_suffix_with_windows_style() {
let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("C:".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("C:\\".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("C:\\Use".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\");
assert_eq!(suffix, "Use");
let (dir, suffix) =
get_dir_and_suffix("C:\\Users\\Junkui\\Docum".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\Users\\Junkui\\");
assert_eq!(suffix, "Docum");
let (dir, suffix) =
get_dir_and_suffix("C:\\Users\\Junkui\\Documents".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\Users\\Junkui\\");
assert_eq!(suffix, "Documents");
let (dir, suffix) =
get_dir_and_suffix("C:\\Users\\Junkui\\Documents\\".into(), PathStyle::Windows);
assert_eq!(dir, "C:\\Users\\Junkui\\Documents\\");
assert_eq!(suffix, "");
}
#[test]
fn test_get_dir_and_suffix_with_posix_style() {
let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Posix);
assert_eq!(dir, "/");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("/".into(), PathStyle::Posix);
assert_eq!(dir, "/");
assert_eq!(suffix, "");
let (dir, suffix) = get_dir_and_suffix("/Use".into(), PathStyle::Posix);
assert_eq!(dir, "/");
assert_eq!(suffix, "Use");
let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Docum".into(), PathStyle::Posix);
assert_eq!(dir, "/Users/Junkui/");
assert_eq!(suffix, "Docum");
let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents".into(), PathStyle::Posix);
assert_eq!(dir, "/Users/Junkui/");
assert_eq!(suffix, "Documents");
let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents/".into(), PathStyle::Posix);
assert_eq!(dir, "/Users/Junkui/Documents/");
assert_eq!(suffix, "");
}
}

View file

@ -5,7 +5,7 @@ use picker::{Picker, PickerDelegate};
use project::Project;
use serde_json::json;
use ui::rems;
use util::path;
use util::{path, paths::PathStyle};
use workspace::{AppState, Workspace};
use crate::OpenPathDelegate;
@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, false, cx);
let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
let query = path!("/root");
insert_query(query, &picker, cx).await;
@ -111,7 +111,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, false, cx);
let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
// Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
let query = path!("/root");
@ -186,7 +186,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
}
#[gpui::test]
#[cfg(target_os = "windows")]
#[cfg_attr(not(target_os = "windows"), ignore)]
async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
@ -204,7 +204,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, false, cx);
let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
// Support both forward and backward slashes.
let query = "C:/root/";
@ -251,6 +251,47 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
);
}
#[gpui::test]
#[cfg_attr(not(target_os = "windows"), ignore)]
async fn test_open_path_prompt_on_windows_with_remote(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
"/root",
json!({
"a": "A",
"dir1": {},
"dir2": {}
}),
)
.await;
let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, false, PathStyle::Posix, cx);
let query = "/root/";
insert_query(query, &picker, cx).await;
assert_eq!(
collect_match_candidates(&picker, cx),
vec!["a", "dir1", "dir2"]
);
assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/a");
// Confirm completion for the query "/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash.
let query = "/root/d";
insert_query(query, &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]);
assert_eq!(confirm_completion(query, 1, &picker, cx), "/root/dir2/");
let query = "/root/d";
insert_query(query, &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]);
assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/dir1/");
}
#[gpui::test]
async fn test_new_path_prompt(cx: &mut TestAppContext) {
let app_state = init_test(cx);
@ -278,7 +319,7 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) {
let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
let (picker, cx) = build_open_path_prompt(project, true, cx);
let (picker, cx) = build_open_path_prompt(project, true, PathStyle::current(), cx);
insert_query(path!("/root"), &picker, cx).await;
assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]);
@ -315,11 +356,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
fn build_open_path_prompt(
project: Entity<Project>,
creating_path: bool,
path_style: PathStyle,
cx: &mut TestAppContext,
) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
let (tx, _) = futures::channel::oneshot::channel();
let lister = project::DirectoryLister::Project(project.clone());
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, path_style);
let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
(