Файли
b0f65769c0 feat(remote): open WSL projects through a WSL transport (#989)
* perf(vfs): list directories off the main thread and reuse the repository lookup

Opening a folder on a network share (\\wsl.localhost\<distro>\...) is slow
because every listing ran on the main thread and walked up to the repository
root and ran a repository discovery again.

- fs_read_dir runs on the blocking pool.
- GitignoreStack::cached keeps the repository root and the repo-level rules
  for a few seconds. An edit of .git/info/exclude is still seen at once (its
  mtime and size are checked) and every .gitignore / .ignore is read fresh.

Refs #916

* feat(remote): open WSL projects through a WSL transport

A folder of a WSL distro (\\wsl.localhost\<distro>\... or \\wsl$\...) now opens
as a remote project whose openidea-server runs inside the distro, so the
terminal, SDK discovery, run, language servers and git are native Linux.

- RemoteTarget (ssh | wsl) replaces the SSH configuration id in the remote
  launch, connection, session and recents; old recents are read as SSH and a
  WSL target never inherits the legacy trust migration.
- remote_wsl: probe the distro, stream the server into ~/.openidea/server and
  start it with `wsl.exe -d <distro> --exec <bin> --stdio`. Distro names are
  validated and every command goes through --exec with positional arguments.
- wsl_distros / wsl_resolve commands; File > Open, the CLI and recents route
  WSL folders to the distro and focus the window that already has them.
- Remote Development dialog: SSH/WSL choice with the detected distros; the
  status bar shows `WSL: <distro>` / `SSH: <host>`.
- ide-jsonrpc: skip the NUL left at the start of a line by UTF-16 output of
  wsl.exe, which made the first message of the server disappear.

Not run on Windows yet: only a stand-in for wsl.exe is tested.

Refs #925, #327, #721

* fix(remote): satisfy clippy in WSL output decoding

* style(run): format run types

* fix: regenerate bindings and drop untranslated WSL catalog entry

* test(i18n): list WSL as an untranslated product name

* fix(run): pick build wrappers and shell quoting by the execution host

A remote window runs commands on its Linux or macOS server, so a Windows
desktop no longer selects gradlew.bat, mvnw.cmd or Windows quoting for it.

* fix(welcome): open legacy WSL UNC recents and sessions through the distro

Recents and the restored session called the local openProject for a
\\wsl.localhost folder saved before WSL projects, which fails with OS
error 123. They now resolve it like the other entry points.

* fix(windows): keep WSL files out of remote project launches

path_launch tested the WSL share before is_dir, so a first-instance CLI
launch with a WSL file opened a remote project instead of LightEdit.

* fix(remote): run-command OS comes from the remote host, not an assumption

The server's OS was dropped after connect, so a restored session had none and
isWindows() assumed every remote host is Unix. Keep it on the connection and
the session, and resolve it through hostOs().

---------

Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: NguyenBang <[email protected]>
Co-authored-by: l0ng-ai <[email protected]>
2026-11-05 15:37:19 +08:00

413 рядки
15 KiB
Rust

//! File system access for the IDE. The UI never touches the disk directly.
mod archive;
mod bytes;
mod compare;
mod encoding;
mod ops;
mod rules;
mod watch;
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::cmp::Ordering;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub use archive::{
ARCHIVE_EXTENSIONS, archive_entry_path, archive_root, is_archive_file, is_archive_path, read_archive_dir,
read_archive_entry, split_archive_path,
};
pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range};
pub use compare::{
DirectoryCompareEntry, DirectoryCompareProgress, DirectoryCompareStatus, compare_directories, copy_merge,
};
use encoding::SNIFF_LEN;
pub use encoding::{
LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode,
encode_properties_ascii, save_properties_text, write_atomic, write_atomic_strict,
};
pub use rules::ExcludeMatcher;
pub use watch::{WatchMode, Watcher};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum EntryKind {
File,
Dir,
Symlink,
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileEntry {
pub name: String,
pub path: ide_core::IpcPath,
pub kind: EntryKind,
pub size: u64,
/// Milliseconds since the Unix epoch.
pub mtime_ms: f64,
/// Matched by .gitignore or the exclude settings.
pub ignored: bool,
/// Matched by the exclude settings (IDEA's "excluded": dimmed, excluded-folder icon).
pub excluded: bool,
pub hidden: bool,
/// For symlinks: what the link points to (`None` when dangling); `None`
/// for other entries.
#[specta(optional)]
pub target_kind: Option<EntryKind>,
}
/// Milliseconds since the Unix epoch of a file's modification time (0 when unknown).
pub fn mtime_ms(meta: &fs::Metadata) -> f64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_nanos() as f64 / 1_000_000.0)
.unwrap_or(0.0)
}
/// Case-insensitive natural ordering: `file2 < file10`, `a < B`.
pub fn natural_cmp(a: &str, b: &str) -> Ordering {
let (mut x, mut y) = (a.chars().peekable(), b.chars().peekable());
loop {
match (x.peek().copied(), y.peek().copied()) {
(None, None) => break,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(c), Some(d)) if c.is_ascii_digit() && d.is_ascii_digit() => {
let take = |it: &mut std::iter::Peekable<std::str::Chars<'_>>| {
let mut s = String::new();
while let Some(c) = it.peek().copied().filter(char::is_ascii_digit) {
s.push(c);
it.next();
}
s
};
let (n, m) = (take(&mut x), take(&mut y));
let (nt, mt) = (n.trim_start_matches('0'), m.trim_start_matches('0'));
let ord = nt.len().cmp(&mt.len()).then_with(|| nt.cmp(mt)).then_with(|| n.len().cmp(&m.len()));
if ord != Ordering::Equal {
return ord;
}
}
(Some(c), Some(d)) => {
let ord = c.to_lowercase().cmp(d.to_lowercase());
if ord != Ordering::Equal {
return ord;
}
x.next();
y.next();
}
}
}
a.cmp(b)
}
/// Lists one directory level: directories first, then files, each sorted
/// case-insensitively with natural number ordering.
///
/// Symlinks are reported as [`EntryKind::Symlink`] but sorted (and sized) by
/// what they point to. `excluded` reflects `excludes` (relative to the enclosing
/// repository root, else to `dir`); `ignored` also the applicable `.gitignore`/`.ignore` files.
///
/// Inside an archive (`<archive>!/<entry>`, or the archive file itself) lists its entries.
pub fn read_dir(dir: &Path, excludes: &[String]) -> IdeResult<Vec<FileEntry>> {
if let Some((archive, entry)) = split_archive_path(dir) {
return read_archive_dir(&archive, &entry);
}
if is_archive_file(dir) && fs::metadata(dir).is_ok_and(|m| m.is_file()) {
return read_archive_dir(dir, "");
}
if !fs::metadata(dir)?.is_dir() {
return Err(IdeError::invalid(format!("{} is not a directory", dir.display())));
}
let gitignore = rules::GitignoreStack::cached(dir);
let base = gitignore.repo_root().unwrap_or(dir).to_path_buf();
let excludes = ExcludeMatcher::new(excludes);
let mut entries: Vec<(bool, FileEntry)> = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = match entry {
Ok(e) => e,
Err(e) => {
tracing::debug!("read_dir {}: {e}", dir.display());
continue;
}
};
let path = entry.path();
let name = entry.file_name().to_string_lossy().into_owned();
let Ok(lmeta) = fs::symlink_metadata(&path) else { continue };
let (kind, meta, target_kind) = if lmeta.file_type().is_symlink() {
match fs::metadata(&path) {
Ok(m) => {
let target = if m.is_dir() { EntryKind::Dir } else { EntryKind::File };
(EntryKind::Symlink, m, Some(target))
}
Err(_) => (EntryKind::Symlink, lmeta, None),
}
} else if lmeta.is_dir() {
(EntryKind::Dir, lmeta, None)
} else {
(EntryKind::File, lmeta, None)
};
let is_dir = meta.is_dir();
let rel = path.strip_prefix(&base).unwrap_or(&path);
let excluded = excludes.is_excluded(rel);
let ignored = excluded || gitignore.is_ignored(&path, is_dir);
entries.push((
is_dir,
FileEntry {
hidden: name.starts_with('.'),
size: if is_dir { 0 } else { meta.len() },
mtime_ms: mtime_ms(&meta),
name,
path: path.into(),
kind,
ignored,
excluded,
target_kind,
},
));
}
entries.sort_by(|(da, a), (db, b)| db.cmp(da).then_with(|| natural_cmp(&a.name, &b.name)));
Ok(entries.into_iter().map(|(_, e)| e).collect())
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct LoadedText {
pub text: String,
/// WHATWG encoding label, e.g. `utf-8`, `gbk`, `utf-16le`.
pub encoding: String,
pub has_bom: bool,
/// File looked binary; `text` is empty and the UI should show a placeholder.
pub binary: bool,
/// File exceeds [`LARGE_FILE_THRESHOLD`]: expensive features are disabled.
#[specta(optional)]
pub large: bool,
/// File exceeds [`READ_ONLY_THRESHOLD`]: opened read-only.
pub too_large: bool,
pub mtime_ms: f64,
}
/// Loads a text file, detecting BOM / encoding (chardetng) and binary content.
///
/// Files over [`LARGE_FILE_THRESHOLD`] load with `large`, over
/// [`READ_ONLY_THRESHOLD`] with `too_large`; files over [`MAX_OPEN_SIZE`] are
/// refused with `InvalidInput`.
pub fn load_text(path: &Path) -> IdeResult<LoadedText> {
encoding::load_text(path)
}
/// Like [`load_text`], but text that is neither valid UTF-8 nor marked by a BOM
/// is decoded as `fallback` (the configured project encoding) instead of guessed.
pub fn load_text_with_fallback(path: &Path, fallback: Option<&str>) -> IdeResult<LoadedText> {
let loaded = encoding::load_text(path)?;
match fallback {
Some(enc)
if !loaded.binary
&& !loaded.has_bom
&& loaded.encoding != "utf-8"
&& !loaded.encoding.starts_with("utf-16") =>
{
encoding::load_text_as(path, enc).or(Ok(loaded))
}
_ => Ok(loaded),
}
}
/// Like [`load_text`] but decodes with the given encoding instead of detecting it.
pub fn load_text_as(path: &Path, encoding: &str) -> IdeResult<LoadedText> {
encoding::load_text_as(path, encoding)
}
/// Writes atomically (temp file + rename), re-encoding to `encoding`.
/// Returns the new mtime in ms. Fails with `InvalidInput` when the text has
/// characters `encoding` cannot represent (rather than corrupting them).
pub fn save_text(path: &Path, text: &str, encoding: &str, has_bom: bool) -> IdeResult<f64> {
let bytes = encode(text, encoding, has_bom)?;
write_atomic(path, &bytes)
}
/// Creates an empty file (and missing parent directories). Fails with
/// `AlreadyExists` rather than truncating an existing file.
pub fn create_file(path: &Path) -> IdeResult<()> {
ops::create_file(path)
}
/// Creates a binary file exclusively, including missing parent directories.
pub fn create_file_bytes(path: &Path, data: &[u8]) -> IdeResult<()> {
ops::create_file_bytes(path, data)
}
/// Creates a directory and missing parents; `AlreadyExists` if present.
pub fn create_dir(path: &Path) -> IdeResult<()> {
ops::create_dir(path)
}
/// Sets or clears the file's writable bit (owner write when making it writable).
pub fn set_writable(path: &Path, writable: bool) -> IdeResult<()> {
ops::set_writable(path, writable)
}
/// Renames / moves; refuses to overwrite an existing target (case-only renames
/// are allowed). Falls back to copy + delete across devices.
pub fn rename(from: &Path, to: &Path) -> IdeResult<()> {
ops::rename(from, to)
}
/// Checks source existence and destination conflicts without mutation, using
/// the same filesystem identity rules as [`rename`].
pub fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
ops::validate_rename(from, to)
}
/// Moves to the OS trash (recoverable), like IDEA's safe delete for files.
/// Deletes permanently when the trash is unavailable.
pub fn delete(path: &Path) -> IdeResult<()> {
ops::delete(path, !cfg!(test))
}
/// Deletes without going through the trash.
pub fn delete_permanently(path: &Path) -> IdeResult<()> {
ops::delete(path, false)
}
/// Copies a file, symlink or directory tree; refuses to overwrite `to`.
pub fn copy(from: &Path, to: &Path) -> IdeResult<()> {
ops::copy(from, to)
}
/// Walks every non-ignored file of a project in parallel (ignore crate),
/// honouring .gitignore, .ignore and `excludes`. Shared by index and search.
///
/// Hidden files are included; `.git` is always skipped. `.gitignore` applies
/// even outside a git repository; inside one, a nested repository's root is a
/// boundary for the outer repository's rules. Symlinks to files are visited, symlinked
/// directories are not followed.
pub fn walk_files(root: &Path, excludes: &[String], visit: impl Fn(&Path) + Send + Sync) {
let excludes = Arc::new(ExcludeMatcher::new(excludes));
let base = root.to_path_buf();
let mut builder = ignore::WalkBuilder::new(root);
builder.hidden(false).require_git(ide_core::ignored::inside_git_repo(root)).follow_links(false);
builder.filter_entry(move |entry| {
if entry.depth() > 0 && entry.file_name() == ".git" {
return false;
}
match entry.path().strip_prefix(&base) {
Ok(rel) if !rel.as_os_str().is_empty() => !excludes.is_match(rel),
_ => true,
}
});
let visit = &visit;
builder.build_parallel().run(|| {
Box::new(move |result| {
match result {
Ok(entry) => {
let is_file = match entry.file_type() {
Some(ft) if ft.is_file() => true,
Some(ft) if ft.is_symlink() => entry.path().is_file(),
_ => false,
};
if is_file {
visit(entry.path());
}
}
Err(e) => tracing::debug!("walk: {e}"),
}
ignore::WalkState::Continue
})
});
}
#[derive(Clone, Debug, Default)]
pub struct FsBatch {
pub created: Vec<PathBuf>,
pub modified: Vec<PathBuf>,
pub removed: Vec<PathBuf>,
/// VCS metadata (`.git/HEAD`, `index`, `refs/**`): reported separately so
/// generic `fsChanged` consumers do not treat the backend's own index
/// refresh as a worktree edit and schedule another `vcs_status` (which
/// writes `.git/index` again — an idle refresh loop).
pub vcs: Vec<PathBuf>,
/// Folders holding changes filtered out as excluded or ignored. Only a view already
/// showing such a folder cares, so they don't count as worktree changes.
pub excluded_dirs: Vec<PathBuf>,
/// Files changed, created or removed that an ignore rule matches while their folder
/// is not ignored (`.env`). Kept apart from the worktree changes above (no Local
/// History, deployment or `fsChanged`), but the index lists them and open editors
/// follow them, like IDEA, where only ignored folders leave the project.
pub ignored: Vec<PathBuf>,
}
impl FsBatch {
pub fn is_empty(&self) -> bool {
self.created.is_empty()
&& self.modified.is_empty()
&& self.removed.is_empty()
&& self.vcs.is_empty()
&& self.excluded_dirs.is_empty()
&& self.ignored.is_empty()
}
}
/// Detect a revision's encoding with the same rules as files opened in the editor.
pub fn detect_encoding(bytes: &[u8], fallback: Option<&str>) -> &'static encoding_rs::Encoding {
let (_, detected, has_bom, binary) = encoding::decode(bytes);
if !binary
&& !has_bom
&& detected != encoding_rs::UTF_8
&& detected != encoding_rs::UTF_16LE
&& detected != encoding_rs::UTF_16BE
{
fallback.and_then(|label| encoding_rs::Encoding::for_label(label.trim().as_bytes())).unwrap_or(detected)
} else {
detected
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn natural_ordering() {
let mut names = vec!["file10", "File2", "file1", "b", "A", "file02", "a", "x9y", "x10y", "readme"];
names.sort_by(|a, b| natural_cmp(a, b));
assert_eq!(names, vec!["A", "a", "b", "file1", "File2", "file02", "file10", "readme", "x9y", "x10y"]);
}
#[test]
fn toggles_the_writable_bit() {
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("x");
fs::write(&f, "1").unwrap();
set_writable(&f, false).unwrap();
assert!(fs::metadata(&f).unwrap().permissions().readonly());
set_writable(&f, true).unwrap();
assert!(!fs::metadata(&f).unwrap().permissions().readonly());
assert_eq!(
set_writable(&tmp.path().join("missing"), true).unwrap_err().kind,
ide_core::error::ErrorKind::NotFound
);
}
#[test]
fn delete_in_tests_is_permanent() {
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("x");
fs::write(&f, "1").unwrap();
delete(&f).unwrap();
assert!(!f.exists());
assert_eq!(delete(&f).unwrap_err().kind, ide_core::error::ErrorKind::NotFound);
}
}
#[cfg(test)]
mod assertions {
fn send_sync<T: Send + Sync>() {}
#[test]
fn types_are_thread_safe() {
send_sync::<super::Watcher>();
send_sync::<super::FsBatch>();
}
}