mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
* 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]>
409 lines
16 KiB
Rust
409 lines
16 KiB
Rust
//! 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(¬e, false));
|
||
std::fs::write(src.join(".gitignore"), "*.md\n").unwrap();
|
||
assert!(GitignoreStack::cached(&src).is_ignored(¬e, 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()));
|
||
}
|
||
}
|