Represent relative paths using a dedicated, separator-agnostic type (#38744)

Closes https://github.com/zed-industries/zed/issues/38690
Closes #37353

### Background

On Windows, paths are normally separated by `\`, unlike mac and linux
where they are separated by `/`. When editing code in a project that
uses a different path style than your local system (e.g. remoting from
Windows to Linux, using WSL, and collaboration between windows and unix
users), the correct separator for a path may differ from the "native"
separator.

Previously, to work around this, Zed converted paths' separators in
numerous places. This was applied to both absolute and relative paths,
leading to incorrect conversions in some cases.

### Solution

Many code paths in Zed use paths that are *relative* to either a
worktree root or a git repository. This PR introduces a dedicated type
for these paths called `RelPath`, which stores the path in the same way
regardless of host platform, and offers `Path`-like manipulation APIs.
RelPath supports *displaying* the path using either separator, so that
we can display paths in a style that is determined at runtime based on
the current project.

The representation of absolute paths is left untouched, for now.
Absolute paths are different from relative paths because (except in
contexts where we know that the path refers to the local filesystem)
they should generally be treated as opaque strings. Currently we use a
mix of types for these paths (std::path::Path, String, SanitizedPath).

Release Notes:

- N/A

---------

Co-authored-by: Cole Miller <cole@zed.dev>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
Co-authored-by: Peter Tripp <petertripp@gmail.com>
Co-authored-by: Smit Barmase <heysmitbarmase@gmail.com>
Co-authored-by: Lukas Wirth <me@lukaswirth.dev>
This commit is contained in:
Max Brunsfeld
2025-09-24 18:57:33 -04:00
committed by GitHub
co-authored by Cole Miller Piotr Osiewicz Peter Tripp Smit Barmase Lukas Wirth
parent 3c626f3758
commit 03f9cf4414
216 changed files with 5873 additions and 5257 deletions
+1 -1
View File
@@ -1509,7 +1509,7 @@ mod fake {
}
fn path_style(&self) -> PathStyle {
PathStyle::current()
PathStyle::local()
}
fn shell(&self) -> String {
+1 -6
View File
@@ -194,7 +194,6 @@ async fn build_remote_server_from_source(
)
.await?;
} else if build_remote_server.contains("cross") {
#[cfg(target_os = "windows")]
use util::paths::SanitizedPath;
delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx);
@@ -216,11 +215,7 @@ async fn build_remote_server_from_source(
);
log::info!("building remote server binary from source for {}", &triple);
// On Windows, the binding needs to be set to the canonical path
#[cfg(target_os = "windows")]
let src = SanitizedPath::new(&smol::fs::canonicalize("./target").await?).to_glob_string();
#[cfg(not(target_os = "windows"))]
let src = "./target";
let src = SanitizedPath::new(&smol::fs::canonicalize("target").await?).to_string();
run_cmd(
Command::new("cross")
+43 -31
View File
@@ -13,6 +13,7 @@ use futures::{
use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task};
use itertools::Itertools;
use parking_lot::Mutex;
use paths::remote_server_dir_relative;
use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
use rpc::proto::Envelope;
pub use settings::SshPortForwardOption;
@@ -27,12 +28,15 @@ use std::{
time::Instant,
};
use tempfile::TempDir;
use util::paths::{PathStyle, RemotePathBuf};
use util::{
paths::{PathStyle, RemotePathBuf},
rel_path::RelPath,
};
pub(crate) struct SshRemoteConnection {
socket: SshSocket,
master_process: Mutex<Option<Child>>,
remote_binary_path: Option<RemotePathBuf>,
remote_binary_path: Option<Arc<RelPath>>,
ssh_platform: RemotePlatform,
ssh_path_style: PathStyle,
ssh_shell: String,
@@ -204,7 +208,7 @@ impl RemoteConnection for SshRemoteConnection {
let mut start_proxy_command = shell_script!(
"exec {binary_path} proxy --identifier {identifier}",
binary_path = &remote_binary_path.to_string(),
binary_path = &remote_binary_path.display(self.path_style()),
identifier = &unique_identifier,
);
@@ -400,7 +404,7 @@ impl SshRemoteConnection {
version: SemanticVersion,
commit: Option<AppCommitSha>,
cx: &mut AsyncApp,
) -> Result<RemotePathBuf> {
) -> Result<Arc<RelPath>> {
let version_str = match release_channel {
ReleaseChannel::Nightly => {
let commit = commit.map(|s| s.full()).unwrap_or_default();
@@ -414,23 +418,21 @@ impl SshRemoteConnection {
release_channel.dev_name(),
version_str
);
let dst_path = RemotePathBuf::new(
paths::remote_server_dir_relative().join(binary_name),
self.ssh_path_style,
);
let dst_path =
paths::remote_server_dir_relative().join(RelPath::new(&binary_name).unwrap());
#[cfg(debug_assertions)]
if let Some(remote_server_path) =
super::build_remote_server_from_source(&self.ssh_platform, delegate.as_ref(), cx)
.await?
{
let tmp_path = RemotePathBuf::new(
paths::remote_server_dir_relative().join(format!(
let tmp_path = paths::remote_server_dir_relative().join(
RelPath::new(&format!(
"download-{}-{}",
std::process::id(),
remote_server_path.file_name().unwrap().to_string_lossy()
)),
self.ssh_path_style,
))
.unwrap(),
);
self.upload_local_server_binary(&remote_server_path, &tmp_path, delegate, cx)
.await?;
@@ -441,7 +443,7 @@ impl SshRemoteConnection {
if self
.socket
.run_command(&dst_path.to_string(), &["version"])
.run_command(&dst_path.display(self.path_style()), &["version"])
.await
.is_ok()
{
@@ -459,9 +461,13 @@ impl SshRemoteConnection {
_ => Ok(Some(AppVersion::global(cx))),
})??;
let tmp_path_gz = RemotePathBuf::new(
PathBuf::from(format!("{}-download-{}.gz", dst_path, std::process::id())),
self.ssh_path_style,
let tmp_path_gz = remote_server_dir_relative().join(
RelPath::new(&format!(
"{}-download-{}.gz",
binary_name,
std::process::id()
))
.unwrap(),
);
if !self.socket.connection_options.upload_binary_over_ssh
&& let Some((url, body)) = delegate
@@ -500,7 +506,7 @@ impl SshRemoteConnection {
&self,
url: &str,
body: &str,
tmp_path_gz: &RemotePathBuf,
tmp_path_gz: &RelPath,
delegate: &Arc<dyn RemoteClientDelegate>,
cx: &mut AsyncApp,
) -> Result<()> {
@@ -510,7 +516,10 @@ impl SshRemoteConnection {
"sh",
&[
"-c",
&shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
&shell_script!(
"mkdir -p {parent}",
parent = parent.display(self.path_style()).as_ref()
),
],
)
.await?;
@@ -533,7 +542,7 @@ impl SshRemoteConnection {
body,
url,
"-o",
&tmp_path_gz.to_string(),
&tmp_path_gz.display(self.path_style()),
],
)
.await
@@ -555,7 +564,7 @@ impl SshRemoteConnection {
body,
url,
"-O",
&tmp_path_gz.to_string(),
&tmp_path_gz.display(self.path_style()),
],
)
.await
@@ -578,7 +587,7 @@ impl SshRemoteConnection {
async fn upload_local_server_binary(
&self,
src_path: &Path,
tmp_path_gz: &RemotePathBuf,
tmp_path_gz: &RelPath,
delegate: &Arc<dyn RemoteClientDelegate>,
cx: &mut AsyncApp,
) -> Result<()> {
@@ -588,7 +597,10 @@ impl SshRemoteConnection {
"sh",
&[
"-c",
&shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
&shell_script!(
"mkdir -p {parent}",
parent = parent.display(self.path_style()).as_ref()
),
],
)
.await?;
@@ -613,33 +625,33 @@ impl SshRemoteConnection {
async fn extract_server_binary(
&self,
dst_path: &RemotePathBuf,
tmp_path: &RemotePathBuf,
dst_path: &RelPath,
tmp_path: &RelPath,
delegate: &Arc<dyn RemoteClientDelegate>,
cx: &mut AsyncApp,
) -> Result<()> {
delegate.set_status(Some("Extracting remote development server"), cx);
let server_mode = 0o755;
let orig_tmp_path = tmp_path.to_string();
let orig_tmp_path = tmp_path.display(self.path_style());
let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
shell_script!(
"gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
server_mode = &format!("{:o}", server_mode),
dst_path = &dst_path.to_string(),
dst_path = &dst_path.display(self.path_style()),
)
} else {
shell_script!(
"chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",
server_mode = &format!("{:o}", server_mode),
dst_path = &dst_path.to_string()
dst_path = &dst_path.display(self.path_style())
)
};
self.socket.run_command("sh", &["-c", &script]).await?;
Ok(())
}
async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> {
async fn upload_file(&self, src_path: &Path, dest_path: &RelPath) -> Result<()> {
log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
let mut command = util::command::new_smol_command("scp");
let output = self
@@ -656,7 +668,7 @@ impl SshRemoteConnection {
.arg(format!(
"{}:{}",
self.socket.connection_options.scp_url(),
dest_path
dest_path.display(self.path_style())
))
.output()
.await?;
@@ -665,7 +677,7 @@ impl SshRemoteConnection {
output.status.success(),
"failed to upload file {} -> {}: {}",
src_path.display(),
dest_path.to_string(),
dest_path.display(self.path_style()),
String::from_utf8_lossy(&output.stderr)
);
Ok(())
@@ -1040,7 +1052,7 @@ fn build_command(
let mut exec = String::from("exec env -C ");
if let Some(working_dir) = working_dir {
let working_dir = RemotePathBuf::new(working_dir.into(), ssh_path_style).to_string();
let working_dir = RemotePathBuf::new(working_dir, ssh_path_style).to_string();
// shlex will wrap the command in single quotes (''), disabling ~ expansion,
// replace with with something that works
+42 -35
View File
@@ -17,7 +17,10 @@ use std::{
sync::Arc,
time::Instant,
};
use util::paths::{PathStyle, RemotePathBuf};
use util::{
paths::{PathStyle, RemotePathBuf},
rel_path::RelPath,
};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct WslConnectionOptions {
@@ -35,7 +38,7 @@ impl From<settings::WslConnection> for WslConnectionOptions {
}
pub(crate) struct WslRemoteConnection {
remote_binary_path: Option<RemotePathBuf>,
remote_binary_path: Option<Arc<RelPath>>,
platform: RemotePlatform,
shell: String,
default_system_shell: String,
@@ -122,7 +125,7 @@ impl WslRemoteConnection {
version: SemanticVersion,
commit: Option<AppCommitSha>,
cx: &mut AsyncApp,
) -> Result<RemotePathBuf> {
) -> Result<Arc<RelPath>> {
let version_str = match release_channel {
ReleaseChannel::Nightly => {
let commit = commit.map(|s| s.full()).unwrap_or_default();
@@ -138,13 +141,11 @@ impl WslRemoteConnection {
version_str
);
let dst_path = RemotePathBuf::new(
paths::remote_wsl_server_dir_relative().join(binary_name),
PathStyle::Posix,
);
let dst_path =
paths::remote_wsl_server_dir_relative().join(RelPath::new(&binary_name).unwrap());
if let Some(parent) = dst_path.parent() {
self.run_wsl_command("mkdir", &["-p", &parent.to_string()])
self.run_wsl_command("mkdir", &["-p", &parent.display(PathStyle::Posix)])
.await
.map_err(|e| anyhow!("Failed to create directory: {}", e))?;
}
@@ -153,13 +154,13 @@ impl WslRemoteConnection {
if let Some(remote_server_path) =
super::build_remote_server_from_source(&self.platform, delegate.as_ref(), cx).await?
{
let tmp_path = RemotePathBuf::new(
paths::remote_wsl_server_dir_relative().join(format!(
let tmp_path = paths::remote_wsl_server_dir_relative().join(
&RelPath::new(&format!(
"download-{}-{}",
std::process::id(),
remote_server_path.file_name().unwrap().to_string_lossy()
)),
PathStyle::Posix,
))
.unwrap(),
);
self.upload_file(&remote_server_path, &tmp_path, delegate, cx)
.await?;
@@ -169,7 +170,7 @@ impl WslRemoteConnection {
}
if self
.run_wsl_command(&dst_path.to_string(), &["version"])
.run_wsl_command(&dst_path.display(PathStyle::Posix), &["version"])
.await
.is_ok()
{
@@ -187,10 +188,12 @@ impl WslRemoteConnection {
.download_server_binary_locally(self.platform, release_channel, wanted_version, cx)
.await?;
let tmp_path = RemotePathBuf::new(
PathBuf::from(format!("{}.{}.gz", dst_path, std::process::id())),
PathStyle::Posix,
let tmp_path = format!(
"{}.{}.gz",
dst_path.display(PathStyle::Posix),
std::process::id()
);
let tmp_path = RelPath::new(&tmp_path).unwrap();
self.upload_file(&src_path, &tmp_path, delegate, cx).await?;
self.extract_and_install(&tmp_path, &dst_path, delegate, cx)
@@ -202,14 +205,14 @@ impl WslRemoteConnection {
async fn upload_file(
&self,
src_path: &Path,
dst_path: &RemotePathBuf,
dst_path: &RelPath,
delegate: &Arc<dyn RemoteClientDelegate>,
cx: &mut AsyncApp,
) -> Result<()> {
delegate.set_status(Some("Uploading remote server to WSL"), cx);
if let Some(parent) = dst_path.parent() {
self.run_wsl_command("mkdir", &["-p", &parent.to_string()])
self.run_wsl_command("mkdir", &["-p", &parent.display(PathStyle::Posix)])
.await
.map_err(|e| anyhow!("Failed to create directory when uploading file: {}", e))?;
}
@@ -224,17 +227,20 @@ impl WslRemoteConnection {
);
let src_path_in_wsl = self.windows_path_to_wsl_path(src_path).await?;
self.run_wsl_command("cp", &["-f", &src_path_in_wsl, &dst_path.to_string()])
.await
.map_err(|e| {
anyhow!(
"Failed to copy file {}({}) to WSL {:?}: {}",
src_path.display(),
src_path_in_wsl,
dst_path,
e
)
})?;
self.run_wsl_command(
"cp",
&["-f", &src_path_in_wsl, &dst_path.display(PathStyle::Posix)],
)
.await
.map_err(|e| {
anyhow!(
"Failed to copy file {}({}) to WSL {:?}: {}",
src_path.display(),
src_path_in_wsl,
dst_path,
e
)
})?;
log::info!("uploaded remote server in {:?}", t0.elapsed());
Ok(())
@@ -242,15 +248,15 @@ impl WslRemoteConnection {
async fn extract_and_install(
&self,
tmp_path: &RemotePathBuf,
dst_path: &RemotePathBuf,
tmp_path: &RelPath,
dst_path: &RelPath,
delegate: &Arc<dyn RemoteClientDelegate>,
cx: &mut AsyncApp,
) -> Result<()> {
delegate.set_status(Some("Extracting remote server"), cx);
let tmp_path_str = tmp_path.to_string();
let dst_path_str = dst_path.to_string();
let tmp_path_str = tmp_path.display(PathStyle::Posix);
let dst_path_str = dst_path.display(PathStyle::Posix);
// Build extraction script with proper error handling
let script = if tmp_path_str.ends_with(".gz") {
@@ -293,7 +299,8 @@ impl RemoteConnection for WslRemoteConnection {
let mut proxy_command = format!(
"exec {} proxy --identifier {}",
remote_binary_path, unique_identifier
remote_binary_path.display(PathStyle::Posix),
unique_identifier
);
if reconnect {
@@ -377,7 +384,7 @@ impl RemoteConnection for WslRemoteConnection {
}
let working_dir = working_dir
.map(|working_dir| RemotePathBuf::new(working_dir.into(), PathStyle::Posix).to_string())
.map(|working_dir| RemotePathBuf::new(working_dir, PathStyle::Posix).to_string())
.unwrap_or("~".to_string());
let mut script = String::new();