feat(files): Add Ignored Files and Folders setting, honoured everywhere

Name patterns the IDE treats as nonexistent (.DS_Store, __pycache__, *.pyc, VCS dirs, ...) were hard-coded in five places, and Git changes ignored none of them, so .DS_Store showed up as unversioned. files.ignoredNames (IDEA's default list) now drives the project view, navigation bar, index, watcher, Find in Files, inspections and the Git status.
Цей коміт міститься в:
l0ng-ai committed 2026-10-23 15:40:41 +08:00
1 джерело bf06ca7832
коміт a66f01ec87
17 files changed
+262 -31

No files matched your search

+106
Переглянути файл
@@ -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<String>,
wildcards: Vec<String>,
}
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::<Vec<_>>())
}
#[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"));
}
}
+1
Переглянути файл
@@ -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;
+16 -2
Переглянути файл
@@ -303,6 +303,8 @@ pub struct FileSettings {
/// Save on focus loss / tab switch, like IDEA.
pub auto_save: bool,
pub exclude: Vec<String>,
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
pub ignored_names: Vec<String>,
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<String> {
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<String> {
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)]
+2 -2
Переглянути файл
@@ -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,
+6
Переглянути файл
@@ -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<repo::Cache>,
ignored: std::sync::RwLock<IgnoredNames>,
}
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<Vec<FileStatus>> {
+1 -1
Переглянути файл
@@ -47,7 +47,7 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
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<PathBuf> {
+4
Переглянути файл
@@ -18,6 +18,7 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
let repo = r.gix()?;
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
let mut conflicts: HashSet<String> = 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<Vec<FileStatus>> {
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('/');
}
+5
Переглянути файл
@@ -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<ChangeKind>, unstaged: Option<ChangeKind>| {
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();
+2 -4
Переглянути файл
@@ -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<Vec<SourceFile>> {
let excluded: Vec<String> =
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 })
+3 -2
Переглянути файл
@@ -29,9 +29,10 @@ pub async fn search_start(
on_event: Channel<SearchEvent>,
) -> 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,
+23 -4
Переглянути файл
@@ -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<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
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<String> {
let mut out: Vec<String> =
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<String> {
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<String> {
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
}
+2
Переглянути файл
@@ -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. */
+15 -5
Переглянути файл
@@ -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). */
+4 -10
Переглянути файл
@@ -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<string, readonly FileEntry[]>;
/** 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;
}
+6
Переглянути файл
@@ -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),
+17 -1
Переглянути файл
@@ -20,6 +20,19 @@ function ExcludeRow() {
);
}
function IgnoredNamesRow() {
const [names, setNames] = useSetting<string[]>("files.ignoredNames");
return (
<Row
label="Ignore files and folders"
stacked
hint="Names (e.g. .DS_Store, *.pyc) hidden from the project tree, navigation bar, search, indexing and Git changes."
>
<GlobListEditor value={names} onChange={setNames} placeholder="*.ext" aria-label="Ignored names" />
</Row>
);
}
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 },
],
},
],
},
+49
Переглянути файл
@@ -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<string>; wildcards: RegExp[] } | null = null;
function compile(names: readonly string[]) {
const exact = new Set<string>();
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));
}