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 -3
View File
@@ -96,9 +96,7 @@ impl Tool for CopyPathTool {
.and_then(|project_path| project.entry_for_path(&project_path, cx))
{
Some(entity) => match project.find_project_path(&input.destination_path, cx) {
Some(project_path) => {
project.copy_entry(entity.id, None, project_path.path, cx)
}
Some(project_path) => project.copy_entry(entity.id, project_path, cx),
None => Task::ready(Err(anyhow!(
"Destination path {} was outside the project.",
input.destination_path
@@ -8,7 +8,7 @@ use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchem
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{fmt::Write, path::Path, sync::Arc};
use std::{fmt::Write, sync::Arc};
use ui::IconName;
use util::markdown::MarkdownInlineCode;
@@ -150,9 +150,7 @@ impl Tool for DiagnosticsTool {
has_diagnostics = true;
output.push_str(&format!(
"{}: {} error(s), {} warning(s)\n",
Path::new(worktree.read(cx).root_name())
.join(project_path.path)
.display(),
worktree.read(cx).absolutize(&project_path.path).display(),
summary.error_count,
summary.warning_count
));
+18 -28
View File
@@ -38,6 +38,7 @@ use settings::Settings;
use std::{
cmp::Reverse,
collections::HashSet,
ffi::OsStr,
ops::Range,
path::{Path, PathBuf},
sync::Arc,
@@ -45,7 +46,7 @@ use std::{
};
use theme::ThemeSettings;
use ui::{CommonAnimationExt, Disclosure, Tooltip, prelude::*};
use util::ResultExt;
use util::{ResultExt, rel_path::RelPath};
use workspace::Workspace;
pub struct EditFileTool;
@@ -146,11 +147,11 @@ impl Tool for EditFileTool {
// If any path component matches the local settings folder, then this could affect
// the editor in ways beyond the project source, so prompt.
let local_settings_folder = paths::local_settings_folder_relative_path();
let local_settings_folder = paths::local_settings_folder_name();
let path = Path::new(&input.path);
if path
.components()
.any(|component| component.as_os_str() == local_settings_folder.as_os_str())
.any(|c| c.as_os_str() == <str as AsRef<OsStr>>::as_ref(local_settings_folder))
{
return true;
}
@@ -195,10 +196,10 @@ impl Tool for EditFileTool {
let mut description = input.display_description.clone();
// Add context about why confirmation may be needed
let local_settings_folder = paths::local_settings_folder_relative_path();
let local_settings_folder = paths::local_settings_folder_name();
if path
.components()
.any(|c| c.as_os_str() == local_settings_folder.as_os_str())
.any(|c| c.as_os_str() == <str as AsRef<OsStr>>::as_ref(local_settings_folder))
{
description.push_str(" (local settings)");
} else if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
@@ -377,7 +378,7 @@ impl Tool for EditFileTool {
.await;
let output = EditFileToolOutput {
original_path: project_path.path.to_path_buf(),
original_path: project_path.path.as_std_path().to_owned(),
new_text,
old_text,
raw_output: Some(agent_output),
@@ -549,10 +550,11 @@ fn resolve_path(
let file_name = input
.path
.file_name()
.and_then(|file_name| file_name.to_str())
.context("Can't create file: invalid filename")?;
let new_file_path = parent_project_path.map(|parent| ProjectPath {
path: Arc::from(parent.path.join(file_name)),
path: parent.path.join(RelPath::new(file_name).unwrap()),
..parent
});
@@ -1236,7 +1238,7 @@ mod tests {
use serde_json::json;
use settings::SettingsStore;
use std::fs;
use util::path;
use util::{path, rel_path::rel_path};
#[gpui::test]
async fn test_edit_nonexistent_file(cx: &mut TestAppContext) {
@@ -1355,14 +1357,10 @@ mod tests {
cx.update(|cx| resolve_path(&input, project, cx))
}
#[track_caller]
fn assert_resolved_path_eq(path: anyhow::Result<ProjectPath>, expected: &str) {
let actual = path
.expect("Should return valid path")
.path
.to_str()
.unwrap()
.replace("\\", "/"); // Naive Windows paths normalization
assert_eq!(actual, expected);
let actual = path.expect("Should return valid path").path;
assert_eq!(actual.as_ref(), rel_path(expected));
}
#[test]
@@ -1976,25 +1974,22 @@ mod tests {
let project = Project::test(fs.clone(), [path!("/home/user/myproject").as_ref()], cx).await;
// Get the actual local settings folder name
let local_settings_folder = paths::local_settings_folder_relative_path();
let local_settings_folder = paths::local_settings_folder_name();
// Test various config path patterns
let test_cases = vec![
(
format!("{}/settings.json", local_settings_folder.display()),
format!("{local_settings_folder}/settings.json"),
true,
"Top-level local settings file".to_string(),
),
(
format!(
"myproject/{}/settings.json",
local_settings_folder.display()
),
format!("myproject/{local_settings_folder}/settings.json"),
true,
"Local settings in project path".to_string(),
),
(
format!("src/{}/config.toml", local_settings_folder.display()),
format!("src/{local_settings_folder}/config.toml"),
true,
"Local settings in subdirectory".to_string(),
),
@@ -2205,12 +2200,7 @@ mod tests {
("", false, "Empty path is treated as project root"),
// Root directory
("/", true, "Root directory should be outside project"),
// Parent directory references - find_project_path resolves these
(
"project/../other",
false,
"Path with .. is resolved by find_project_path",
),
("project/../other", true, "Path with .. is outside project"),
(
"project/./src/file.rs",
false,
+14 -6
View File
@@ -161,10 +161,13 @@ impl Tool for FindPathTool {
}
fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Result<Vec<PathBuf>>> {
let path_matcher = match PathMatcher::new([
// Sometimes models try to search for "". In this case, return all paths in the project.
if glob.is_empty() { "*" } else { glob },
]) {
let path_matcher = match PathMatcher::new(
[
// Sometimes models try to search for "". In this case, return all paths in the project.
if glob.is_empty() { "*" } else { glob },
],
project.read(cx).path_style(cx),
) {
Ok(matcher) => matcher,
Err(err) => return Task::ready(Err(anyhow!("Invalid glob: {err}"))),
};
@@ -178,10 +181,15 @@ fn search_paths(glob: &str, project: Entity<Project>, cx: &mut App) -> Task<Resu
Ok(snapshots
.iter()
.flat_map(|snapshot| {
let root_name = PathBuf::from(snapshot.root_name());
snapshot
.entries(false, 0)
.map(move |entry| root_name.join(&entry.path))
.map(move |entry| {
snapshot
.root_name()
.join(&entry.path)
.as_std_path()
.to_path_buf()
})
.filter(|path| path_matcher.is_match(&path))
})
.collect())
+2 -1
View File
@@ -125,6 +125,7 @@ impl Tool for GrepTool {
.as_ref()
.into_iter()
.collect::<Vec<_>>(),
project.read(cx).path_style(cx),
) {
Ok(matcher) => matcher,
Err(error) => {
@@ -141,7 +142,7 @@ impl Tool for GrepTool {
.iter()
.chain(global_settings.private_files.sources().iter());
match PathMatcher::new(exclude_patterns) {
match PathMatcher::new(exclude_patterns, project.read(cx).path_style(cx)) {
Ok(matcher) => matcher,
Err(error) => {
return Task::ready(Err(anyhow!("invalid exclude pattern: {error}"))).into();
@@ -4,11 +4,11 @@ use anyhow::{Result, anyhow};
use assistant_tool::{Tool, ToolResult};
use gpui::{AnyWindowHandle, App, Entity, Task};
use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat};
use project::{Project, WorktreeSettings};
use project::{Project, ProjectPath, WorktreeSettings};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::{fmt::Write, path::Path, sync::Arc};
use std::{fmt::Write, sync::Arc};
use ui::IconName;
use util::markdown::MarkdownInlineCode;
@@ -100,7 +100,7 @@ impl Tool for ListDirectoryTool {
.filter_map(|worktree| {
worktree.read(cx).root_entry().and_then(|entry| {
if entry.is_dir() {
entry.path.to_str()
Some(entry.path.as_str())
} else {
None
}
@@ -158,7 +158,6 @@ impl Tool for ListDirectoryTool {
}
let worktree_snapshot = worktree.read(cx).snapshot();
let worktree_root_name = worktree.read(cx).root_name().to_string();
let Some(entry) = worktree_snapshot.entry_for_path(&project_path.path) else {
return Task::ready(Err(anyhow!("Path not found: {}", input.path))).into();
@@ -180,23 +179,22 @@ impl Tool for ListDirectoryTool {
continue;
}
if project
.read(cx)
.find_project_path(&entry.path, cx)
.map(|project_path| {
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
let project_path = ProjectPath {
worktree_id: worktree_snapshot.id(),
path: entry.path.clone(),
};
let worktree_settings = WorktreeSettings::get(Some((&project_path).into()), cx);
worktree_settings.is_path_excluded(&project_path.path)
|| worktree_settings.is_path_private(&project_path.path)
})
.unwrap_or(false)
if worktree_settings.is_path_excluded(&project_path.path)
|| worktree_settings.is_path_private(&project_path.path)
{
continue;
}
let full_path = Path::new(&worktree_root_name)
let full_path = worktree_snapshot
.root_name()
.join(&entry.path)
.display()
.display(worktree_snapshot.path_style())
.to_string();
if entry.is_dir() {
folders.push(full_path);
+1 -1
View File
@@ -108,7 +108,7 @@ impl Tool for MovePathTool {
.and_then(|project_path| project.entry_for_path(&project_path, cx))
{
Some(entity) => match project.find_project_path(&input.destination_path, cx) {
Some(project_path) => project.rename_entry(entity.id, project_path.path, cx),
Some(project_path) => project.rename_entry(entity.id, project_path, cx),
None => Task::ready(Err(anyhow!(
"Destination path {} was outside the project.",
input.destination_path