diff --git a/crates/ide-core/src/ignored.rs b/crates/ide-core/src/ignored.rs new file mode 100644 index 000000000..fb6c20a35 --- /dev/null +++ b/crates/ide-core/src/ignored.rs @@ -0,0 +1,106 @@ +//! "Ignored Files and Folders" (IDEA: Settings › File Types): file names the IDE +//! treats as nonexistent — hidden from the project view and navigation, never +//! indexed or searched, never offered as unversioned changes. + +/// IDEA's default list, plus `.jj` and the Windows shell files. +pub const DEFAULT_IGNORED_NAMES: &[&str] = &[ + "*.pyc", + "*.pyo", + "*.rbc", + "*.yarb", + "*~", + ".DS_Store", + ".git", + ".hg", + ".jj", + ".svn", + "CVS", + "__pycache__", + "_svn", + "vssver.scc", + "vssver2.scc", + "Thumbs.db", + "desktop.ini", +]; + +/// Matches single path segments against name patterns (`*` and `?` wildcards). +#[derive(Clone, Debug, Default)] +pub struct IgnoredNames { + exact: Vec, + wildcards: Vec, +} + +impl IgnoredNames { + pub fn new(patterns: &[String]) -> Self { + let mut out = Self::default(); + for p in patterns.iter().map(|p| p.trim()).filter(|p| !p.is_empty() && !p.contains('/')) { + if p.contains(['*', '?']) { + out.wildcards.push(p.to_owned()); + } else { + out.exact.push(p.to_owned()); + } + } + out + } + + pub fn is_ignored_name(&self, name: &str) -> bool { + self.exact.iter().any(|e| e == name) || self.wildcards.iter().any(|w| wildcard(w.as_bytes(), name.as_bytes())) + } + + /// Whether any segment of a `/`-separated relative path is ignored. + pub fn is_ignored_path(&self, rel: &str) -> bool { + rel.split('/').any(|seg| !seg.is_empty() && self.is_ignored_name(seg)) + } +} + +fn wildcard(p: &[u8], s: &[u8]) -> bool { + let (mut pi, mut si, mut star, mut mark) = (0, 0, None, 0); + while si < s.len() { + if pi < p.len() && (p[pi] == b'?' || p[pi] == s[si]) { + pi += 1; + si += 1; + } else if pi < p.len() && p[pi] == b'*' { + star = Some(pi); + pi += 1; + mark = si; + } else if let Some(st) = star { + pi = st + 1; + mark += 1; + si = mark; + } else { + return false; + } + } + p[pi..].iter().all(|&c| c == b'*') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn defaults() -> IgnoredNames { + IgnoredNames::new(&DEFAULT_IGNORED_NAMES.iter().map(|s| s.to_string()).collect::>()) + } + + #[test] + fn matches_names_and_wildcards() { + let m = defaults(); + for name in [".DS_Store", "__pycache__", "a.pyc", "notes.txt~", "Thumbs.db"] { + assert!(m.is_ignored_name(name), "{name}"); + } + for name in ["DS_Store", "a.py", "pyc", "src", ".gitignore"] { + assert!(!m.is_ignored_name(name), "{name}"); + } + assert!(wildcard(b"a?c*", b"abcdef")); + assert!(!wildcard(b"a?c", b"abcd")); + } + + #[test] + fn matches_any_path_segment() { + let m = defaults(); + assert!(m.is_ignored_path("src/.DS_Store")); + assert!(m.is_ignored_path("pkg/__pycache__/")); + assert!(m.is_ignored_path("pkg/__pycache__/mod.cpython-312.pyc")); + assert!(!m.is_ignored_path("src/main.rs")); + } +} diff --git a/crates/ide-core/src/lib.rs b/crates/ide-core/src/lib.rs index ac3e2d603..7113b25e0 100644 --- a/crates/ide-core/src/lib.rs +++ b/crates/ide-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod editor_settings; pub mod error; pub mod event; pub mod ids; +pub mod ignored; pub mod ipc_path; pub mod project_dir; pub mod settings; diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index d67d2d51c..e78c87356 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -303,6 +303,8 @@ pub struct FileSettings { /// Save on focus loss / tab switch, like IDEA. pub auto_save: bool, pub exclude: Vec, + /// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`]. + pub ignored_names: Vec, pub trim_trailing_whitespace: bool, pub ensure_final_newline: bool, /// Local History keeps changes from this many working days (days with changes), like IDEA. @@ -316,6 +318,7 @@ impl Default for FileSettings { Self { auto_save: true, exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()], + ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(), trim_trailing_whitespace: true, ensure_final_newline: true, local_history_days: 5, @@ -423,9 +426,9 @@ pub struct ProjectStructureSettings { } impl Settings { - /// `files.exclude` plus the Project Structure excluded folders as globs. + /// `files.exclude`, the ignored names and the Project Structure excluded folders as globs. pub fn exclude_globs(&self) -> Vec { - let mut out = self.files.exclude.clone(); + let mut out = self.file_excludes(); for dir in &self.project_structure.excluded { let dir = dir.trim().trim_matches('/'); if !dir.is_empty() { @@ -434,6 +437,17 @@ impl Settings { } out } + + /// `files.exclude` plus `files.ignoredNames` (a bare name matches at any depth). + pub fn file_excludes(&self) -> Vec { + let mut out = self.files.exclude.clone(); + out.extend(self.files.ignored_names.iter().filter(|n| !n.trim().is_empty()).cloned()); + out + } + + pub fn ignored_names(&self) -> crate::ignored::IgnoredNames { + crate::ignored::IgnoredNames::new(&self.files.ignored_names) + } } #[cfg(test)] diff --git a/crates/ide-index/src/filter.rs b/crates/ide-index/src/filter.rs index 3259ff79b..edf5b5a36 100644 --- a/crates/ide-index/src/filter.rs +++ b/crates/ide-index/src/filter.rs @@ -9,8 +9,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::time::UNIX_EPOCH; -/// VCS metadata directories are never indexed. -const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj", ".DS_Store"]; +/// VCS metadata directories are never indexed, whatever the ignored names say. +const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj"]; pub(crate) struct PathFilter { root: PathBuf, diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 70b597c1b..ed6d94e34 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -2,6 +2,7 @@ //! writes (commit, checkout, push/pull, stash, merge) shell out to `git` so //! hooks, credentials and signing behave exactly as on the command line. +use ide_core::ignored::IgnoredNames; use ide_core::{CancellationToken, IdeResult}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -283,6 +284,7 @@ pub struct Repo { root: PathBuf, git_dir: PathBuf, inner: std::sync::Mutex, + ignored: std::sync::RwLock, } impl Repo { @@ -294,6 +296,10 @@ impl Repo { pub fn root(&self) -> &Path { &self.root } + /// Untracked files with one of these names (or under such a folder) are not reported. + pub fn set_ignored_names(&self, names: IgnoredNames) { + *self.ignored.write().unwrap_or_else(|p| p.into_inner()) = names; + } /// Staged (HEAD↔index) and unstaged (index↔worktree, incl. untracked) changes, /// sorted by path. Conflicted files report `unstaged: Conflicted` and no staged kind. pub fn status(&self) -> IdeResult> { diff --git a/crates/ide-vcs/src/repo.rs b/crates/ide-vcs/src/repo.rs index b0533f373..17aeb67a8 100644 --- a/crates/ide-vcs/src/repo.rs +++ b/crates/ide-vcs/src/repo.rs @@ -47,7 +47,7 @@ pub(crate) fn discover(path: &Path) -> IdeResult> { let common_dir = absolute(repo.common_dir())?; let files = config_files(&git_dir, &common_dir); let cache = Cache { config_stamp: stamp(&files), config_files: files, repo: ts }; - Ok(Some(Repo { root, git_dir, inner: std::sync::Mutex::new(cache) })) + Ok(Some(Repo { root, git_dir, inner: std::sync::Mutex::new(cache), ignored: Default::default() })) } fn absolute(p: &Path) -> IdeResult { diff --git a/crates/ide-vcs/src/status.rs b/crates/ide-vcs/src/status.rs index a98c3d369..6fefb630f 100644 --- a/crates/ide-vcs/src/status.rs +++ b/crates/ide-vcs/src/status.rs @@ -18,6 +18,7 @@ pub(crate) fn status(r: &Repo) -> IdeResult> { let repo = r.gix()?; let mut map: BTreeMap = BTreeMap::new(); let mut conflicts: HashSet = HashSet::new(); + let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone(); let platform = repo .status(gix::progress::Discard) @@ -50,6 +51,9 @@ pub(crate) fn status(r: &Repo) -> IdeResult> { continue; } let mut path = to_string(entry.rela_path.as_ref()); + if ignored.is_ignored_path(&path) { + continue; + } if entry.disk_kind.is_some_and(|k| k.is_dir()) && !path.ends_with('/') { path.push('/'); } diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index fa12939ff..c02d5ccc5 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -192,8 +192,12 @@ fn status_matrix() { fx.write("dir/nested/untracked2.txt", "u\n"); fx.write("debug.log", "ignored\n"); fx.write("build/out.bin", "ignored\n"); + fx.write(".DS_Store", "junk"); + fx.write("dir/.DS_Store", "junk"); + fx.write("pkg/__pycache__/m.pyc", "junk"); let repo = fx.repo(); + repo.set_ignored_names(ide_core::settings::Settings::default().ignored_names()); let st = repo.status().unwrap(); let check = |rel: &str, staged: Option, unstaged: Option| { let s = status_of(&st, rel); @@ -213,6 +217,7 @@ fn status_matrix() { assert_eq!(status_of(&st, "renamed.txt").renamed_from.as_deref(), Some("rename_me.txt")); assert!(!st.iter().any(|s| s.rel_path == "rename_me.txt"), "rename source listed: {st:#?}"); assert!(!st.iter().any(|s| s.rel_path.contains("debug.log") || s.rel_path.contains("build"))); + assert!(!st.iter().any(|s| s.rel_path.contains(".DS_Store") || s.rel_path.contains("pkg")), "{st:#?}"); // Sorted by path. let paths: Vec<_> = st.iter().map(|s| s.rel_path.clone()).collect(); let mut sorted = paths.clone(); diff --git a/src-tauri/src/commands/analysis.rs b/src-tauri/src/commands/analysis.rs index dcd2299cb..76b9cfaea 100644 --- a/src-tauri/src/commands/analysis.rs +++ b/src-tauri/src/commands/analysis.rs @@ -23,11 +23,9 @@ use std::time::Instant; use tauri::ipc::Channel; use tauri::{State, Window}; -/// Files of `scope`, minus excluded roots. +/// Files of `scope`, minus excluded roots and ignored names. fn files_in_scope(project: &Project, scope: &SearchScope, cancel: &CancellationToken) -> IdeResult> { - let excluded: Vec = - project.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect(); - let mask = FileMask::new(&[], &excluded)?; + let mask = FileMask::new(&[], &project.search_excludes())?; Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)? .into_iter() .map(|f| SourceFile { path: f.path, rel_path: f.rel_path }) diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index b0b020478..e59e501e0 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -29,9 +29,10 @@ pub async fn search_start( on_event: Channel, ) -> IdeResult<()> { let project = state.project(window.label())?; - // Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) are never searched. + // Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and + // ignored names are never searched. + query.exclude.extend(project.search_excludes()); let roots = project.roots(); - query.exclude.extend(roots.excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/')))); let set = match query.scope { ide_search::SearchScope::FileSet { set } => Some(set), _ => None, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 77aed1065..f406fb828 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -376,9 +376,13 @@ impl AppState { impl Project { /// Runs `f` with the git repository, or fails when the project is not under git. pub fn with_repo(&self, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { - crate::commands::vcs::apply_git_executable(&self.settings.effective()); + let settings = self.settings.effective(); + crate::commands::vcs::apply_git_executable(&settings); match self.repo.lock().as_ref() { - Some(repo) => f(repo), + Some(repo) => { + repo.set_ignored_names(settings.ignored_names()); + f(repo) + } None => Err(IdeError::unavailable("project is not a git repository")), } } @@ -436,7 +440,22 @@ impl Project { self.cache_dir.join("build") } - /// `files.exclude` plus the effective excluded roots (user-marked and detected). + /// File masks (`ide_search::FileMask`) for Find in Files and inspections: the + /// excluded roots and the ignored names (files, and everything under such folders). + pub fn search_excludes(&self) -> Vec { + let mut out: Vec = + self.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect(); + for name in &self.settings.effective().files.ignored_names { + let name = name.trim(); + if !name.is_empty() && !name.contains(['/', ',']) { + out.push(name.to_owned()); + out.push(format!("**/{name}/**")); + } + } + out + } + + /// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected). pub fn exclude_globs(&self) -> Vec { exclude_globs(&self.settings.effective(), &self.detected_roots.read()) } @@ -509,7 +528,7 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa } fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec { - let mut globs = settings.files.exclude.clone(); + let mut globs = settings.file_excludes(); globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs()); globs } diff --git a/src/bindings.ts b/src/bindings.ts index fb195b98e..c027c7898 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -1854,6 +1854,8 @@ export type FileSettings = { /** Save on focus loss / tab switch, like IDEA. */ autoSave?: boolean, exclude?: string[], + /** Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`]. */ + ignoredNames?: string[], trimTrailingWhitespace?: boolean, ensureFinalNewline?: boolean, /** Local History keeps changes from this many working days (days with changes), like IDEA. */ diff --git a/src/editor/core/view/navBar.ts b/src/editor/core/view/navBar.ts index 59f58458e..81631ce14 100644 --- a/src/editor/core/view/navBar.ts +++ b/src/editor/core/view/navBar.ts @@ -2,6 +2,7 @@ * Navigation bar popup (⌘↑) model: the path of the active file as segments * (project › dirs › file › symbols at the caret) and the child list of a segment. */ +import { isIgnoredName } from "@/platform/ignoredNames"; import type { FileEntry, OutlineItem } from "@/platform/ipc"; import { badgeKind, fileIconName } from "@/ui"; @@ -19,7 +20,11 @@ const parentOf = (p: string) => p.slice(0, Math.max(p.lastIndexOf("/"), 0)) || " const nameOf = (p: string) => p.slice(p.lastIndexOf("/") + 1); /** Segments for `path`; directories from the project root, or just the parent folder outside it. */ -export function navSegments(project: { root: string; name: string } | null, path: string, chain: readonly OutlineItem[]): NavSegment[] { +export function navSegments( + project: { root: string; name: string } | null, + path: string, + chain: readonly OutlineItem[], +): NavSegment[] { const out: NavSegment[] = []; const parent = parentOf(path); if (project && (parent === project.root || parent.startsWith(project.root + "/"))) { @@ -42,12 +47,17 @@ export function navSegments(project: { root: string; name: string } | null, path return out; } -const HIDDEN = new Set([".git", ".hg", ".svn", ".DS_Store", "__pycache__"]); - export function dirChildren(entries: readonly FileEntry[]): NavChild[] { return entries - .filter((e) => !HIDDEN.has(e.name)) - .map((e) => ({ kind: e.kind === "dir" || (e.kind === "symlink" && e.size === 0) ? "dir" : "file", path: e.path, label: e.name }) as NavChild); + .filter((e) => !isIgnoredName(e.name)) + .map( + (e) => + ({ + kind: e.kind === "dir" || (e.kind === "symlink" && e.size === 0) ? "dir" : "file", + path: e.path, + label: e.name, + }) as NavChild, + ); } /** Children of a file or symbol segment: its outline items (one level). */ diff --git a/src/features/project-tree/model.ts b/src/features/project-tree/model.ts index fe4481e2b..c3e6d86f0 100644 --- a/src/features/project-tree/model.ts +++ b/src/features/project-tree/model.ts @@ -4,10 +4,11 @@ * incremental refresh for `fsChanged` events. No React, no IPC. */ import type { FileEntry } from "@/bindings"; +import { isIgnoredName } from "@/platform/ignoredNames"; import { isSameOrInside, nameOf, parentOf, relativeTo } from "./paths"; export interface TreeOptions { - /** Show IDE-hidden names (`.git`, `.DS_Store`, …). Dotfiles are always shown. */ + /** Show ignored names (`files.ignoredNames`: `.git`, `.DS_Store`, …). Dotfiles are always shown. */ showHidden: boolean; /** Show entries matched by .gitignore / exclude settings (dimmed). */ showExcluded: boolean; @@ -53,16 +54,9 @@ export interface TreeModel { export type Listings = ReadonlyMap; -/** Names IDEA hides by default ("Ignored Files and Folders"). */ -const IDE_HIDDEN = new Set([".git", ".hg", ".svn", "CVS", "_svn", ".DS_Store", "__pycache__", "vssver.scc", "vssver2.scc"]); -const IDE_HIDDEN_EXT = /\.(pyc|pyo|rbc|yarb)$|~$/; - -export function isIdeHidden(name: string): boolean { - return IDE_HIDDEN.has(name) || IDE_HIDDEN_EXT.test(name); -} - export function isVisible(entry: FileEntry, opts: TreeOptions): boolean { - if (!opts.showHidden && isIdeHidden(entry.name)) return false; + // Ignored names are also excluded on the backend; only "Show Hidden" brings them back. + if (isIgnoredName(entry.name)) return opts.showHidden; if (!opts.showExcluded && entry.ignored) return false; return true; } diff --git a/src/features/settings/model/schema.ts b/src/features/settings/model/schema.ts index ec935bc1f..a538f8db2 100644 --- a/src/features/settings/model/schema.ts +++ b/src/features/settings/model/schema.ts @@ -4,6 +4,7 @@ * so `validateSettings` never throws — anything unknown or malformed falls * back to the default (mirrors `ide-core::settings` defaults). */ +import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames"; import type { Settings } from "@/platform/ipc"; import { DEFAULT_LANGUAGES, @@ -58,6 +59,7 @@ export interface ValidSettings extends WorkflowSettings { files: { autoSave: boolean; exclude: string[]; + ignoredNames: string[]; trimTrailingWhitespace: boolean; ensureFinalNewline: boolean; /** Local History keeps this many working days (days with changes). */ @@ -95,6 +97,7 @@ export const DEFAULT_SETTINGS: ValidSettings = { files: { autoSave: true, exclude: ["**/.git", "**/node_modules", "**/target"], + ignoredNames: [...DEFAULT_IGNORED_NAMES], trimTrailingWhitespace: true, ensureFinalNewline: true, localHistoryDays: 5, @@ -188,6 +191,9 @@ export function validateSettings(raw: Settings | unknown): ValidSettings { exclude: Array.isArray(f.exclude) ? f.exclude.filter((x): x is string => typeof x === "string" && !!x.trim()) : [...d.files.exclude], + ignoredNames: Array.isArray(f.ignoredNames) + ? f.ignoredNames.filter((x): x is string => typeof x === "string" && !!x.trim()) + : [...d.files.ignoredNames], trimTrailingWhitespace: bool(f.trimTrailingWhitespace, d.files.trimTrailingWhitespace), ensureFinalNewline: bool(f.ensureFinalNewline, d.files.ensureFinalNewline), localHistoryDays: num(f.localHistoryDays, d.files.localHistoryDays, LIMITS.localHistoryDays, true), diff --git a/src/features/settings/pages/builtins.tsx b/src/features/settings/pages/builtins.tsx index 8da6b9973..48db92996 100644 --- a/src/features/settings/pages/builtins.tsx +++ b/src/features/settings/pages/builtins.tsx @@ -20,6 +20,19 @@ function ExcludeRow() { ); } +function IgnoredNamesRow() { + const [names, setNames] = useSetting("files.ignoredNames"); + return ( + + + + ); +} + export const BUILTIN_PAGES: PageSpec[] = [ { id: "keymap", @@ -39,7 +52,10 @@ export const BUILTIN_PAGES: PageSpec[] = [ sections: [ { title: "Ignored Files and Folders", - options: [{ kind: "custom", label: "Exclude patterns", component: ExcludeRow }], + options: [ + { kind: "custom", label: "Ignore files and folders", component: IgnoredNamesRow }, + { kind: "custom", label: "Exclude patterns", component: ExcludeRow }, + ], }, ], }, diff --git a/src/platform/ignoredNames.ts b/src/platform/ignoredNames.ts new file mode 100644 index 000000000..838297672 --- /dev/null +++ b/src/platform/ignoredNames.ts @@ -0,0 +1,49 @@ +/** "Ignored Files and Folders" (`files.ignoredNames`): names hidden everywhere; mirrors `ide_core::ignored`. */ +import { useProject } from "./project"; + +/** Defaults of `ide_core::ignored::DEFAULT_IGNORED_NAMES`; used until settings load. */ +export const DEFAULT_IGNORED_NAMES: readonly string[] = [ + "*.pyc", + "*.pyo", + "*.rbc", + "*.yarb", + "*~", + ".DS_Store", + ".git", + ".hg", + ".jj", + ".svn", + "CVS", + "__pycache__", + "_svn", + "vssver.scc", + "vssver2.scc", + "Thumbs.db", + "desktop.ini", +]; + +let cached: { names: readonly string[]; exact: Set; wildcards: RegExp[] } | null = null; + +function compile(names: readonly string[]) { + const exact = new Set(); + const wildcards: RegExp[] = []; + for (const raw of names) { + const n = raw.trim(); + if (!n || n.includes("/")) continue; + if (!/[*?]/.test(n)) exact.add(n); + else { + const body = n + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*") + .replace(/\?/g, "."); + wildcards.push(new RegExp(`^${body}$`, "s")); + } + } + return { names, exact, wildcards }; +} + +export function isIgnoredName(name: string): boolean { + const names = useProject.getState().settings?.files?.ignoredNames ?? DEFAULT_IGNORED_NAMES; + const m = cached?.names === names ? cached : (cached = compile(names)); + return m.exact.has(name) || m.wildcards.some((re) => re.test(name)); +}