From 397b30b996b11126c3a14a2fe3cf11ab06986d1a Mon Sep 17 00:00:00 2001 From: Carlos de Paula Date: Thu, 6 Nov 2025 17:30:19 -0300 Subject: [PATCH] Enhance 'cd' action to include ignore functionality and refactor excluded state handling --- yazi-actor/src/mgr/cd.rs | 1 + yazi-actor/src/mgr/exclude_add.rs | 2 +- yazi-actor/src/mgr/excluded.rs | 7 +- yazi-actor/src/mgr/ignore.rs | 2 +- yazi-config/src/files/exclude.rs | 218 +++++++++++++++++++++++++----- yazi-config/src/files/files.rs | 8 +- 6 files changed, 192 insertions(+), 46 deletions(-) diff --git a/yazi-actor/src/mgr/cd.rs b/yazi-actor/src/mgr/cd.rs index f63c689e..072d700b 100644 --- a/yazi-actor/src/mgr/cd.rs +++ b/yazi-actor/src/mgr/cd.rs @@ -76,6 +76,7 @@ impl Actor for Cd { err!(Pubsub::pub_after_cd(tab.id, tab.cwd())); act!(mgr:hidden, cx)?; act!(mgr:sort, cx)?; + act!(mgr:ignore, cx)?; act!(mgr:hover, cx)?; act!(mgr:refresh, cx)?; succ!(render!()); diff --git a/yazi-actor/src/mgr/exclude_add.rs b/yazi-actor/src/mgr/exclude_add.rs index b9fe3d7a..8d85435c 100644 --- a/yazi-actor/src/mgr/exclude_add.rs +++ b/yazi-actor/src/mgr/exclude_add.rs @@ -310,7 +310,7 @@ impl Actor for ExcludeAdd { if apply(h, hovered_filter) { render!(h.repos(None)); act!(mgr:peek, cx, true)?; - } else if hovered.as_deref() != cx.hovered().map(|f| f.urn()) { + } else if hovered != cx.hovered().map(|f| f.urn().to_owned()) { act!(mgr:peek, cx)?; act!(mgr:watch, cx)?; } diff --git a/yazi-actor/src/mgr/excluded.rs b/yazi-actor/src/mgr/excluded.rs index 2da95a98..943d5213 100644 --- a/yazi-actor/src/mgr/excluded.rs +++ b/yazi-actor/src/mgr/excluded.rs @@ -15,9 +15,10 @@ impl Actor for Excluded { const NAME: &str = "excluded"; fn act(cx: &mut Ctx, opt: Self::Options) -> Result { - let state = opt.state.bool(cx.tab().current.files.show_excluded()); - let hovered = cx.hovered().map(|f| f.urn().to_owned()); + let current_state = cx.tab().current.files.show_excluded(); + let state = opt.state.bool(current_state); + let hovered = cx.hovered().map(|f| f.urn().to_owned()); let apply = |f: &mut Folder| { if f.stage == FolderStage::Loading { render!(); @@ -42,7 +43,7 @@ impl Actor for Excluded { { render!(h.repos(None)); act!(mgr:peek, cx, true)?; - } else if hovered.as_deref() != cx.hovered().map(|f| f.urn()) { + } else if hovered != cx.hovered().map(|f| f.urn().to_owned()) { act!(mgr:peek, cx)?; act!(mgr:watch, cx)?; } diff --git a/yazi-actor/src/mgr/ignore.rs b/yazi-actor/src/mgr/ignore.rs index 06c6ffa0..9862af59 100644 --- a/yazi-actor/src/mgr/ignore.rs +++ b/yazi-actor/src/mgr/ignore.rs @@ -103,7 +103,7 @@ impl Actor for Ignore { if apply(h, hovered_filter) { render!(h.repos(None)); act!(mgr:peek, cx, true)?; - } else if hovered.as_deref() != cx.hovered().map(|f| f.urn()) { + } else if hovered != cx.hovered().map(|f| f.urn().to_owned()) { act!(mgr:peek, cx)?; act!(mgr:watch, cx)?; } diff --git a/yazi-config/src/files/exclude.rs b/yazi-config/src/files/exclude.rs index ba0f5304..ae491933 100644 --- a/yazi-config/src/files/exclude.rs +++ b/yazi-config/src/files/exclude.rs @@ -54,12 +54,36 @@ impl Exclude { for pattern in &self.urn { if let Some(negated) = pattern.strip_prefix('!') { // Negation pattern - add to whitelist - let glob = Glob::new(negated)?; + // Transform simple filename patterns to match anywhere in the tree + let transformed = if negated.contains('/') || negated.starts_with("**/") { + negated.to_string() + } else { + // Match the item itself and everything inside it + format!("**/{}", negated) + }; + let glob = Glob::new(&transformed)?; whitelist_builder.add(glob); + // Also match everything inside directories with this name + if !negated.contains('/') && !negated.starts_with("**/") { + let glob_inner = Glob::new(&format!("**/{}/**", negated))?; + whitelist_builder.add(glob_inner); + } } else { // Regular pattern - add to ignore list - let glob = Glob::new(pattern)?; + // Transform simple filename patterns to match anywhere in the tree + let transformed = if pattern.contains('/') || pattern.starts_with("**/") { + pattern.to_string() + } else { + // Match the item itself: **/.git + format!("**/{}", pattern) + }; + let glob = Glob::new(&transformed)?; ignore_builder.add(glob); + // Also match everything inside directories with this name: **/.git/** + if !pattern.contains('/') && !pattern.starts_with("**/") { + let glob_inner = Glob::new(&format!("**/{}/**", pattern))?; + ignore_builder.add(glob_inner); + } } } @@ -77,39 +101,14 @@ impl Exclude { pub fn matches_path(&self, path: &Path) -> Option { let compiled = self.compiled.as_ref()?; - // For absolute paths, try both the full path and relative components - // This helps patterns like **/.git/** match /home/user/project/.git - let paths_to_check: Vec<&Path> = if path.is_absolute() { - // Also check each component as if it's relative - // This allows **/.git/** to match /home/user/.git/config - let mut paths = vec![path]; - - // Check if any path component matches by checking relative sub-paths - // For /home/user/.git/config, we want to match against .git/config too - if let Some(components) = path.to_str() { - for (i, _) in components.match_indices('/').skip(1) { - if let Some(subpath) = components.get(i + 1..) { - paths.push(Path::new(subpath)); - } - } - } - paths - } else { - vec![path] - }; - // Check whitelist first (negation takes precedence) - for p in &paths_to_check { - if compiled.whitelists.is_match(p) { - return Some(false); // Explicitly NOT ignored - } + if compiled.whitelists.is_match(path) { + return Some(false); // Explicitly NOT ignored } // Check ignore patterns - for p in &paths_to_check { - if compiled.ignores.is_match(p) { - return Some(true); // Should be ignored - } + if compiled.ignores.is_match(path) { + return Some(true); // Should be ignored } None // No match @@ -127,13 +126,10 @@ impl Exclude { let pattern = &self.r#in[3..]; // Remove leading "**/", e.g., "target" or "target/**" // Strip trailing /** if present - let pattern = if let Some(p) = pattern.strip_suffix("/**") { - p - } else { - pattern - }; + let pattern = if let Some(p) = pattern.strip_suffix("/**") { p } else { pattern }; - // Check if path ends with the pattern (e.g., /home/user/project/target matches **/target) + // Check if path ends with the pattern (e.g., /home/user/project/target matches + // **/target) if path.ends_with(&format!("/{}", pattern)) || path.ends_with(pattern) { return true; } @@ -174,3 +170,151 @@ impl Exclude { path == self.r#in || path.starts_with(&format!("{}/", self.r#in)) } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use super::*; + + fn create_exclude(in_pattern: &str, urn_patterns: Vec<&str>) -> Exclude { + let mut exclude = Exclude { + r#in: in_pattern.to_string(), + urn: urn_patterns.into_iter().map(String::from).collect(), + compiled: None, + }; + exclude.compile().unwrap(); + exclude + } + + #[test] + fn test_context_matching_with_dots() { + let exclude = create_exclude("*", vec![".git"]); + + // Test various path contexts - all should match "*" + let test_cases = vec![ + ("/home/user/projects/yazi/yazi", true), + ("/home/user/projects/yazi/yazi-rs.github.io", true), + ("/home/user/projects/yazi/command-palette.yazi", true), + ("/home/user/projects/yazi/gitignore.yazi", true), + ]; + + for (context, should_match) in test_cases { + let matches = exclude.matches_context(context); + assert_eq!( + matches, should_match, + "Context matching failed for {}: expected {}, got {}", + context, should_match, matches + ); + } + } + + #[test] + fn test_simple_pattern_behavior() { + // Test how globset matches simple patterns like ".git" + use globset::Glob; + + let patterns_and_paths = vec![ + // Pattern ".git" gets transformed to "**/.git" in our compile() method + // So let's test the transformed version + ("**/.git", "/home/user/.git", true), + ("**/.git", "/home/user/proj/.git", true), + ("**/.git", "/home/user/command-palette.yazi/.git", true), + ("**/.git", ".git", true), + // **/.git/** matches files INSIDE .git, not the .git directory itself + ("**/.git/**", "/home/user/.git", false), + ("**/.git/**", "/home/user/.git/config", true), + ]; + + for (pattern, path, expected) in patterns_and_paths { + let glob = Glob::new(pattern).unwrap().compile_matcher(); + let matches = glob.is_match(Path::new(path)); + assert_eq!( + matches, expected, + "Pattern '{}' with path '{}': expected {}, got {}", + pattern, path, expected, matches + ); + } + } + + #[test] + fn test_path_matching_with_git() { + let exclude = create_exclude("*", vec![".git"]); + + // Test various .git paths + // Pattern ".git" is transformed to "**/.git" which matches any component named + // .git + let test_paths = vec![ + ("/home/user/projects/yazi/yazi/.git", true), + ("/home/user/projects/yazi/yazi-rs.github.io/.git", true), + ("/home/user/projects/yazi/command-palette.yazi/.git", true), + ("/home/user/projects/yazi/gitignore.yazi/.git", true), + // Files inside .git now also match "**/.git" since .git is the component name + ("/home/user/projects/yazi/yazi/.git/config", true), + ("/home/user/projects/yazi/command-palette.yazi/.git/config", true), + ]; + + for (path_str, should_match) in test_paths { + let path = Path::new(path_str); + let result = exclude.matches_path(path); + // None means no match, which is equivalent to false (not ignored) + let actual = result.unwrap_or(false); + assert_eq!( + actual, should_match, + "Path matching failed for {}: expected {}, got {}", + path_str, should_match, actual + ); + } + } + + #[test] + fn test_glob_pattern_matching() { + // User provides "**/.git" explicitly - matches only things NAMED .git + let exclude = create_exclude("*", vec!["**/.git"]); + + let test_paths = vec![ + // Should match .git directory itself + ("/home/user/projects/yazi/yazi/.git", true), + ("/home/user/projects/yazi/command-palette.yazi/.git", true), + // Should NOT match files inside .git when using **/.git alone + ("/home/user/projects/yazi/yazi/.git/config", false), + ]; + + for (path_str, should_match) in test_paths { + let path = Path::new(path_str); + let result = exclude.matches_path(path); + let actual = result.unwrap_or(false); + assert_eq!( + actual, should_match, + "Glob pattern matching failed for {}: expected {}, got {}", + path_str, should_match, actual + ); + } + } + + #[test] + fn test_glob_pattern_with_trailing_slash() { + // Pattern **/.git/** means match everything inside any .git directory + let exclude = create_exclude("*", vec!["**/.git/**"]); + + let test_paths = vec![ + // Should NOT match .git directory itself with /** + ("/home/user/projects/yazi/yazi/.git", false), + // Should match all files inside .git + ("/home/user/projects/yazi/yazi/.git/config", true), + ("/home/user/projects/yazi/command-palette.yazi/.git", false), + ("/home/user/projects/yazi/command-palette.yazi/.git/config", true), + ]; + + for (path_str, should_match) in test_paths { + let path = Path::new(path_str); + let result = exclude.matches_path(path); + let actual = result.unwrap_or(false); + assert_eq!( + actual, should_match, + "Glob pattern with /** matching failed for {}: expected {}, got {}", + path_str, should_match, actual + ); + } + } +} diff --git a/yazi-config/src/files/files.rs b/yazi-config/src/files/files.rs index 33500e3a..ca12f5de 100644 --- a/yazi-config/src/files/files.rs +++ b/yazi-config/src/files/files.rs @@ -40,10 +40,10 @@ impl Files { let mut result = None; for exclude in &self.excludes { - if exclude.matches_context(context) { - if let Some(should_ignore) = exclude.matches_path(path) { - result = Some(should_ignore); - } + if exclude.matches_context(context) + && let Some(should_ignore) = exclude.matches_path(path) + { + result = Some(should_ignore); } }