Files
openidea/crates/ide-vfs/src/rules.rs
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

409 lines
16 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Ignore rules shared by directory listing, walking and watching:
//! user exclude globs plus layered `.gitignore` / `.ignore` files.
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::Match;
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use parking_lot::{Mutex, RwLock};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime};
/// Exclude globs from settings (`files.exclude`), e.g. `**/node_modules`.
///
/// Patterns are matched against paths relative to a base directory; a path is
/// excluded when it or any of its ancestors (below the base) matches. Patterns
/// without a `/` match at any depth, like gitignore; a leading `/` anchors at the base.
#[derive(Clone, Debug)]
pub struct ExcludeMatcher {
set: GlobSet,
}
impl ExcludeMatcher {
pub fn new(patterns: &[String]) -> Self {
let mut builder = GlobSetBuilder::new();
for raw in patterns {
let pattern = raw.trim().trim_end_matches('/');
if pattern.is_empty() {
continue;
}
// A leading `/` anchors the pattern at the base, like gitignore.
let anchored = pattern.starts_with('/');
let pattern = pattern.trim_start_matches("./").trim_start_matches('/');
if pattern.is_empty() {
continue;
}
let mut variants = vec![pattern.to_owned()];
if !anchored && !pattern.contains('/') {
variants.push(format!("**/{pattern}"));
}
for v in variants {
match Glob::new(&v) {
Ok(glob) => {
builder.add(glob);
}
Err(e) => tracing::warn!("invalid exclude pattern {raw:?}: {e}"),
}
}
}
let set = builder.build().unwrap_or_else(|e| {
tracing::warn!("invalid exclude patterns: {e}");
GlobSet::empty()
});
Self { set }
}
/// Whether `rel` itself matches (ancestors are not consulted).
pub fn is_match(&self, rel: &Path) -> bool {
!self.set.is_empty() && self.set.is_match(rel)
}
/// `rel` is relative to the base directory.
pub fn is_excluded(&self, rel: &Path) -> bool {
if self.set.is_empty() {
return false;
}
rel.ancestors().filter(|p| !p.as_os_str().is_empty()).any(|p| self.set.is_match(p))
}
}
/// Walks up from `start` looking for a directory containing `.git`.
pub(crate) fn find_repo_root(start: &Path) -> Option<PathBuf> {
start.ancestors().find(|dir| dir.join(".git").exists()).map(Path::to_path_buf)
}
/// `info/exclude` of the repository at `root` (shared by its worktrees).
fn exclude_path(root: &Path) -> PathBuf {
gix::discover(root)
.ok()
.map(|r| r.common_dir().join("info/exclude"))
.unwrap_or_else(|| root.join(".git/info/exclude"))
}
/// Layered gitignore matching: per-directory `.gitignore` / `.ignore` from the
/// path's directory up to the repository root (or the file system root when
/// not in a repository), then `.git/info/exclude` and the global excludes file.
pub(crate) struct GitignoreStack {
repo_root: Option<PathBuf>,
/// `.git/info/exclude` and the global excludes file, rooted at the repo root.
repo_level: RwLock<Vec<Gitignore>>,
cache: Mutex<HashMap<PathBuf, Option<Arc<Gitignore>>>>,
nested: Mutex<HashMap<PathBuf, Option<Arc<GitignoreStack>>>>,
}
/// How long [`GitignoreStack::cached`] reuses the repository lookup of a directory.
const REPO_LEVEL_TTL: Duration = Duration::from_secs(3);
/// What changes when `.git/info/exclude` is edited.
type Stamp = Option<(SystemTime, u64)>;
struct RepoLevel {
at: Instant,
repo_root: Option<PathBuf>,
/// `<common dir>/info/exclude`, found by a repository discovery.
exclude: Option<PathBuf>,
stamp: Stamp,
rules: Vec<Gitignore>,
}
/// Repo-level rules by the directory they were looked up for.
static REPO_LEVEL: Mutex<Option<HashMap<PathBuf, Arc<RepoLevel>>>> = Mutex::new(None);
fn stamp_of(file: Option<&Path>) -> Stamp {
let meta = std::fs::metadata(file?).ok()?;
Some((meta.modified().ok()?, meta.len()))
}
impl GitignoreStack {
pub fn new(near: &Path) -> Self {
let repo_root = find_repo_root(near);
let repo_level = RwLock::new(Self::load_repo_level(repo_root.as_deref()));
Self { repo_root, repo_level, cache: Mutex::default(), nested: Mutex::default() }
}
/// [`new`](Self::new) for listing a directory over and over (expanding a tree, a slow share):
/// the walk up to the repository root and the repository discovery are reused for
/// [`REPO_LEVEL_TTL`]. An edit of `.git/info/exclude` is still seen at once (its mtime and size
/// are checked), as is any `.gitignore` / `.ignore`, which are read fresh. Only a repository
/// created in the directory, or a change of the global excludes file, can lag by the TTL.
pub fn cached(near: &Path) -> Self {
let now = Instant::now();
let hit = REPO_LEVEL
.lock()
.as_ref()
.and_then(|m| m.get(near))
.cloned()
.filter(|e| now.duration_since(e.at) < REPO_LEVEL_TTL && stamp_of(e.exclude.as_deref()) == e.stamp);
let entry = hit.unwrap_or_else(|| {
let repo_root = find_repo_root(near);
let exclude = repo_root.as_deref().map(exclude_path);
let entry = Arc::new(RepoLevel {
at: now,
stamp: stamp_of(exclude.as_deref()),
rules: Self::load_repo_level_from(repo_root.as_deref(), exclude.as_deref()),
repo_root,
exclude,
});
let mut guard = REPO_LEVEL.lock();
let map = guard.get_or_insert_with(HashMap::new);
map.retain(|_, e| now.duration_since(e.at) < REPO_LEVEL_TTL);
map.insert(near.to_path_buf(), entry.clone());
entry
});
Self {
repo_root: entry.repo_root.clone(),
repo_level: RwLock::new(entry.rules.clone()),
cache: Mutex::default(),
nested: Mutex::default(),
}
}
fn load_repo_level(repo_root: Option<&Path>) -> Vec<Gitignore> {
Self::load_repo_level_from(repo_root, repo_root.map(exclude_path).as_deref())
}
fn load_repo_level_from(repo_root: Option<&Path>, exclude: Option<&Path>) -> Vec<Gitignore> {
let mut repo_level = Vec::new();
if let Some(root) = repo_root {
let mut builder = GitignoreBuilder::new(root);
if let Some(exclude) = exclude.filter(|f| f.is_file()) {
builder.add(exclude);
}
if let Ok(gi) = builder.build()
&& !gi.is_empty()
{
repo_level.push(gi);
}
let (global, err) = GitignoreBuilder::new(root).build_global();
if let Some(err) = err {
tracing::debug!("global gitignore: {err}");
}
if !global.is_empty() {
repo_level.push(global);
}
}
repo_level
}
/// Re-reads `.git/info/exclude` and the global excludes file.
pub fn reload_repo_level(&self) {
if let (Some(root), Some(map)) = (&self.repo_root, REPO_LEVEL.lock().as_mut()) {
map.retain(|dir, _| !dir.starts_with(root));
}
self.nested.lock().clear();
*self.repo_level.write() = Self::load_repo_level(self.repo_root.as_deref());
}
/// Forgets the cached rules of `dir` and every directory below it.
pub fn invalidate_tree(&self, dir: &Path) {
self.nested.lock().clear();
let mut cache = self.cache.lock();
// Lookups always populate every ancestor, so no key below `dir` exists without `dir`.
if cache.contains_key(dir) {
cache.retain(|k, _| !k.starts_with(dir));
}
}
pub fn repo_root(&self) -> Option<&Path> {
self.repo_root.as_deref()
}
/// Forgets the cached rules of `dir` (after its `.gitignore` changed).
pub fn invalidate(&self, dir: &Path) {
self.nested.lock().clear();
self.cache.lock().remove(dir);
}
fn matcher_for(&self, dir: &Path) -> Option<Arc<Gitignore>> {
if let Some(cached) = self.cache.lock().get(dir) {
return cached.clone();
}
let mut builder = GitignoreBuilder::new(dir);
let mut any = false;
for name in [".gitignore", ".ignore"] {
let file = dir.join(name);
if file.is_file() {
any = true;
if let Some(err) = builder.add(&file) {
tracing::debug!("{}: {err}", file.display());
}
}
}
let matcher = if any {
match builder.build() {
Ok(gi) if !gi.is_empty() => Some(Arc::new(gi)),
Ok(_) => None,
Err(e) => {
tracing::debug!("{}: {e}", dir.display());
None
}
}
} else {
None
};
self.cache.lock().insert(dir.to_path_buf(), matcher.clone());
matcher
}
/// Whether `path` (absolute) is ignored. `is_dir` refers to `path` itself.
pub fn is_ignored(&self, path: &Path, is_dir: bool) -> bool {
// A project's watcher can span independent nested repositories. Their local
// excludes and ignore ancestry must stop at their own repository boundary.
if let Some(parent) = path.parent() {
let nested = self
.nested
.lock()
.entry(parent.to_path_buf())
.or_insert_with(|| {
find_repo_root(parent)
.filter(|root| self.repo_root.as_ref() != Some(root))
.map(|_| Arc::new(Self::new(parent)))
})
.clone();
if let Some(nested) = nested {
return nested.is_ignored(path, is_dir);
}
}
if self.repo_root.as_deref() == Some(path) {
return false;
}
for dir in path.ancestors().skip(1) {
if let Some(m) = self.matcher_for(dir)
&& let Ok(rel) = path.strip_prefix(dir)
{
match m.matched_path_or_any_parents(rel, is_dir) {
Match::Ignore(_) => return true,
Match::Whitelist(_) => return false,
Match::None => {}
}
}
if self.repo_root.as_deref() == Some(dir) {
break;
}
}
if let Some(root) = &self.repo_root
&& let Ok(rel) = path.strip_prefix(root)
&& !rel.as_os_str().is_empty()
{
for gi in self.repo_level.read().iter() {
match gi.matched_path_or_any_parents(rel, is_dir) {
Match::Ignore(_) => return true,
Match::Whitelist(_) => return false,
Match::None => {}
}
}
}
false
}
/// Like [`is_ignored`], but only checks whether `path` itself matches —
/// ignored ancestors do not count. Lets a directory like `target/` (itself
/// ignored) still emit its own create/remove so the tree can show it
/// appearing marked as ignored, while files inside it stay filtered out.
pub fn is_ignored_self(&self, path: &Path, is_dir: bool) -> bool {
if let Some(parent) = path.parent() {
let nested = self
.nested
.lock()
.entry(parent.to_path_buf())
.or_insert_with(|| {
find_repo_root(parent)
.filter(|root| self.repo_root.as_ref() != Some(root))
.map(|_| Arc::new(Self::new(parent)))
})
.clone();
if let Some(nested) = nested {
return nested.is_ignored_self(path, is_dir);
}
}
if self.repo_root.as_deref() == Some(path) {
return false;
}
for dir in path.ancestors().skip(1) {
if let Some(m) = self.matcher_for(dir)
&& let Ok(rel) = path.strip_prefix(dir)
{
match m.matched(rel, is_dir) {
Match::Ignore(_) => return true,
Match::Whitelist(_) => return false,
Match::None => {}
}
}
if self.repo_root.as_deref() == Some(dir) {
break;
}
}
if let Some(root) = &self.repo_root
&& let Ok(rel) = path.strip_prefix(root)
&& !rel.as_os_str().is_empty()
{
for gi in self.repo_level.read().iter() {
match gi.matched(rel, is_dir) {
Match::Ignore(_) => return true,
Match::Whitelist(_) => return false,
Match::None => {}
}
}
}
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn exclude_patterns() {
let m = ExcludeMatcher::new(&["**/node_modules".into(), "target".into(), "docs/*.tmp".into(), "[".into()]);
assert!(m.is_excluded(Path::new("node_modules")));
assert!(m.is_excluded(Path::new("a/node_modules/x/y.js")));
assert!(m.is_excluded(Path::new("target")));
assert!(m.is_excluded(Path::new("crates/x/target/debug")));
assert!(m.is_excluded(Path::new("docs/a.tmp")));
assert!(!m.is_excluded(Path::new("src/main.rs")));
assert!(!m.is_excluded(Path::new("targets")));
assert!(!ExcludeMatcher::new(&[]).is_excluded(Path::new("x")));
// A leading `/` anchors at the base (Project Structure › Excluded folders).
let anchored = ExcludeMatcher::new(&["/build".into()]);
assert!(anchored.is_excluded(Path::new("build/out.o")));
assert!(!anchored.is_excluded(Path::new("web/build/out.o")));
}
fn repo_with_exclude(rule: &str) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join(".git/info")).unwrap();
std::fs::create_dir_all(tmp.path().join("src")).unwrap();
std::fs::write(tmp.path().join(".git/info/exclude"), rule).unwrap();
tmp
}
#[test]
fn cached_stack_sees_every_edit_of_the_ignore_files_at_once() {
let tmp = repo_with_exclude("secret.txt\n");
let (src, secret, note) =
(tmp.path().join("src"), tmp.path().join("src/secret.txt"), tmp.path().join("src/note.md"));
assert!(GitignoreStack::cached(&src).is_ignored(&secret, false));
assert!(!GitignoreStack::cached(&src).is_ignored(&note, false));
std::fs::write(src.join(".gitignore"), "*.md\n").unwrap();
assert!(GitignoreStack::cached(&src).is_ignored(&note, false), "directory .gitignore");
std::fs::write(tmp.path().join(".git/info/exclude"), "").unwrap();
assert!(!GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude");
std::fs::write(tmp.path().join(".git/info/exclude"), "note.md\nsecret.txt\n").unwrap();
assert!(GitignoreStack::cached(&src).is_ignored(&secret, false), "info/exclude again");
}
#[test]
fn cached_stack_reuses_the_repository_lookup() {
let tmp = repo_with_exclude("");
let src = tmp.path().join("src");
let first = GitignoreStack::cached(&src);
let entry = REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap().clone();
let again = GitignoreStack::cached(&src);
assert!(Arc::ptr_eq(&entry, REPO_LEVEL.lock().as_ref().unwrap().get(&src).unwrap()));
assert_eq!(first.repo_root(), again.repo_root());
assert_eq!(again.repo_root(), Some(tmp.path()));
}
}