first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+15 -1
View File
@@ -18,8 +18,22 @@ impl ContentVersion {
pub fn as_i32(&self) -> i32 {
self.0 as i32
}
/// Reconstructs a `ContentVersion` from a raw value received over the wire.
///
/// This bypasses the global atomic counter and should only be used at
/// protocol deserialization boundaries (e.g. converting a `u64` from a
/// proto message back into a `ContentVersion`).
pub fn from_raw(val: usize) -> Self {
ContentVersion(val)
}
/// Returns the underlying value as a `u64` for wire serialization.
pub fn as_u64(&self) -> u64 {
self.0 as u64
}
}
#[cfg(test)]
#[path = "content_version_test.rs"]
#[path = "content_version_tests.rs"]
mod tests;
@@ -0,0 +1,42 @@
use super::*;
#[test]
fn test_create_version() {
ContentVersion::new();
}
#[test]
fn test_versions_equal() {
let version1 = ContentVersion::new();
let version2 = version1;
assert_eq!(version1, version2);
}
#[test]
fn test_versions_not_equal() {
let version1 = ContentVersion::new();
let version2 = ContentVersion::new();
assert_ne!(version1, version2);
}
#[test]
fn test_from_raw_roundtrip() {
let version = ContentVersion::from_raw(42);
assert_eq!(version.as_u64(), 42);
}
#[test]
fn test_from_raw_preserves_equality() {
let a = ContentVersion::from_raw(7);
let b = ContentVersion::from_raw(7);
assert_eq!(a, b);
}
#[test]
fn test_as_u64_matches_as_i32() {
let version = ContentVersion::from_raw(100);
assert_eq!(version.as_u64(), 100);
assert_eq!(version.as_i32(), 100);
}
+3 -5
View File
@@ -1,8 +1,6 @@
use std::{
io,
path::PathBuf,
sync::atomic::{AtomicUsize, Ordering},
};
use std::io;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
#[derive(thiserror::Error, Debug)]
pub enum FileSaveError {
+16 -122
View File
@@ -4,18 +4,22 @@
//! based on its filename and extension. It uses a hybrid approach combining MIME type detection
//! with explicit extension checking for edge cases.
use content_inspector::{inspect, ContentType};
use mime_guess::{self, mime};
use std::fs::File;
use std::io::Read;
use std::path::Path;
use content_inspector::{inspect, ContentType};
use mime_guess::{self, mime};
/// File extensions for Markdown files.
const MARKDOWN_EXTENSIONS: &[&str] = &["md", "markdown"];
/// Names of files that are typically Markdown or plain text.
const MARKDOWN_FILE_NAMES: &[&str] = &["README", "CHANGELOG", "LICENSE"];
/// File extension for Jupyter notebook files.
const JUPYTER_NOTEBOOK_EXTENSION: &str = "ipynb";
/// Checks if a buffer appears to contain binary content.
/// Returns true if the buffer appears to be binary, false if it appears to be text.
pub fn is_buffer_binary(buffer: &[u8]) -> bool {
@@ -140,6 +144,13 @@ pub fn is_markdown_file(path: impl AsRef<Path>) -> bool {
}
}
/// Guess whether or not `path` is a Jupyter notebook file based on its `.ipynb` extension.
pub fn is_jupyter_notebook_file(path: impl AsRef<Path>) -> bool {
path.as_ref()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case(JUPYTER_NOTEBOOK_EXTENSION))
}
/// Determines if a file is likely to be a text file based on its filename.
///
/// This function uses a hybrid approach:
@@ -226,7 +237,7 @@ fn is_development_text_extension(extension: &str) -> bool {
"svelte" | "astro" | "blade" | "twig" | "mustache" | "hbs" |
"handlebars" | "ejs" | "pug" | "jade" | "erb" | "haml" |
// Configuration and data formats
"toml" | "yaml" | "yml" | "json" | "jsonc" | "json5" |
"toml" | "yaml" | "yml" | "json" | "jsonc" | "json5" | "jq" |
"xml" | "ini" | "cfg" | "conf" | "config" | "properties" |
"env" | "dotenv" | "editorconfig" | "gitignore" | "gitattributes" |
// Documentation
@@ -287,122 +298,5 @@ fn is_extensionless_text_file(filename: &str) -> bool {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_common_text_files() {
// Programming languages
assert!(is_text_file("main.rs"));
assert!(is_text_file("script.py"));
assert!(is_text_file("app.js"));
assert!(is_text_file("component.tsx"));
assert!(is_text_file("Main.java"));
assert!(is_text_file("header.h"));
assert!(is_text_file("script.sh"));
// Web files
assert!(is_text_file("index.html"));
assert!(is_text_file("styles.css"));
assert!(is_text_file("component.vue"));
// Configuration files
assert!(is_text_file("config.json"));
assert!(is_text_file("settings.yaml"));
assert!(is_text_file("Cargo.toml"));
assert!(is_text_file(".gitignore"));
assert!(is_text_file(".env"));
// Documentation
assert!(is_text_file("README.md"));
assert!(is_text_file("docs.txt"));
assert!(is_text_file("manual.rst"));
// Build files
assert!(is_text_file("Dockerfile"));
assert!(is_text_file("Makefile"));
assert!(is_text_file("build.gradle"));
// Files without extensions
assert!(is_text_file("README"));
assert!(is_text_file("LICENSE"));
assert!(is_text_file("Dockerfile"));
}
#[test]
fn test_binary_files() {
// Images
assert!(!is_text_file("image.png"));
assert!(!is_text_file("photo.jpg"));
assert!(!is_text_file("icon.ico"));
// Note: SVG might be detected as text by MIME, which is correct
// Executables
assert!(!is_text_file("program.exe"));
assert!(!is_text_file("app.dmg"));
// Archives
assert!(!is_text_file("archive.zip"));
assert!(!is_text_file("package.tar.gz"));
assert!(!is_text_file("data.7z"));
// Media files
assert!(!is_text_file("video.mp4"));
assert!(!is_text_file("audio.mp3"));
assert!(!is_text_file("sound.wav"));
// Document formats (binary)
assert!(!is_text_file("document.pdf"));
assert!(!is_text_file("spreadsheet.xlsx"));
assert!(!is_text_file("presentation.pptx"));
}
#[test]
fn test_edge_cases() {
// Empty filename
assert!(!is_text_file(""));
// Files with multiple extensions
assert!(is_text_file("backup.tar.gz.txt"));
assert!(is_text_file("config.local.json"));
// Mixed case
assert!(is_text_file("Component.TSX"));
assert!(is_text_file("README.MD"));
// Path separators
assert!(is_text_file("/path/to/file.rs"));
assert!(is_text_file("..\\windows\\path\\file.py"));
// Unusual but valid text files
assert!(is_text_file("script.fish"));
assert!(is_text_file("data.graphql"));
assert!(is_text_file("schema.proto"));
}
#[test]
fn test_development_extensions() {
// Test some specific development file types
assert!(is_development_text_extension("rs"));
assert!(is_development_text_extension("py"));
assert!(is_development_text_extension("dockerfile"));
assert!(is_development_text_extension("yaml"));
assert!(!is_development_text_extension("png"));
assert!(!is_development_text_extension("exe"));
assert!(!is_development_text_extension("zip"));
}
#[test]
fn test_extensionless_files() {
assert!(is_extensionless_text_file("README"));
assert!(is_extensionless_text_file("LICENSE"));
assert!(is_extensionless_text_file("Dockerfile"));
assert!(is_extensionless_text_file(".gitignore"));
assert!(is_extensionless_text_file(".env"));
assert!(!is_extensionless_text_file("binary"));
assert!(!is_extensionless_text_file("unknown"));
assert!(!is_extensionless_text_file("data"));
}
}
#[path = "file_type_tests.rs"]
mod tests;
+129
View File
@@ -0,0 +1,129 @@
use super::*;
#[test]
fn test_common_text_files() {
// Programming languages
assert!(is_text_file("main.rs"));
assert!(is_text_file("script.py"));
assert!(is_text_file("app.js"));
assert!(is_text_file("component.tsx"));
assert!(is_text_file("Main.java"));
assert!(is_text_file("header.h"));
assert!(is_text_file("script.sh"));
// Web files
assert!(is_text_file("index.html"));
assert!(is_text_file("styles.css"));
assert!(is_text_file("component.vue"));
// Configuration files
assert!(is_text_file("config.json"));
assert!(is_text_file("settings.yaml"));
assert!(is_text_file("Cargo.toml"));
assert!(is_text_file(".gitignore"));
assert!(is_text_file(".env"));
// Documentation
assert!(is_text_file("README.md"));
assert!(is_text_file("docs.txt"));
assert!(is_text_file("manual.rst"));
// Build files
assert!(is_text_file("Dockerfile"));
assert!(is_text_file("Makefile"));
assert!(is_text_file("build.gradle"));
// Files without extensions
assert!(is_text_file("README"));
assert!(is_text_file("LICENSE"));
assert!(is_text_file("Dockerfile"));
}
#[test]
fn test_binary_files() {
// Images
assert!(!is_text_file("image.png"));
assert!(!is_text_file("photo.jpg"));
assert!(!is_text_file("icon.ico"));
// Note: SVG might be detected as text by MIME, which is correct
// Executables
assert!(!is_text_file("program.exe"));
assert!(!is_text_file("app.dmg"));
// Archives
assert!(!is_text_file("archive.zip"));
assert!(!is_text_file("package.tar.gz"));
assert!(!is_text_file("data.7z"));
// Media files
assert!(!is_text_file("video.mp4"));
assert!(!is_text_file("audio.mp3"));
assert!(!is_text_file("sound.wav"));
// Document formats (binary)
assert!(!is_text_file("document.pdf"));
assert!(!is_text_file("spreadsheet.xlsx"));
assert!(!is_text_file("presentation.pptx"));
}
#[test]
fn test_edge_cases() {
// Empty filename
assert!(!is_text_file(""));
// Files with multiple extensions
assert!(is_text_file("backup.tar.gz.txt"));
assert!(is_text_file("config.local.json"));
// Mixed case
assert!(is_text_file("Component.TSX"));
assert!(is_text_file("README.MD"));
// Path separators
assert!(is_text_file("/path/to/file.rs"));
assert!(is_text_file("..\\windows\\path\\file.py"));
// Unusual but valid text files
assert!(is_text_file("script.fish"));
assert!(is_text_file("data.graphql"));
assert!(is_text_file("schema.proto"));
}
#[test]
fn test_development_extensions() {
// Test some specific development file types
assert!(is_development_text_extension("rs"));
assert!(is_development_text_extension("py"));
assert!(is_development_text_extension("dockerfile"));
assert!(is_development_text_extension("yaml"));
assert!(!is_development_text_extension("png"));
assert!(!is_development_text_extension("exe"));
assert!(!is_development_text_extension("zip"));
}
#[test]
fn test_is_jupyter_notebook_file() {
assert!(is_jupyter_notebook_file("analysis.ipynb"));
assert!(is_jupyter_notebook_file("Analysis.IPYNB"));
assert!(is_jupyter_notebook_file("/path/to/notebook.ipynb"));
assert!(!is_jupyter_notebook_file("notebook.json"));
assert!(!is_jupyter_notebook_file("README.md"));
assert!(!is_jupyter_notebook_file("ipynb"));
assert!(!is_jupyter_notebook_file("notebook"));
}
#[test]
fn test_extensionless_files() {
assert!(is_extensionless_text_file("README"));
assert!(is_extensionless_text_file("LICENSE"));
assert!(is_extensionless_text_file("Dockerfile"));
assert!(is_extensionless_text_file(".gitignore"));
assert!(is_extensionless_text_file(".env"));
assert!(!is_extensionless_text_file("binary"));
assert!(!is_extensionless_text_file("unknown"));
assert!(!is_extensionless_text_file("data"));
}
+71
View File
@@ -0,0 +1,71 @@
use std::path::Path;
use anyhow::{anyhow, Result};
/// Runs a git command and returns the output as a string.
/// Thin wrapper over [`run_git_command_with_env`] with no `PATH` override.
#[cfg(not(target_family = "wasm"))]
pub async fn run_git_command(repo_path: &Path, args: &[&str]) -> Result<String> {
run_git_command_with_env(repo_path, args, None).await
}
/// Like [`run_git_command`] but sets `PATH` on the child when `path_env` is
/// `Some`. Used by callers whose hooks need user-installed binaries (e.g.
/// the LFS `pre-push` hook → `git-lfs`). See `specs/APP-4188/TECH.md`.
#[cfg(not(target_family = "wasm"))]
pub async fn run_git_command_with_env(
repo_path: &Path,
args: &[&str],
path_env: Option<&str>,
) -> Result<String> {
use command::r#async::Command;
use command::Stdio;
log::debug!(
"[GIT OPERATION] git.rs run_git_command git {}",
args.join(" ")
);
let mut cmd = Command::new("git");
cmd.arg("-c")
.arg("diff.autoRefreshIndex=false")
.args(args)
.current_dir(repo_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.env("GIT_OPTIONAL_LOCKS", "0")
.kill_on_drop(true);
if let Some(path_env) = path_env {
cmd.env("PATH", path_env);
}
let output = cmd
.output()
.await
.map_err(|e| anyhow!("Failed to execute git command: {}", e))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr);
// Handle git diff specific behavior:
// - Exit code 0: no differences
// - Exit code 1: differences found (this is normal for diff commands)
// - Exit code > 1: actual error
if output.status.success() || (output.status.code() == Some(1) && !stdout.is_empty()) {
Ok(stdout)
} else {
Err(anyhow!("Git command failed: {}, {}", stderr, stdout))
}
}
#[cfg(target_family = "wasm")]
pub async fn run_git_command(_repo_path: &Path, _args: &[&str]) -> Result<String> {
Err(anyhow!("Not supported on wasm"))
}
#[cfg(target_family = "wasm")]
pub async fn run_git_command_with_env(
_repo_path: &Path,
_args: &[&str],
_path_env: Option<&str>,
) -> Result<String> {
Err(anyhow!("Not supported on wasm"))
}
+27
View File
@@ -0,0 +1,27 @@
use std::fmt;
use serde::{Deserialize, Serialize};
/// Opaque identifier for a remote host.
///
/// Returned by the server in `InitializeResponse`. Used by
/// `RemoteServerManager` and downstream features to deduplicate
/// host-scoped models (e.g. `RepoMetadataModel`) across sessions.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct HostId(String);
impl HostId {
pub fn new(id: String) -> Self {
Self(id)
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl fmt::Display for HostId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
+5
View File
@@ -7,9 +7,14 @@ pub mod assets;
pub mod content_version;
pub mod file;
pub mod file_type;
pub mod git;
pub mod host_id;
pub mod local_or_remote_path;
pub mod on_cancel;
pub mod path;
pub mod remote_path;
pub mod standardized_path;
pub mod sync;
pub mod user_input;
pub mod worktree_names;
@@ -0,0 +1,203 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::remote_path::RemotePath;
use crate::standardized_path::StandardizedPath;
/// Uniquely identifies where a file lives — either on the local filesystem
/// or on a remote host. Used across both the buffer model and the
/// editor/view layers as the canonical file-identity type.
#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
pub enum LocalOrRemotePath {
/// File on the local filesystem.
Local(PathBuf),
/// File on a remote host, identified by host + path.
Remote(RemotePath),
}
impl LocalOrRemotePath {
/// Returns `true` if this is a `Local` location.
pub fn is_local(&self) -> bool {
matches!(self, LocalOrRemotePath::Local(_))
}
/// Returns `true` if this is a `Remote` location.
pub fn is_remote(&self) -> bool {
matches!(self, LocalOrRemotePath::Remote(_))
}
/// Returns the standardized path component of the location, regardless of where it lives.
pub fn path_component(&self) -> StandardizedPath {
match self {
LocalOrRemotePath::Local(path) => StandardizedPath::from_local_absolute_unchecked(path),
LocalOrRemotePath::Remote(remote) => remote.path.clone(),
}
}
/// Returns the file name component for display (e.g. tab titles).
pub fn display_name(&self) -> &str {
match self {
LocalOrRemotePath::Local(path) => path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default(),
LocalOrRemotePath::Remote(remote) => remote.path.file_name().unwrap_or_default(),
}
}
/// Returns a displayable path string.
pub fn display_path(&self) -> String {
match self {
LocalOrRemotePath::Local(path) => path.to_string_lossy().to_string(),
LocalOrRemotePath::Remote(remote) => {
format!("{}", remote.path)
}
}
}
/// Returns this location's parent, preserving remote host identity.
pub fn parent(&self) -> Option<LocalOrRemotePath> {
match self {
LocalOrRemotePath::Local(path) => path
.parent()
.map(|parent| LocalOrRemotePath::Local(parent.to_path_buf())),
LocalOrRemotePath::Remote(remote) => remote.path.parent().map(|parent| {
LocalOrRemotePath::Remote(RemotePath::new(remote.host_id.clone(), parent))
}),
}
}
/// Returns the file name component, regardless of where the path lives.
pub fn file_name(&self) -> Option<&str> {
match self {
LocalOrRemotePath::Local(path) => path.file_name().and_then(|name| name.to_str()),
LocalOrRemotePath::Remote(remote) => remote.path.file_name(),
}
}
/// Returns whether this location starts with `base`.
///
/// Remote locations only compare as ancestors when they are on the same
/// host. This prevents `/repo` on one host from matching `/repo` on another.
pub fn starts_with(&self, base: &LocalOrRemotePath) -> bool {
match (self, base) {
(LocalOrRemotePath::Local(path), LocalOrRemotePath::Local(base)) => {
path.starts_with(base)
}
(LocalOrRemotePath::Remote(path), LocalOrRemotePath::Remote(base)) => {
path.host_id == base.host_id && path.path.starts_with(&base.path)
}
_ => false,
}
}
/// Returns the local path if this is a `Local` location, `None` for `Remote`.
/// Callers that only work with local files (LSP, save-to-disk, reveal-in-finder)
/// should use this to gate their behavior.
pub fn to_local_path(&self) -> Option<&Path> {
match self {
LocalOrRemotePath::Local(path) => Some(path.as_path()),
LocalOrRemotePath::Remote(_) => None,
}
}
/// Returns the remote path if this is a `Remote` location, `None` for `Local`.
/// Callers that only work with remote files should use this to gate their
/// behavior.
pub fn as_remote(&self) -> Option<&RemotePath> {
match self {
LocalOrRemotePath::Local(_) => None,
LocalOrRemotePath::Remote(remote) => Some(remote),
}
}
/// Joins a (typically repo-relative) segment onto this location, preserving
/// the host.
///
/// Accepts a `&str` rather than a `&Path` so that no caller is forced to
/// construct a local-filesystem path type when working with paths that
/// may originate from a remote host.
///
/// For `Local`, this delegates to `PathBuf::join` and yields a new local
/// path. For `Remote`, the host id is carried through and only the
/// path component is extended.
///
/// Note: if `segment` is itself absolute, the standard `Path::join`
/// replacement semantics apply (the joined result is `segment`), so
/// callers that already hold an absolute path from a wire decode will
/// get back the absolute path unchanged — modulo host preservation on
/// the remote side.
pub fn join(&self, segment: &str) -> LocalOrRemotePath {
match self {
LocalOrRemotePath::Local(path) => LocalOrRemotePath::Local(path.join(segment)),
LocalOrRemotePath::Remote(remote) => {
let joined = remote.path.join(segment);
LocalOrRemotePath::Remote(RemotePath::new(remote.host_id.clone(), joined))
}
}
}
/// If `file` shares this location's host and starts with this location's
/// path, returns the relative remainder as a `String`. Returns `None`
/// when the hosts differ or when `file` is not under this location.
///
/// Returns a `String` (rather than `PathBuf`) so that callers do not
/// implicitly assume the relative remainder lives on the local
/// filesystem — remote paths may use a different encoding than the host
/// the client is running on.
///
/// Use this when you want to compute a repo-relative path from an
/// absolute file path without silently dropping the host id (as
/// `path_component().strip_prefix(...)` would).
pub fn strip_repo_prefix(&self, file: &LocalOrRemotePath) -> Option<String> {
match (self, file) {
(LocalOrRemotePath::Local(repo), LocalOrRemotePath::Local(f)) => f
.strip_prefix(repo)
.ok()
.map(|p| p.to_string_lossy().into_owned()),
(LocalOrRemotePath::Remote(repo), LocalOrRemotePath::Remote(f))
if repo.host_id == f.host_id =>
{
f.path.strip_prefix(&repo.path).map(str::to_owned)
}
_ => None,
}
}
}
impl From<PathBuf> for LocalOrRemotePath {
fn from(path: PathBuf) -> Self {
LocalOrRemotePath::Local(path)
}
}
impl From<RemotePath> for LocalOrRemotePath {
fn from(remote: RemotePath) -> Self {
LocalOrRemotePath::Remote(remote)
}
}
impl TryFrom<LocalOrRemotePath> for PathBuf {
type Error = RemotePath;
fn try_from(location: LocalOrRemotePath) -> Result<Self, Self::Error> {
match location {
LocalOrRemotePath::Local(path) => Ok(path),
LocalOrRemotePath::Remote(remote) => Err(remote),
}
}
}
impl TryFrom<&LocalOrRemotePath> for PathBuf {
type Error = ();
fn try_from(location: &LocalOrRemotePath) -> Result<Self, Self::Error> {
match location {
LocalOrRemotePath::Local(path) => Ok(path.clone()),
LocalOrRemotePath::Remote(_) => Err(()),
}
}
}
#[cfg(test)]
#[path = "local_or_remote_path_tests.rs"]
mod tests;
@@ -0,0 +1,203 @@
use crate::host_id::HostId;
use crate::local_or_remote_path::LocalOrRemotePath;
use crate::remote_path::RemotePath;
use crate::standardized_path::StandardizedPath;
fn test_host_id() -> HostId {
HostId::new("test-host".to_string())
}
fn local_repo_path() -> std::path::PathBuf {
#[cfg(unix)]
let path = "/repo";
#[cfg(windows)]
let path = r"C:\repo";
path.into()
}
fn local_file_path() -> std::path::PathBuf {
local_repo_path().join("file.txt")
}
fn local_absolute_file_path() -> std::path::PathBuf {
#[cfg(unix)]
let path = "/server/repo/src/foo.rs";
#[cfg(windows)]
let path = r"C:\server\repo\src\foo.rs";
path.into()
}
#[test]
fn local_or_remote_path_helpers_return_local_path_components() {
let local_file = local_file_path();
let path = LocalOrRemotePath::Local(local_file.clone());
assert_eq!(path.display_name(), "file.txt");
assert_eq!(
path.path_component(),
StandardizedPath::try_from_local(&local_file).unwrap()
);
assert_eq!(
path.display_path(),
local_file.to_string_lossy().into_owned()
);
assert_eq!(path.to_local_path(), Some(local_file.as_path()));
}
#[test]
fn local_or_remote_path_helpers_return_remote_path_components() {
let path = LocalOrRemotePath::Remote(RemotePath::new(
test_host_id(),
StandardizedPath::try_new("/tmp/repo/file.txt").unwrap(),
));
assert_eq!(path.display_name(), "file.txt");
assert_eq!(
path.path_component(),
StandardizedPath::try_new("/tmp/repo/file.txt").unwrap()
);
assert_eq!(path.display_path(), "/tmp/repo/file.txt");
assert_eq!(path.to_local_path(), None);
}
#[test]
fn local_or_remote_path_is_local_and_is_remote_classify_variants() {
let local = LocalOrRemotePath::Local(local_repo_path());
let remote = LocalOrRemotePath::Remote(RemotePath::new(
test_host_id(),
StandardizedPath::try_new("/tmp/repo").unwrap(),
));
assert!(local.is_local());
assert!(!local.is_remote());
assert!(!remote.is_local());
assert!(remote.is_remote());
}
#[test]
fn local_or_remote_path_join_preserves_host_for_remote() {
let local_repo = local_repo_path();
let local = LocalOrRemotePath::Local(local_repo.clone());
let remote = LocalOrRemotePath::Remote(RemotePath::new(
test_host_id(),
StandardizedPath::try_new("/repo").unwrap(),
));
let local_joined = local.join("src/foo.rs");
let expected_local_joined = local_repo.join("src/foo.rs");
assert_eq!(
local_joined.path_component(),
StandardizedPath::try_from_local(&expected_local_joined).unwrap()
);
assert!(local_joined.is_local());
let remote_joined = remote.join("src/foo.rs");
assert_eq!(
remote_joined.path_component(),
StandardizedPath::try_new("/repo/src/foo.rs").unwrap()
);
assert!(remote_joined.is_remote());
if let LocalOrRemotePath::Remote(remote) = remote_joined {
assert_eq!(remote.host_id, test_host_id());
} else {
panic!("expected remote variant");
}
}
#[test]
fn local_or_remote_path_join_with_absolute_replaces_prefix() {
let local = LocalOrRemotePath::Local(local_repo_path());
let remote = LocalOrRemotePath::Remote(RemotePath::new(
test_host_id(),
StandardizedPath::try_new("/some/repo").unwrap(),
));
let local_abs = local_absolute_file_path();
let local_abs_str = local_abs.to_string_lossy().into_owned();
let remote_abs = "/server/repo/src/foo.rs";
// Path::join replacement semantics on absolute argument.
let local_joined = local.join(&local_abs_str);
assert_eq!(
local_joined.path_component(),
StandardizedPath::try_from_local(&local_abs).unwrap()
);
let remote_joined = remote.join(remote_abs);
assert_eq!(
remote_joined.path_component(),
StandardizedPath::try_new("/server/repo/src/foo.rs").unwrap()
);
}
#[test]
fn local_or_remote_path_strip_repo_prefix_local_local() {
let repo = LocalOrRemotePath::Local(local_repo_path());
let inside = LocalOrRemotePath::Local(local_repo_path().join("src").join("foo.rs"));
let outside = LocalOrRemotePath::Local(local_absolute_file_path());
let expected_relative = std::path::Path::new("src")
.join("foo.rs")
.to_string_lossy()
.into_owned();
assert_eq!(repo.strip_repo_prefix(&inside), Some(expected_relative));
assert_eq!(repo.strip_repo_prefix(&outside), None);
}
#[test]
fn local_or_remote_path_strip_repo_prefix_requires_same_host() {
let host_a = HostId::new("host-a".to_string());
let host_b = HostId::new("host-b".to_string());
let repo_a = LocalOrRemotePath::Remote(RemotePath::new(
host_a.clone(),
StandardizedPath::try_new("/repo").unwrap(),
));
let file_a = LocalOrRemotePath::Remote(RemotePath::new(
host_a,
StandardizedPath::try_new("/repo/src/foo.rs").unwrap(),
));
let file_b = LocalOrRemotePath::Remote(RemotePath::new(
host_b,
StandardizedPath::try_new("/repo/src/foo.rs").unwrap(),
));
let file_local = LocalOrRemotePath::Local(local_repo_path().join("src/foo.rs"));
assert_eq!(
repo_a.strip_repo_prefix(&file_a),
Some("src/foo.rs".to_string()),
);
assert_eq!(
repo_a.strip_repo_prefix(&file_b),
None,
"cross-host strip should be rejected"
);
assert_eq!(
repo_a.strip_repo_prefix(&file_local),
None,
"local-vs-remote strip should be rejected"
);
}
#[test]
fn local_or_remote_path_strip_repo_prefix_rejects_sibling_dirs() {
// A remote file under a *sibling* directory whose name merely shares a
// string prefix with the repo (`/repository` vs `/repo`) is not inside
// the repo and must not be stripped. This must match the `Local` arm,
// which already rejects it via component-aware `Path::strip_prefix`.
let host = HostId::new("host".to_string());
let repo = LocalOrRemotePath::Remote(RemotePath::new(
host.clone(),
StandardizedPath::try_new("/repo").unwrap(),
));
let sibling = LocalOrRemotePath::Remote(RemotePath::new(
host,
StandardizedPath::try_new("/repository/src/foo.rs").unwrap(),
));
assert_eq!(
repo.strip_repo_prefix(&sibling),
None,
"sibling dir sharing a string prefix is not inside the repo"
);
}
+2 -1
View File
@@ -1,8 +1,9 @@
use pin_project::{pin_project, pinned_drop};
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use pin_project::{pin_project, pinned_drop};
/// Trait allowing you to attach a function to a [`Future`] that will be called if the future is
/// cancelled.
pub trait OnCancelFutureExt
+6 -4
View File
@@ -1,13 +1,15 @@
use crate::on_cancel::OnCancelFutureExt;
use futures_util::future::{AbortHandle, Abortable, Aborted};
use std::sync::atomic::{AtomicBool, Ordering};
use futures_util::future::{AbortHandle, Abortable, Aborted};
use crate::on_cancel::OnCancelFutureExt;
#[test]
fn test_ready_future_doesnt_call_callback() {
let callback_called = AtomicBool::new(false);
let future = async {}.on_cancel(|| callback_called.store(true, Ordering::SeqCst));
galaxyui::r#async::block_on(future);
galaxyui_core::r#async::block_on(future);
assert!(!callback_called.load(Ordering::Relaxed));
}
@@ -24,7 +26,7 @@ fn test_aborted_future_calls_callback() {
// Abort the future before it is ever polled.
handle.abort();
let future_result = galaxyui::r#async::block_on(future);
let future_result = galaxyui_core::r#async::block_on(future);
// The future should be aborted and the callback should have been called.
assert_eq!(future_result, Err(Aborted));
+11 -14
View File
@@ -455,21 +455,26 @@ pub fn convert_wsl_to_windows_host_path(
}
let components = unix_path.components();
let prefix = components.take(3).collect::<Vec<_>>();
let windows_path = match prefix.as_slice() {
let mut windows_path = TypedPathBuf::new(PathType::Windows);
match prefix.as_slice() {
// Check if the prefix is "/mnt/c/" or similar, which is how WSL refers to Windows drive
// "C:\". Valid drive names are a..=z, which are bytes 97..=122.
[TypedComponent::Unix(UnixComponent::RootDir), TypedComponent::Unix(UnixComponent::Normal(b"mnt")), TypedComponent::Unix(UnixComponent::Normal(bytes))]
if bytes.len() == 1 && (97..=122).contains(&bytes[0]) =>
{
let mut windows_path = TypedPathBuf::new(PathType::Windows);
windows_path.push([*bytes, b":\\"].concat());
for component in unix_path.with_windows_encoding().components().skip(3) {
windows_path.push(component.as_bytes());
}
windows_path
let pathbuf = PathBuf::try_from(windows_path)
.map_err(WSLPathConversionError::CouldNotConvertToPath)?;
// Many directories are symlinks into the underlying file-system location in Windows.
match std::fs::read_link(&pathbuf) {
Ok(linked_file) => Ok(linked_file),
Err(_) => Ok(pathbuf),
}
}
_ => {
let mut windows_path = TypedPathBuf::new(PathType::Windows);
windows_path.push(format!(r"\\WSL$\{distro_name}"));
for component in unix_path
.with_windows_encoding()
@@ -478,15 +483,8 @@ pub fn convert_wsl_to_windows_host_path(
{
windows_path.push(component.as_bytes());
}
windows_path
PathBuf::try_from(windows_path).map_err(WSLPathConversionError::CouldNotConvertToPath)
}
};
let pathbuf =
PathBuf::try_from(windows_path).map_err(WSLPathConversionError::CouldNotConvertToPath)?;
// Many directories are symlinks into the underlying file-system location in Windows.
match std::fs::read_link(&pathbuf) {
Ok(linked_file) => Ok(linked_file),
Err(_) => Ok(pathbuf),
}
}
@@ -573,7 +571,6 @@ pub fn to_relative_path(is_wsl: bool, absolute_path: &Path, cwd: &Path) -> Optio
// On Windows, check if paths are on different drives
#[cfg(windows)]
{
use std::path::Component;
let abs_drive = absolute_path.components().next().and_then(|c| match c {
Component::Prefix(prefix) => Some(prefix.kind()),
@@ -847,5 +844,5 @@ pub fn group_roots_by_common_ancestor<P: RootPath>(roots: &[P]) -> RootGrouping<
}
#[cfg(test)]
#[path = "path_test.rs"]
#[path = "path_tests.rs"]
mod tests;
+4 -3
View File
@@ -19,9 +19,10 @@ fn test_user_friendly_path_with_home() {
#[test]
fn test_to_relative_path() {
use super::to_relative_path;
use std::path::Path;
use super::to_relative_path;
// Basic relative path conversion
#[cfg(not(windows))]
{
@@ -204,7 +205,6 @@ fn test_to_relative_path() {
#[test]
fn test_normalize_relative_path_for_glob() {
use std::path::Path;
assert_eq!(
normalize_relative_path_for_glob(Path::new("app/src/main.rs")),
@@ -704,9 +704,10 @@ fn test_canonicalize_git_bash_path() {
// ── group_roots_by_common_ancestor tests ─────────────────────────────
mod group_roots_by_common_ancestor_tests {
use crate::path::group_roots_by_common_ancestor;
use std::path::PathBuf;
use crate::path::group_roots_by_common_ancestor;
fn pb(s: &str) -> PathBuf {
PathBuf::from(s)
}
+811
View File
@@ -0,0 +1,811 @@
use super::*;
#[test]
fn test_user_friendly_path_with_home() {
let home = "/Users/blue";
assert_eq!(
user_friendly_path("/Users/blue", Some(home)),
"~".to_string(),
);
assert_eq!(
user_friendly_path("/Users/blue/warp", Some(home)),
"~/warp".to_string(),
);
assert_eq!(
user_friendly_path("/Users/admin/warp", Some(home)),
"/Users/admin/warp".to_string(),
);
}
#[test]
fn test_to_relative_path() {
use std::path::Path;
use super::to_relative_path;
// Basic relative path conversion
#[cfg(not(windows))]
{
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects/app/src/main.rs"),
Path::new("/Users/john/projects")
),
Some("app/src/main.rs".to_string())
);
// Same directory
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects"),
Path::new("/Users/john/projects")
),
Some(".".to_string())
);
// Parent directory
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john"),
Path::new("/Users/john/projects")
),
Some("..".to_string())
);
// Nested parent
assert_eq!(
to_relative_path(
false,
Path::new("/Users"),
Path::new("/Users/john/projects")
),
Some("../..".to_string())
);
// Cross-branch paths
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/documents/file.txt"),
Path::new("/Users/john/projects")
),
Some("../documents/file.txt".to_string())
);
// Root to subdirectory
assert_eq!(
to_relative_path(false, Path::new("/var/log/system.log"), Path::new("/")),
Some("var/log/system.log".to_string())
);
// Handles paths that would have leading slashes correctly
assert_eq!(
to_relative_path(false, Path::new("/home/user/file.txt"), Path::new("/home")),
Some("user/file.txt".to_string())
);
// Test with current directory references that should be cleaned
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects/./app/src/main.rs"),
Path::new("/Users/john/projects")
),
Some("app/src/main.rs".to_string()),
);
}
#[cfg(windows)]
{
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects/app/src/main.rs"),
Path::new("/Users/john/projects")
),
Some("app\\src\\main.rs".to_string())
);
// Same directory
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects"),
Path::new("/Users/john/projects")
),
Some(".".to_string())
);
// Parent directory
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john"),
Path::new("/Users/john/projects")
),
Some("..".to_string())
);
// Nested parent
assert_eq!(
to_relative_path(
false,
Path::new("/Users"),
Path::new("/Users/john/projects")
),
Some("..\\..".to_string())
);
// Cross-branch paths
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/documents/file.txt"),
Path::new("/Users/john/projects")
),
Some("..\\documents\\file.txt".to_string())
);
// Root to subdirectory
assert_eq!(
to_relative_path(false, Path::new("/var/log/system.log"), Path::new("/")),
Some("var\\log\\system.log".to_string())
);
// Handles paths that would have leading slashes correctly
assert_eq!(
to_relative_path(false, Path::new("/home/user/file.txt"), Path::new("/home")),
Some("user\\file.txt".to_string())
);
// Test with current directory references that should be cleaned
assert_eq!(
to_relative_path(
false,
Path::new("/Users/john/projects/./app/src/main.rs"),
Path::new("/Users/john/projects")
),
Some("app\\src\\main.rs".to_string())
);
// Windows paths - different drives should return None
assert_eq!(
to_relative_path(
/* is_wsl */ false,
Path::new("D:\\projects\\app"),
Path::new("C:\\workspace")
),
None,
);
// Windows paths - same drive
assert_eq!(
to_relative_path(
/* is_wsl */ false,
Path::new("C:\\projects\\app\\src\\main.rs"),
Path::new("C:\\projects")
),
Some("app\\src\\main.rs".to_string())
);
// Windows paths - same drive -- WSL is disabled for now
assert_eq!(
to_relative_path(
/* is_wsl */ true,
Path::new("C:\\projects\\app\\src\\main.rs"),
Path::new("C:\\projects")
),
None
);
}
}
#[test]
fn test_normalize_relative_path_for_glob() {
assert_eq!(
normalize_relative_path_for_glob(Path::new("app/src/main.rs")),
"app/src/main.rs"
);
assert_eq!(
normalize_relative_path_for_glob(Path::new("./app/src/main.rs")),
"app/src/main.rs"
);
assert_eq!(
normalize_relative_path_for_glob(Path::new("../app/src/main.rs")),
"app/src/main.rs"
);
assert_eq!(normalize_relative_path_for_glob(Path::new("..")), "");
assert_eq!(normalize_relative_path_for_glob(Path::new("")), "");
}
#[test]
fn test_posix_escape() {
let shell_family = ShellFamily::Posix;
assert_eq!(
shell_family.escape("~/test_dir/library% 1$2"),
"\\~/test_dir/library%\\ 1\\$2"
);
assert_eq!(shell_family.escape("あい"), "あい");
assert_eq!(shell_family.escape("abc \n \t"), "abc\\ \\\n\\ \\\t");
assert_eq!(shell_family.escape(""), "''");
assert_eq!(
shell_family.escape("foo '\"' bar"),
"foo\\ \\'\\\"\\'\\ bar"
);
}
#[test]
fn test_powershell_escape() {
let shell_family = ShellFamily::PowerShell;
assert_eq!(
shell_family.escape("~/test_dir/library% 1$2"),
"~/test_dir/library%` 1`$2"
);
assert_eq!(shell_family.escape("あい"), "あい");
assert_eq!(shell_family.escape("abc \n \t"), "abc` `\n` `\t");
assert_eq!(shell_family.escape(""), "''");
assert_eq!(shell_family.escape("foo '\"' bar"), "foo` `'`\"`'` bar");
}
#[test]
fn test_posix_unescape() {
let shell_family = ShellFamily::Posix;
// Escaped spaces
assert_eq!(shell_family.unescape("my\\ file.txt"), "my file.txt");
// Multiple escaped characters
assert_eq!(
shell_family.unescape("path/to/my\\ file\\ \\(1\\).txt"),
"path/to/my file (1).txt"
);
// No escaping needed — returns borrowed
assert!(matches!(
shell_family.unescape("simple.txt"),
std::borrow::Cow::Borrowed(_)
));
// Trailing backslash kept as-is
assert_eq!(shell_family.unescape("trailing\\"), "trailing\\");
// Roundtrip: unescape(escape(x)) == x
let original = "hello world $HOME 'quotes'";
assert_eq!(
shell_family.unescape(&shell_family.escape(original)),
original
);
}
#[test]
fn test_powershell_unescape() {
let shell_family = ShellFamily::PowerShell;
// Escaped spaces
assert_eq!(shell_family.unescape("my` file.txt"), "my file.txt");
// Multiple escaped characters
assert_eq!(shell_family.unescape("path` `$var"), "path $var");
// No escaping needed — returns borrowed
assert!(matches!(
shell_family.unescape("simple.txt"),
std::borrow::Cow::Borrowed(_)
));
// Roundtrip: unescape(escape(x)) == x
let original = "hello world $HOME";
assert_eq!(
shell_family.unescape(&shell_family.escape(original)),
original
);
}
#[test]
fn test_clean_path() {
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml:10:5"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 10,
column_num: Some(5)
}),
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml:30:5abc"),
CleanPathResult {
path: "Cargo.toml:30:5abc".into(),
line_and_column_num: None
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml[30,5]"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 30,
column_num: Some(5)
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml(3,1)"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 3,
column_num: Some(1)
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml\", line 100, in"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 100,
column_num: None,
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml\", line 5, column 20"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 5,
column_num: Some(20),
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml#L100"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 100,
column_num: None
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml#L100:4"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 100,
column_num: Some(4)
})
}
);
// Line range format :start-end (should link to start line, ignore end line)
assert_eq!(
CleanPathResult::with_line_and_column_number("Cargo.toml:10-50"),
CleanPathResult {
path: "Cargo.toml".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 10,
column_num: None
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("/path/to/file.rs:1-1000"),
CleanPathResult {
path: "/path/to/file.rs".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 1,
column_num: None
})
}
);
assert_eq!(
CleanPathResult::with_line_and_column_number("src/main.rs:100-100"),
CleanPathResult {
path: "src/main.rs".into(),
line_and_column_num: Some(LineAndColumnArg {
line_num: 100,
column_num: None
})
}
);
}
#[test]
#[cfg(windows)]
fn test_msys2_exe_to_root() {
assert_eq!(
msys2_exe_to_root(WindowsPath::new(r"D:\Program Files\Git\usr\bin\git.exe")),
WindowsPathBuf::from(r"D:\Program Files\Git")
);
assert_eq!(
msys2_exe_to_root(WindowsPath::new(r"C:\git.exe")),
WindowsPathBuf::from(r"C:\Program Files\Git")
);
assert_eq!(
msys2_exe_to_root(WindowsPath::new(r"C:\foo\bar\baz\git.exe")),
WindowsPathBuf::from(r"C:\Program Files\Git")
);
assert_eq!(
msys2_exe_to_root(WindowsPath::new(r"C:\msys64\usr\bin\fish.exe")),
WindowsPathBuf::from(r"C:\msys64")
);
}
/// These tests all fail when running on UNIX.
#[test]
#[cfg(windows)]
fn test_convert_git_bash_to_windows_native_path() {
use std::sync::LazyLock;
static GIT_BASH_ROOT: LazyLock<WindowsPathBuf> =
LazyLock::new(|| WindowsPathBuf::from(r"C:\Program Files\Git"));
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/c/foo/bar").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"C:\foo\bar")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/d/special folder").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"D:\special folder")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/z").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"Z:\")
);
// non-ascii isn't actually a valid drive name
assert!(matches!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/😊/invalid").to_path(),
&GIT_BASH_ROOT
),
Err(MSYS2PathConversionError::NotInDrive)
));
assert!(matches!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/aa/invalid").to_path(),
&GIT_BASH_ROOT
),
Err(MSYS2PathConversionError::NotInDrive)
));
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("//wsl$/Ubuntu/home").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"\\wsl$\Ubuntu\home")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("//WSL.localhost/Ubuntu/home").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"\\WSL.localhost\Ubuntu\home")
);
// This path might get auto-inferred by typed-path to be a Windows path, even if it looks like
// UNIX with forward slashes.
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPath::from("//wsl$/Ubuntu/home"),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"\\wsl$\Ubuntu\home")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("~/.bash_history").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"~\.bash_history")
);
// Relative paths cannot be converted.
assert!(matches!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("some/relative/path").to_path(),
&GIT_BASH_ROOT
),
Err(MSYS2PathConversionError::PathNotAbsolute)
));
assert!(matches!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_windows(r"C:\Users").to_path(),
&GIT_BASH_ROOT
),
Err(MSYS2PathConversionError::NonUnixPath)
));
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"C:\Program Files\Git")
);
assert_eq!(
convert_msys2_to_windows_native_path(
&TypedPathBuf::from_unix("/usr/bin").to_path(),
&GIT_BASH_ROOT
)
.unwrap(),
PathBuf::from(r"C:\Program Files\Git\usr\bin")
);
}
/// These tests all fail when running on UNIX.
#[test]
#[cfg(windows)]
fn test_convert_wsl_to_windows_host_path() {
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/mnt/c/foo/bar").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"C:\foo\bar")
);
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/mnt/e/special dir").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"E:\special dir")
);
assert_eq!(
convert_wsl_to_windows_host_path(&TypedPathBuf::from_unix("/mnt/z").to_path(), "Ubuntu")
.unwrap(),
PathBuf::from(r"Z:\")
);
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/home/andy").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"\\WSL$\Ubuntu\home\andy")
);
assert!(matches!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("some/relative/path").to_path(),
"Ubuntu"
),
Err(WSLPathConversionError::PathNotAbsolute)
));
assert!(matches!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("~/.bash_history").to_path(),
"Ubuntu"
),
Err(WSLPathConversionError::PathNotAbsolute)
));
// Two letters isn't actually a valid drive.
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/mnt/aa/invalid_drive").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"\\WSL$\Ubuntu\mnt\aa\invalid_drive")
);
assert_eq!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_unix("/mnt/😊/invalid_drive").to_path(),
"Ubuntu"
)
.unwrap(),
PathBuf::from(r"\\WSL$\Ubuntu\mnt\😊\invalid_drive")
);
assert!(matches!(
convert_wsl_to_windows_host_path(
&TypedPathBuf::from_windows(r"C:\Users").to_path(),
"Ubuntu"
),
Err(WSLPathConversionError::NonUnixPath)
));
}
#[test]
fn test_convert_windows_path_to_wsl() {
assert_eq!(
convert_windows_path_to_wsl(r"C:\Users\aloke\file.txt"),
"/mnt/c/Users/aloke/file.txt"
);
assert_eq!(
convert_windows_path_to_wsl(r"D:\Pictures\Screenshots\Screenshot 2025-05-14 155816.png"),
"/mnt/d/Pictures/Screenshots/Screenshot 2025-05-14 155816.png"
);
// Drive letter only
assert_eq!(convert_windows_path_to_wsl(r"C:\"), "/mnt/c");
assert_eq!(convert_windows_path_to_wsl("C:"), "/mnt/c");
// Uppercase drive letter should be lowercased
assert_eq!(convert_windows_path_to_wsl(r"E:\foo"), "/mnt/e/foo");
// Non-drive path (e.g. UNC) gets backslashes replaced
assert_eq!(
convert_windows_path_to_wsl(r"\\server\share\file"),
"//server/share/file"
);
}
#[test]
fn test_convert_windows_path_to_msys2() {
assert_eq!(
convert_windows_path_to_msys2(r"C:\Users\aloke\file.txt"),
"/c/Users/aloke/file.txt"
);
assert_eq!(
convert_windows_path_to_msys2(r"D:\Pictures\Screenshots\Screenshot 2025-05-14 155816.png"),
"/d/Pictures/Screenshots/Screenshot 2025-05-14 155816.png"
);
// Drive letter only
assert_eq!(convert_windows_path_to_msys2(r"C:\"), "/c");
assert_eq!(convert_windows_path_to_msys2("C:"), "/c");
// Uppercase drive letter should be lowercased
assert_eq!(convert_windows_path_to_msys2(r"E:\foo"), "/e/foo");
// Non-drive path (e.g. UNC) gets backslashes replaced
assert_eq!(
convert_windows_path_to_msys2(r"\\server\share\file"),
"//server/share/file"
);
}
#[test]
fn test_canonicalize_git_bash_path() {
assert_eq!(
canonicalize_git_bash_path(
Path::new("C:")
.join("Program Files")
.join("Git")
.join("bin")
.join("bash.exe")
),
Path::new("C:")
.join("Program Files")
.join("Git")
.join("usr")
.join("bin")
.join("bash.exe")
);
assert_eq!(
canonicalize_git_bash_path(
Path::new("C:")
.join("Windows")
.join("system32")
.join("bash.exe")
),
Path::new("C:")
.join("Windows")
.join("system32")
.join("bash.exe")
);
}
// ── group_roots_by_common_ancestor tests ─────────────────────────────
mod group_roots_by_common_ancestor_tests {
use std::path::PathBuf;
use crate::path::group_roots_by_common_ancestor;
fn pb(s: &str) -> PathBuf {
PathBuf::from(s)
}
#[test]
fn empty_input_produces_empty_grouping() {
let grouping = group_roots_by_common_ancestor::<PathBuf>(&[]);
assert!(grouping.roots.is_empty());
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn single_path_survives_with_no_absorbed() {
let grouping = group_roots_by_common_ancestor(&[pb("/a")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn unrelated_siblings_both_survive() {
let grouping = group_roots_by_common_ancestor(&[pb("/a"), pb("/b")]);
assert_eq!(grouping.roots, vec![pb("/a"), pb("/b")]);
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn descendant_absorbed_into_ancestor() {
// Ancestor listed first.
let grouping = group_roots_by_common_ancestor(&[pb("/a"), pb("/a/b")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert_eq!(grouping.absorbed_by_root.len(), 1);
assert_eq!(grouping.absorbed_by_root[&pb("/a")], vec![pb("/a/b")]);
}
#[test]
fn descendant_first_still_absorbed() {
// Descendant listed first, ancestor second; survivor is still the
// ancestor and its input order is preserved.
let grouping = group_roots_by_common_ancestor(&[pb("/a/b"), pb("/a")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert_eq!(grouping.absorbed_by_root[&pb("/a")], vec![pb("/a/b")]);
}
#[test]
fn three_deep_chain_collapses_to_root() {
// Descendant order in input is preserved in the absorbed list.
let grouping = group_roots_by_common_ancestor(&[pb("/a/b/c"), pb("/a/b"), pb("/a")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert_eq!(
grouping.absorbed_by_root[&pb("/a")],
vec![pb("/a/b/c"), pb("/a/b")]
);
}
#[test]
fn mixed_groups_absorb_independently() {
let grouping =
group_roots_by_common_ancestor(&[pb("/a"), pb("/x"), pb("/a/b"), pb("/x/y")]);
assert_eq!(grouping.roots, vec![pb("/a"), pb("/x")]);
assert_eq!(grouping.absorbed_by_root[&pb("/a")], vec![pb("/a/b")]);
assert_eq!(grouping.absorbed_by_root[&pb("/x")], vec![pb("/x/y")]);
}
#[test]
fn same_prefix_different_component_name_both_survive() {
// /foo/a is NOT an ancestor of /foo/abc (component-aware match).
let grouping = group_roots_by_common_ancestor(&[pb("/foo/a"), pb("/foo/abc")]);
assert_eq!(grouping.roots, vec![pb("/foo/a"), pb("/foo/abc")]);
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn duplicate_inputs_collapse_to_single_survivor() {
let grouping = group_roots_by_common_ancestor(&[pb("/a"), pb("/a")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert!(grouping.absorbed_by_root.is_empty());
}
#[test]
fn surviving_root_order_matches_input_order() {
// Insert a descendant between two surviving ancestors; the survivors
// should appear in their original input order even though processing
// sorted by component count.
let grouping = group_roots_by_common_ancestor(&[pb("/b"), pb("/a/x"), pb("/a"), pb("/c")]);
assert_eq!(grouping.roots, vec![pb("/b"), pb("/a"), pb("/c")]);
assert_eq!(grouping.absorbed_by_root[&pb("/a")], vec![pb("/a/x")]);
}
#[test]
fn descendant_absorbed_by_closest_ancestor_not_furthest() {
// Both /a and /a/b are surviving ancestors of /a/b/c... wait, /a/b is
// itself absorbed into /a. So /a/b/c should be absorbed into /a as
// well (the only surviving ancestor).
let grouping = group_roots_by_common_ancestor(&[pb("/a"), pb("/a/b"), pb("/a/b/c")]);
assert_eq!(grouping.roots, vec![pb("/a")]);
assert_eq!(
grouping.absorbed_by_root[&pb("/a")],
vec![pb("/a/b"), pb("/a/b/c")]
);
}
}
+36
View File
@@ -0,0 +1,36 @@
use serde::{Deserialize, Serialize};
use crate::host_id::HostId;
use crate::standardized_path::StandardizedPath;
/// Identifies a file on a remote host.
///
/// Pairs a [`HostId`] (to deduplicate across multiple SSH sessions to the
/// same host) with the server-side [`StandardizedPath`]. This type is the
/// canonical representation for remote file locations and is shared across
/// buffer tracking, repository identification, and other host-scoped features.
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct RemotePath {
pub host_id: HostId,
pub path: StandardizedPath,
}
impl RemotePath {
pub fn new(host_id: HostId, path: StandardizedPath) -> Self {
Self { host_id, path }
}
/// Returns true if the given host and path identify this remote path.
pub fn matches(&self, host_id: &HostId, path: &StandardizedPath) -> bool {
host_id == &self.host_id && path == &self.path
}
}
/// The result of a `navigate_to_directory` request to the remote server.
#[derive(Clone, Debug)]
pub struct RemoteNavigationResult {
/// The canonicalized remote path returned by the server.
pub remote_path: RemotePath,
/// Whether the server detected a git repository at this path.
pub is_git: bool,
}
+18 -8
View File
@@ -5,9 +5,8 @@
//! [`CanonicalizedPath`](repo_metadata::CanonicalizedPath), construction does **not** resolve
//! symlinks or verify existence on disk.
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use std::{fmt, io};
use serde::{Deserialize, Serialize};
use typed_path::{PathType, TypedPath, TypedPathBuf};
@@ -152,16 +151,27 @@ impl StandardizedPath {
}
/// Strip a prefix from this path, returning the relative remainder.
///
/// Matching is done at the component level (consistent with
/// [`starts_with`](Self::starts_with)), so `base = /repo` does **not**
/// match `self = /repository/foo.rs`; that returns `None` rather than a
/// bogus mid-component remainder.
pub fn strip_prefix(&self, base: &StandardizedPath) -> Option<&str> {
let self_str = self.as_str();
let base_str = base.as_str();
self_str.strip_prefix(base_str).map(|remainder| {
// Remove leading separator if present.
// Gate on the component-aware `starts_with` so we never strip a base
// that is only a *string* prefix (e.g. `/repo` vs `/repository`). For
// two normalized paths, a component prefix is also a byte prefix, so
// slicing at `base` length lands exactly on a component boundary.
if !self.starts_with(base) {
return None;
}
let remainder = &self.as_str()[base.as_str().len()..];
Some(
// Remove the separator left between `base` and the remainder.
remainder
.strip_prefix('/')
.or_else(|| remainder.strip_prefix('\\'))
.unwrap_or(remainder)
})
.unwrap_or(remainder),
)
}
/// Join a relative segment onto this path.
@@ -102,6 +102,30 @@ fn strip_prefix() {
assert_eq!(p.strip_prefix(&base), Some("src/main.rs"));
}
#[test]
fn strip_prefix_equal_path() {
let p = StandardizedPath::try_new("/home/user/project").unwrap();
let base = StandardizedPath::try_new("/home/user/project").unwrap();
assert_eq!(p.strip_prefix(&base), Some(""));
}
#[test]
fn strip_prefix_matches_only_whole_components() {
// `/repository` is a string-prefix sibling of `/repo`, but it is NOT a
// path-component descendant of it. `strip_prefix` must behave like
// `starts_with` and refuse to strip, returning `None` rather than a
// bogus mid-component remainder like `Some("sitory/foo.rs")`.
let base = StandardizedPath::try_new("/repo").unwrap();
let sibling = StandardizedPath::try_new("/repository/foo.rs").unwrap();
assert!(!sibling.starts_with(&base));
assert_eq!(sibling.strip_prefix(&base), None);
let nested_base = StandardizedPath::try_new("/home/user").unwrap();
let nested_sibling = StandardizedPath::try_new("/home/username/x").unwrap();
assert!(!nested_sibling.starts_with(&nested_base));
assert_eq!(nested_sibling.strip_prefix(&nested_base), None);
}
#[test]
fn join() {
let p = StandardizedPath::try_new("/home/user").unwrap();
+92
View File
@@ -0,0 +1,92 @@
//! Synchronization utilities.
use std::future::Future;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use event_listener::Event;
#[cfg(test)]
#[path = "sync_tests.rs"]
mod tests;
/// A set-once asynchronous condition variable.
///
/// Generally, a [condition variable](http://www.cs.cornell.edu/courses/cs3110/2012fa/recitations/rec16.html)
/// lets tasks wait until some condition becomes true (for example, we might want to wait for the
/// user to have logged in, or for the initial load of Warp Drive objects to have finished). When
/// the condition becomes true, one or all of the waiting tasks can wake up and do their work.
///
/// This [`Condition`] implementation models the simpler case where a condition becomes true and
/// is then *always* true (unless reset explicitly). This allows waiting for something to happen at
/// least once. If a task starts waiting before the condition is met, it will block, but if the
/// condition is already true, it continues immediately.
///
/// This is useful when regular UI framework events are not suitable because they do not tell us if
/// the event had *already* happened - a task that subscribed too late would block forever.
///
/// Also see [`std::sync::Condvar`].
#[derive(Debug, Clone)]
pub struct Condition {
// This is more or less the reference example for event-listener:
// https://github.com/smol-rs/event-listener
flag: Arc<AtomicBool>,
event: Arc<Event>,
}
impl Condition {
pub fn new() -> Self {
Self {
flag: Arc::new(AtomicBool::new(false)),
event: Arc::new(Event::new()),
}
}
/// Mark the condition as true.
pub fn set(&self) {
self.flag.store(true, Ordering::SeqCst);
self.event.notify(usize::MAX);
}
/// Reset the condition to false so that future [`wait`](Self::wait) calls
/// will block until [`set`](Self::set) is called again.
pub fn reset(&self) {
self.flag.store(false, Ordering::SeqCst);
}
/// Returns `true` if the condition has already been set.
pub fn is_set(&self) -> bool {
self.flag.load(Ordering::SeqCst)
}
/// Asynchronously wait for the condition to be true.
pub fn wait(&self) -> impl Future<Output = ()> {
let flag = self.flag.clone();
let event = self.event.clone();
async move {
// Loop in case of spurious wakeups.
loop {
// Check if the condition has already been set.
if flag.load(Ordering::SeqCst) {
break;
}
let listener = event.listen();
// Check the flag again after creating the listener, in case it was set while we
// started listening.
if flag.load(Ordering::SeqCst) {
break;
}
listener.await;
}
}
}
}
impl Default for Condition {
fn default() -> Self {
Self::new()
}
}
+52
View File
@@ -0,0 +1,52 @@
use futures_lite::future;
use super::Condition;
#[test]
fn test_condition_multiple_waiters() {
future::block_on(async {
let condition = Condition::new();
let mut listener1 = Box::pin(condition.wait());
let mut listener2 = Box::pin(condition.wait());
// Neither listener should be ready.
assert!(future::poll_once(&mut listener1).await.is_none());
assert!(future::poll_once(&mut listener2).await.is_none());
condition.set();
// Now, both should complete.
assert!(future::poll_once(listener1).await.is_some());
assert!(future::poll_once(listener2).await.is_some());
})
}
#[test]
fn test_condition_after_set() {
future::block_on(async {
let condition = Condition::new();
condition.set();
// After the condition is set, waiting should complete immediately.
assert!(future::poll_once(condition.wait()).await.is_some());
})
}
#[test]
fn test_condition_multiple_sets() {
future::block_on(async {
let condition = Condition::new();
// Test that multiple interleavings of `wait` and `set` all complete as expected.
let first = condition.wait();
condition.set();
let second = condition.wait();
condition.set();
let third = condition.wait();
assert!(future::poll_once(first).await.is_some());
assert!(future::poll_once(second).await.is_some());
assert!(future::poll_once(third).await.is_some());
})
}
+2 -4
View File
@@ -1,7 +1,5 @@
use std::{
fmt::{self, Debug},
ops::{Deref, DerefMut},
};
use std::fmt::{self, Debug};
use std::ops::{Deref, DerefMut};
/// Wrapper type for values which may contain user input.
///