Enhance 'cd' action to include ignore functionality and refactor excluded state handling

This commit is contained in:
Carlos de Paula 2025-11-06 17:30:19 -03:00
parent f068dafec2
commit 397b30b996
No known key found for this signature in database
6 changed files with 192 additions and 46 deletions

View file

@ -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!());

View file

@ -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)?;
}

View file

@ -15,9 +15,10 @@ impl Actor for Excluded {
const NAME: &str = "excluded";
fn act(cx: &mut Ctx, opt: Self::Options) -> Result<Data> {
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)?;
}

View file

@ -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)?;
}

View file

@ -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<bool> {
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
);
}
}
}

View file

@ -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);
}
}