From 545aad9eeab2e8e4b73a061f7c2cabb58d2fe522 Mon Sep 17 00:00:00 2001 From: Carlos de Paula Date: Mon, 20 Oct 2025 10:34:24 -0300 Subject: [PATCH] Improve file exclude integration with context-specific exclude patterns --- yazi-actor/src/mgr/ignore.rs | 30 +++-- yazi-config/preset/yazi-default.toml | 192 ++++++++++++++++----------- yazi-config/src/files/exclude.rs | 109 +++++++++++++++ yazi-config/src/files/files.rs | 56 ++++++++ yazi-config/src/files/mod.rs | 5 + yazi-config/src/lib.rs | 2 +- yazi-config/src/mgr/mgr.rs | 6 - yazi-config/src/yazi.rs | 10 +- yazi-fs/src/ignore.rs | 137 ++++++++++++++----- 9 files changed, 419 insertions(+), 128 deletions(-) create mode 100644 yazi-config/src/files/exclude.rs create mode 100644 yazi-config/src/files/files.rs create mode 100644 yazi-config/src/files/mod.rs diff --git a/yazi-actor/src/mgr/ignore.rs b/yazi-actor/src/mgr/ignore.rs index 388de693..8add8a99 100644 --- a/yazi-actor/src/mgr/ignore.rs +++ b/yazi-actor/src/mgr/ignore.rs @@ -16,14 +16,18 @@ impl Actor for Ignore { const NAME: &str = "ignore"; fn act(cx: &mut Ctx, _: Self::Options) -> Result { - let gitignore_enabled = YAZI.mgr.gitignore_enable; - let override_patterns = &YAZI.mgr.ignore_override; + let gitignores = YAZI.files.gitignores; - // If gitignore is disabled but we have override patterns, apply them - if !gitignore_enabled && !override_patterns.is_empty() { - // Load ignore filter from override patterns only + // Get exclude patterns for the current context + // Use path string for context matching + let cwd_str = cx.cwd().as_path().map(|p| p.display().to_string()).unwrap_or_default(); + let exclude_patterns = YAZI.files.excludes_for_context(&cwd_str); + + // If gitignores is disabled but we have exclude patterns, apply them + if !gitignores && !exclude_patterns.is_empty() { + // Load ignore filter from exclude patterns only let ignore_filter = if let Some(path) = cx.cwd().as_path() { - IgnoreFilter::from_patterns(path, override_patterns) + IgnoreFilter::from_patterns(path, &exclude_patterns) } else { None }; @@ -48,8 +52,10 @@ impl Actor for Ignore { // Apply to hovered if let Some(h) = cx.hovered_folder_mut() { + let hovered_str = h.url.as_path().map(|p| p.display().to_string()).unwrap_or_default(); + let hovered_excludes = YAZI.files.excludes_for_context(&hovered_str); let hovered_filter = if let Some(path) = h.url.as_path() { - IgnoreFilter::from_patterns(path, override_patterns) + IgnoreFilter::from_patterns(path, &hovered_excludes) } else { None }; @@ -66,8 +72,8 @@ impl Actor for Ignore { succ!(); } - // If gitignore is disabled and no override patterns, remove any ignore filter - if !gitignore_enabled { + // If gitignores is disabled and no exclude patterns, remove any ignore filter + if !gitignores { let hovered = cx.hovered().map(|f| f.urn().to_owned()); let apply = |f: &mut Folder| { // Always clear the filter, even when loading @@ -104,7 +110,7 @@ impl Actor for Ignore { // Load ignore filter from the current directory let ignore_filter = if let Some(path) = cx.cwd().as_path() { - IgnoreFilter::from_dir(path, override_patterns) + IgnoreFilter::from_dir(path, &exclude_patterns, gitignores) } else { None }; @@ -135,8 +141,10 @@ impl Actor for Ignore { // Apply to hovered if let Some(h) = cx.hovered_folder_mut() { // Load ignore filter for hovered directory if it's a directory + let hovered_str = h.url.as_path().map(|p| p.display().to_string()).unwrap_or_default(); + let hovered_excludes = YAZI.files.excludes_for_context(&hovered_str); let hovered_filter = if let Some(path) = h.url.as_path() { - IgnoreFilter::from_dir(path, override_patterns) + IgnoreFilter::from_dir(path, &hovered_excludes, gitignores) } else { None }; diff --git a/yazi-config/preset/yazi-default.toml b/yazi-config/preset/yazi-default.toml index b5b662a9..b9b8b479 100644 --- a/yazi-config/preset/yazi-default.toml +++ b/yazi-config/preset/yazi-default.toml @@ -16,90 +16,128 @@ scrolloff = 5 mouse_events = [ "click", "scroll" ] title_format = "Yazi: {cwd}" -# Git ignore integration -# gitignore_enable = false -# ignore_override = [ -# "*.log", # Hide all .log files -# "tmp/", # Hide tmp directory -# "!target/", # Show target/ even if gitignored (negation) +[files] +# Enable git ignore integration +# gitignores = false + +# Context-specific exclude patterns +# Patterns are compiled into glob matchers for efficient matching +# Patterns starting with '!' negate (whitelist) previous matches +# excludes = [ +# # SFTP temporary files +# { urn = "*.tmp", in = "sftp://**" }, +# # Python cache in search results +# { urn = "/root/**/*.pyc", in = "search://**" }, +# # Multiple patterns for /code directory (supports arrays) +# { urn = [".git", ".DS_Store", "__pycache__"], in = "/code/**" }, +# # Negation: show target/ even if previously ignored +# { urn = "!target/", in = "/code/**" }, +# # Fallback rule for all contexts +# { urn = ".DS_Store", in = "*" }, # ] [preview] -wrap = "no" -tab_size = 2 -max_width = 600 -max_height = 900 -cache_dir = "" -image_delay = 30 -image_filter = "triangle" -image_quality = 75 -ueberzug_scale = 1 -ueberzug_offset = [ 0, 0, 0, 0 ] +wrap = "no" +tab_size = 2 +max_width = 600 +max_height = 900 +cache_dir = "" +image_delay = 30 +image_filter = "triangle" +image_quality = 75 +ueberzug_scale = 1 +ueberzug_offset = [0, 0, 0, 0] [opener] edit = [ - { run = "${EDITOR:-vi} %s", desc = "$EDITOR", for = "unix", block = true }, - { run = "code %s", desc = "code", for = "windows", orphan = true }, - { run = "code -w %s", desc = "code (block)", for = "windows", block = true }, + { run = "${EDITOR:-vi} %s", desc = "$EDITOR", for = "unix", block = true }, + { run = "code %s", desc = "code", for = "windows", orphan = true }, + { run = "code -w %s", desc = "code (block)", for = "windows", block = true }, ] play = [ - { run = "xdg-open %s1", desc = "Play", for = "linux" }, - { run = "open %s", desc = "Play", for = "macos" }, + { run = "xdg-open %s1", desc = "Play", for = "linux" }, + { run = "open %s", desc = "Play", for = "macos" }, { run = 'start "" %s1', orphan = true, desc = "Play", for = "windows" }, - { run = "termux-open %s1", desc = "Play", for = "android" }, + { run = "termux-open %s1", desc = "Play", for = "android" }, { run = "mediainfo %s1; echo 'Press enter to exit'; read _", block = true, desc = "Show media info", for = "unix" }, { run = "mediainfo %s1 & pause", block = true, desc = "Show media info", for = "windows" }, ] open = [ - { run = "xdg-open %s1", desc = "Open", for = "linux" }, - { run = "open %s", desc = "Open", for = "macos" }, - { run = 'start "" %s1', desc = "Open", for = "windows", orphan = true }, + { run = "xdg-open %s1", desc = "Open", for = "linux" }, + { run = "open %s", desc = "Open", for = "macos" }, + { run = 'start "" %s1', desc = "Open", for = "windows", orphan = true }, { run = "termux-open %s1", desc = "Open", for = "android" }, ] reveal = [ - { run = "xdg-open %d1", desc = "Reveal", for = "linux" }, - { run = "open -R %s1", desc = "Reveal", for = "macos" }, + { run = "xdg-open %d1", desc = "Reveal", for = "linux" }, + { run = "open -R %s1", desc = "Reveal", for = "macos" }, { run = "explorer /select,%s1", desc = "Reveal", for = "windows", orphan = true }, - { run = "termux-open %d1", desc = "Reveal", for = "android" }, + { run = "termux-open %d1", desc = "Reveal", for = "android" }, { run = "clear; exiftool %s1; echo 'Press enter to exit'; read _", desc = "Show EXIF", for = "unix", block = true }, ] -extract = [ - { run = "ya pub extract --list %s", desc = "Extract here" }, -] +extract = [{ run = "ya pub extract --list %s", desc = "Extract here" }] download = [ { run = "ya emit download --open %S", desc = "Download and open" }, - { run = "ya emit download %S", desc = "Download" }, + { run = "ya emit download %S", desc = "Download" }, ] [open] rules = [ # Folder - { url = "*/", use = [ "edit", "open", "reveal" ] }, + { url = "*/", use = [ + "edit", + "open", + "reveal", + ] }, # Text - { mime = "text/*", use = [ "edit", "reveal" ] }, + { mime = "text/*", use = [ + "edit", + "reveal", + ] }, # Image - { mime = "image/*", use = [ "open", "reveal" ] }, + { mime = "image/*", use = [ + "open", + "reveal", + ] }, # Media - { mime = "{audio,video}/*", use = [ "play", "reveal" ] }, + { mime = "{audio,video}/*", use = [ + "play", + "reveal", + ] }, # Archive - { mime = "application/{zip,rar,7z*,tar,gzip,xz,zstd,bzip*,lzma,compress,archive,cpio,arj,xar,ms-cab*}", use = [ "extract", "reveal" ] }, + { mime = "application/{zip,rar,7z*,tar,gzip,xz,zstd,bzip*,lzma,compress,archive,cpio,arj,xar,ms-cab*}", use = [ + "extract", + "reveal", + ] }, # JSON - { mime = "application/{json,ndjson}", use = [ "edit", "reveal" ] }, - { mime = "*/javascript", use = [ "edit", "reveal" ] }, + { mime = "application/{json,ndjson}", use = [ + "edit", + "reveal", + ] }, + { mime = "*/javascript", use = [ + "edit", + "reveal", + ] }, # Empty file - { mime = "inode/empty", use = [ "edit", "reveal" ] }, + { mime = "inode/empty", use = [ + "edit", + "reveal", + ] }, # Virtual file system { mime = "vfs/{absent,stale}", use = "download" }, # Fallback - { url = "*", use = [ "open", "reveal" ] }, + { url = "*", use = [ + "open", + "reveal", + ] }, ] [tasks] -micro_workers = 10 -macro_workers = 10 -bizarre_retry = 3 -image_alloc = 536870912 # 512MB -image_bound = [ 10000, 10000 ] +micro_workers = 10 +macro_workers = 10 +bizarre_retry = 3 +image_alloc = 536870912 # 512MB +image_bound = [10000, 10000] suppress_preload = false [plugin] @@ -176,70 +214,70 @@ previewers = [ cursor_blink = false # cd -cd_title = "Change directory:" +cd_title = "Change directory:" cd_origin = "top-center" -cd_offset = [ 0, 2, 50, 3 ] +cd_offset = [0, 2, 50, 3] # create -create_title = [ "Create:", "Create (dir):" ] +create_title = ["Create:", "Create (dir):"] create_origin = "top-center" -create_offset = [ 0, 2, 50, 3 ] +create_offset = [0, 2, 50, 3] # rename -rename_title = "Rename:" +rename_title = "Rename:" rename_origin = "hovered" -rename_offset = [ 0, 1, 50, 3 ] +rename_offset = [0, 1, 50, 3] # filter -filter_title = "Filter:" +filter_title = "Filter:" filter_origin = "top-center" -filter_offset = [ 0, 2, 50, 3 ] +filter_offset = [0, 2, 50, 3] # find -find_title = [ "Find next:", "Find previous:" ] +find_title = ["Find next:", "Find previous:"] find_origin = "top-center" -find_offset = [ 0, 2, 50, 3 ] +find_offset = [0, 2, 50, 3] # search -search_title = "Search via {n}:" +search_title = "Search via {n}:" search_origin = "top-center" -search_offset = [ 0, 2, 50, 3 ] +search_offset = [0, 2, 50, 3] # shell -shell_title = [ "Shell:", "Shell (block):" ] +shell_title = ["Shell:", "Shell (block):"] shell_origin = "top-center" -shell_offset = [ 0, 2, 50, 3 ] +shell_offset = [0, 2, 50, 3] [confirm] # trash -trash_title = "Trash {n} selected file{s}?" -trash_origin = "center" -trash_offset = [ 0, 0, 70, 20 ] +trash_title = "Trash {n} selected file{s}?" +trash_origin = "center" +trash_offset = [0, 0, 70, 20] # delete -delete_title = "Permanently delete {n} selected file{s}?" -delete_origin = "center" -delete_offset = [ 0, 0, 70, 20 ] +delete_title = "Permanently delete {n} selected file{s}?" +delete_origin = "center" +delete_offset = [0, 0, 70, 20] # overwrite -overwrite_title = "Overwrite file?" -overwrite_body = "Will overwrite the following file:" +overwrite_title = "Overwrite file?" +overwrite_body = "Will overwrite the following file:" overwrite_origin = "center" -overwrite_offset = [ 0, 0, 50, 15 ] +overwrite_offset = [0, 0, 50, 15] # quit -quit_title = "Quit?" -quit_body = "The following tasks are still running, are you sure you want to quit?" +quit_title = "Quit?" +quit_body = "The following tasks are still running, are you sure you want to quit?" quit_origin = "center" -quit_offset = [ 0, 0, 50, 15 ] +quit_offset = [0, 0, 50, 15] [pick] -open_title = "Open with:" +open_title = "Open with:" open_origin = "hovered" -open_offset = [ 0, 1, 50, 7 ] +open_offset = [0, 1, 50, 7] [which] -sort_by = "none" +sort_by = "none" sort_sensitive = false -sort_reverse = false -sort_translit = false +sort_reverse = false +sort_translit = false diff --git a/yazi-config/src/files/exclude.rs b/yazi-config/src/files/exclude.rs new file mode 100644 index 00000000..263c4884 --- /dev/null +++ b/yazi-config/src/files/exclude.rs @@ -0,0 +1,109 @@ +use std::path::Path; + +use globset::{Glob, GlobSet, GlobSetBuilder}; +use serde::Deserialize; + +/// Represents a single exclude rule with patterns and context +#[derive(Debug, Clone, Deserialize)] +pub struct Exclude { + /// Pattern(s) to match files/directories against + /// Can be a single glob pattern string or an array of glob patterns + /// Patterns starting with '!' negate (whitelist) previously matched patterns + #[serde(deserialize_with = "deserialize_urn")] + pub urn: Vec, + + /// Context where this exclude rule applies + /// Supports glob patterns like "/code/**", "sftp://**", "search://**", or "*" for all + #[serde(rename = "in")] + pub context: String, + + #[serde(skip)] + compiled: Option, +} + +#[derive(Debug, Clone)] +struct CompiledPatterns { + /// Regular patterns (to ignore) + ignores: GlobSet, + /// Negated patterns (to whitelist/un-ignore) + whitelists: GlobSet, +} + +fn deserialize_urn<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum UrnOrUrns { + Single(String), + Multiple(Vec), + } + + match UrnOrUrns::deserialize(deserializer)? { + UrnOrUrns::Single(s) => Ok(vec![s]), + UrnOrUrns::Multiple(v) => Ok(v), + } +} + +impl Exclude { + /// Compile the glob patterns into GlobSets for efficient matching + pub fn compile(&mut self) -> Result<(), globset::Error> { + let mut ignore_builder = GlobSetBuilder::new(); + let mut whitelist_builder = GlobSetBuilder::new(); + + for pattern in &self.urn { + if let Some(negated) = pattern.strip_prefix('!') { + // Negation pattern - add to whitelist + let glob = Glob::new(negated)?; + whitelist_builder.add(glob); + } else { + // Regular pattern - add to ignore list + let glob = Glob::new(pattern)?; + ignore_builder.add(glob); + } + } + + self.compiled = Some(CompiledPatterns { + ignores: ignore_builder.build()?, + whitelists: whitelist_builder.build()?, + }); + + Ok(()) + } + + /// Check if a path matches this exclude rule + /// Returns Some(true) if path should be ignored, Some(false) if whitelisted, + /// None if no match + pub fn matches_path(&self, path: &Path) -> Option { + let compiled = self.compiled.as_ref()?; + + // Check whitelist first (negation takes precedence) + if compiled.whitelists.is_match(path) { + return Some(false); // Explicitly NOT ignored + } + + // Check ignore patterns + if compiled.ignores.is_match(path) { + return Some(true); // Should be ignored + } + + None // No match + } + + /// Check if this exclude rule applies to the given path context + pub fn matches_context(&self, path: &str) -> bool { + // Wildcard matches everything + if self.context == "*" { + return true; + } + + // Handle glob patterns + if self.context.ends_with("/**") { + let prefix = &self.context[..self.context.len() - 3]; + path.starts_with(prefix) + } else { + path == self.context || path.starts_with(&format!("{}/", self.context)) + } + } +} diff --git a/yazi-config/src/files/files.rs b/yazi-config/src/files/files.rs new file mode 100644 index 00000000..a3e22ab7 --- /dev/null +++ b/yazi-config/src/files/files.rs @@ -0,0 +1,56 @@ +use std::path::Path; + +use serde::Deserialize; +use yazi_codegen::DeserializeOver2; + +use super::Exclude; + +/// Configuration for file filtering +#[derive(Debug, Deserialize, DeserializeOver2, Default)] +pub struct Files { + /// Enable git ignore integration + #[serde(default)] + pub gitignores: bool, + + /// List of exclude rules with context-specific patterns + #[serde(default)] + pub excludes: Vec, +} + +impl Files { + /// Compile all glob patterns in exclude rules + pub fn compile(&mut self) -> Result<(), String> { + for exclude in &mut self.excludes { + exclude.compile().map_err(|e| format!("Failed to compile glob pattern: {}", e))?; + } + Ok(()) + } + + /// Get all exclude patterns that apply to a given context + pub fn excludes_for_context(&self, context: &str) -> Vec { + self + .excludes + .iter() + .filter(|e| e.matches_context(context)) + .flat_map(|e| e.urn.iter().cloned()) + .collect() + } + + /// Check if a path should be excluded based on compiled patterns for a given + /// context Returns Some(true) if should be ignored, Some(false) if + /// whitelisted, None if no match + pub fn matches_path(&self, path: &Path, context: &str) -> Option { + // Process rules in order, last match wins + 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); + } + } + } + + result + } +} diff --git a/yazi-config/src/files/mod.rs b/yazi-config/src/files/mod.rs new file mode 100644 index 00000000..ec9007ba --- /dev/null +++ b/yazi-config/src/files/mod.rs @@ -0,0 +1,5 @@ +mod exclude; +mod files; + +pub use exclude::*; +pub use files::*; diff --git a/yazi-config/src/lib.rs b/yazi-config/src/lib.rs index 11c4e37f..84a8d0be 100644 --- a/yazi-config/src/lib.rs +++ b/yazi-config/src/lib.rs @@ -1,4 +1,4 @@ -yazi_macro::mod_pub!(keymap mgr open opener plugin popup preview tasks theme which vfs); +yazi_macro::mod_pub!(files keymap mgr open opener plugin popup preview tasks theme which vfs); yazi_macro::mod_flat!(color icon layout pattern platform preset priority style yazi); diff --git a/yazi-config/src/mgr/mgr.rs b/yazi-config/src/mgr/mgr.rs index 22d9df2f..6be3ebfb 100644 --- a/yazi-config/src/mgr/mgr.rs +++ b/yazi-config/src/mgr/mgr.rs @@ -24,12 +24,6 @@ pub struct Mgr { pub scrolloff: SyncCell, pub mouse_events: SyncCell, pub title_format: String, - - // Filtering - #[serde(default)] - pub gitignore_enable: bool, - #[serde(default)] - pub ignore_override: Vec, } impl Mgr { diff --git a/yazi-config/src/yazi.rs b/yazi-config/src/yazi.rs index ba346ccf..0701ccc8 100644 --- a/yazi-config/src/yazi.rs +++ b/yazi-config/src/yazi.rs @@ -3,11 +3,13 @@ use serde::Deserialize; use yazi_codegen::DeserializeOver1; use yazi_fs::{Xdg, ok_or_not_found}; -use crate::{mgr, open, opener, plugin, popup, preview, tasks, which}; +use crate::{files, mgr, open, opener, plugin, popup, preview, tasks, which}; #[derive(Deserialize, DeserializeOver1)] pub struct Yazi { pub mgr: mgr::Mgr, + #[serde(default)] + pub files: files::Files, pub preview: preview::Preview, pub opener: opener::Opener, pub open: open::Open, @@ -26,9 +28,13 @@ impl Yazi { .with_context(|| format!("Failed to read config {p:?}")) } - pub(super) fn reshape(self) -> Result { + pub(super) fn reshape(mut self) -> Result { + // Compile glob patterns in exclude rules + self.files.compile().map_err(|e| anyhow::anyhow!(e))?; + Ok(Self { mgr: self.mgr.reshape()?, + files: self.files, preview: self.preview.reshape()?, opener: self.opener.reshape()?, open: self.open.reshape()?, diff --git a/yazi-fs/src/ignore.rs b/yazi-fs/src/ignore.rs index 2ed9896f..00ecc35d 100644 --- a/yazi-fs/src/ignore.rs +++ b/yazi-fs/src/ignore.rs @@ -2,51 +2,93 @@ use std::{collections::HashSet, path::{Path, PathBuf}}; use yazi_shared::url::AsUrl; +/// Filter for ignoring files based on git ignore rules and custom exclude +/// patterns. +/// +/// This filter combines git's native ignore functionality with custom exclude +/// patterns that follow gitignore syntax. Exclude patterns can be +/// context-specific and take precedence over git's ignore rules using the `!` +/// prefix for negation. #[derive(Clone, Debug)] pub struct IgnoreFilter { + /// Set of paths ignored by git (from git status) ignored_paths: HashSet, + /// Custom gitignore matcher for exclude patterns gitignore: Option, } impl IgnoreFilter { - /// Create a new IgnoreFilter by checking git ignore status for files in the - /// given directory - pub fn from_dir(dir: impl AsRef, override_patterns: &[String]) -> Option { + /// Creates a new `IgnoreFilter` by checking git ignore status for the given + /// directory. + /// + /// # Arguments + /// + /// * `dir` - Directory to check for git ignore status + /// * `exclude_patterns` - Custom gitignore patterns that override git's rules + /// * `use_git` - Whether to integrate with git ignore rules + /// + /// # Returns + /// + /// `Some(IgnoreFilter)` if git repository is found (when use_git=true) or + /// exclude patterns exist, `None` if no git repository and no exclude + /// patterns. + /// + /// # Behavior + /// + /// - Discovers the git repository containing `dir` (if use_git=true) + /// - Collects all ignored paths from git status + /// - Builds a custom gitignore matcher from exclude patterns (relative to + /// repo root) + /// - Exclude patterns are checked first and take precedence over git ignore + /// rules + /// - Negation patterns (starting with `!`) can whitelist git-ignored files + pub fn from_dir( + dir: impl AsRef, + exclude_patterns: &[String], + use_git: bool, + ) -> Option { let dir = dir.as_ref(); - // Try to open the git repository for this directory - let repo = git2::Repository::discover(dir).ok()?; + let (workdir, ignored_paths) = if use_git { + // Try to open the git repository for this directory + let repo = git2::Repository::discover(dir).ok()?; - // Get the workdir (root of the git repository) - let workdir = repo.workdir()?; + // Get the workdir (root of the git repository) + let workdir = repo.workdir()?.to_path_buf(); - // Get git statuses for the repository - let statuses = match repo.statuses(None) { - Ok(s) => s, - Err(_) => return None, - }; + // Get git statuses for the repository + let statuses = match repo.statuses(None) { + Ok(s) => s, + Err(_) => return None, + }; - // Build a set of ALL ignored paths from git status - let mut ignored_paths = HashSet::new(); + // Build a set of ALL ignored paths from git status + let mut ignored_paths = HashSet::new(); - // Manually add .git directory as ignored (like eza does) - ignored_paths.insert(workdir.join(".git")); + // Manually add .git directory as ignored (like eza does) + ignored_paths.insert(workdir.join(".git")); - // Add all ignored files from git status - for status in statuses.iter() { - if status.status() == git2::Status::IGNORED { - if let Some(path) = status.path() { + // Add all ignored files from git status + for status in statuses.iter() { + if status.status() == git2::Status::IGNORED + && let Some(path) = status.path() + { ignored_paths.insert(workdir.join(path)); } } - } - // Build custom gitignore from override patterns if provided - let gitignore = if !override_patterns.is_empty() { - let mut builder = ignore::gitignore::GitignoreBuilder::new(workdir); + (workdir, ignored_paths) + } else { + // No git integration, use current directory as base + (dir.to_path_buf(), HashSet::new()) + }; - // Add each override pattern - for pattern in override_patterns { + // Build custom gitignore from exclude patterns if provided + let gitignore = if !exclude_patterns.is_empty() { + let mut builder = ignore::gitignore::GitignoreBuilder::new(&workdir); + + // Add each exclude pattern + for pattern in exclude_patterns { let _ = builder.add_line(None, pattern); } @@ -58,7 +100,7 @@ impl IgnoreFilter { if ignored_paths.is_empty() || (ignored_paths.len() == 1 && ignored_paths.contains(&workdir.join(".git"))) { - // If we have no git-ignored paths but we have override patterns, still create + // If we have no git-ignored paths but we have exclude patterns, still create // the filter if gitignore.is_some() { return Some(Self { ignored_paths, gitignore }); @@ -71,7 +113,21 @@ impl IgnoreFilter { Some(Self { ignored_paths, gitignore }) } - /// Create a new IgnoreFilter from only override patterns (no git integration) + /// Creates a new `IgnoreFilter` from only exclude patterns without git + /// integration. + /// + /// # Arguments + /// + /// * `dir` - Base directory for pattern matching + /// * `patterns` - Gitignore-style patterns to apply + /// + /// # Returns + /// + /// `Some(IgnoreFilter)` if patterns are provided, `None` if patterns is + /// empty. + /// + /// This is useful when `gitignores = false` but custom exclude patterns + /// are still desired. pub fn from_patterns(dir: impl AsRef, patterns: &[String]) -> Option { if patterns.is_empty() { return None; @@ -90,7 +146,28 @@ impl IgnoreFilter { Some(Self { ignored_paths: HashSet::new(), gitignore: Some(gitignore) }) } - /// Check if a file should be ignored based on its URL + /// Checks if a file should be ignored based on its URL. + /// + /// # Arguments + /// + /// * `url` - URL of the file to check + /// + /// # Returns + /// + /// `true` if the file should be ignored, `false` otherwise. + /// + /// # Matching Logic + /// + /// 1. Check override patterns first: + /// - If matched as whitelist (negation `!`), return `false` (not ignored) + /// - If matched as ignore, return `true` (ignored) + /// 2. Check if path is directly in git's ignored set + /// 3. Check if any parent directory is ignored by git + /// - If parent is ignored, also check if override patterns whitelist it + /// - If parent is whitelisted, children are not ignored + /// + /// This ensures override patterns take precedence and negation patterns work + /// correctly even when navigating inside ignored directories. pub fn matches_url(&self, url: impl AsUrl) -> bool { let url = url.as_url(); let path = url.loc.as_path(); @@ -147,8 +224,6 @@ impl IgnoreFilter { #[cfg(test)] mod tests { - use super::*; - #[test] fn test_git_repo_discovery() { // This test assumes we're running in the yazi git repo