дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
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.
Цей коміт міститься в:
1 джерело
bf06ca7832
коміт
a66f01ec87
17 files changed
+262
-31
No files matched your search
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ pub mod editor_settings;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod ids;
|
pub mod ids;
|
||||||
|
pub mod ignored;
|
||||||
pub mod ipc_path;
|
pub mod ipc_path;
|
||||||
pub mod project_dir;
|
pub mod project_dir;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
|||||||
@@ -303,6 +303,8 @@ pub struct FileSettings {
|
|||||||
/// Save on focus loss / tab switch, like IDEA.
|
/// Save on focus loss / tab switch, like IDEA.
|
||||||
pub auto_save: bool,
|
pub auto_save: bool,
|
||||||
pub exclude: Vec<String>,
|
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 trim_trailing_whitespace: bool,
|
||||||
pub ensure_final_newline: bool,
|
pub ensure_final_newline: bool,
|
||||||
/// Local History keeps changes from this many working days (days with changes), like IDEA.
|
/// Local History keeps changes from this many working days (days with changes), like IDEA.
|
||||||
@@ -316,6 +318,7 @@ impl Default for FileSettings {
|
|||||||
Self {
|
Self {
|
||||||
auto_save: true,
|
auto_save: true,
|
||||||
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
|
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,
|
trim_trailing_whitespace: true,
|
||||||
ensure_final_newline: true,
|
ensure_final_newline: true,
|
||||||
local_history_days: 5,
|
local_history_days: 5,
|
||||||
@@ -423,9 +426,9 @@ pub struct ProjectStructureSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
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> {
|
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 {
|
for dir in &self.project_structure.excluded {
|
||||||
let dir = dir.trim().trim_matches('/');
|
let dir = dir.trim().trim_matches('/');
|
||||||
if !dir.is_empty() {
|
if !dir.is_empty() {
|
||||||
@@ -434,6 +437,17 @@ impl Settings {
|
|||||||
}
|
}
|
||||||
out
|
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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use std::sync::atomic::{AtomicBool, Ordering};
|
|||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
use std::time::UNIX_EPOCH;
|
use std::time::UNIX_EPOCH;
|
||||||
|
|
||||||
/// VCS metadata directories are never indexed.
|
/// VCS metadata directories are never indexed, whatever the ignored names say.
|
||||||
const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj", ".DS_Store"];
|
const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj"];
|
||||||
|
|
||||||
pub(crate) struct PathFilter {
|
pub(crate) struct PathFilter {
|
||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//! writes (commit, checkout, push/pull, stash, merge) shell out to `git` so
|
//! writes (commit, checkout, push/pull, stash, merge) shell out to `git` so
|
||||||
//! hooks, credentials and signing behave exactly as on the command line.
|
//! hooks, credentials and signing behave exactly as on the command line.
|
||||||
|
|
||||||
|
use ide_core::ignored::IgnoredNames;
|
||||||
use ide_core::{CancellationToken, IdeResult};
|
use ide_core::{CancellationToken, IdeResult};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use specta::Type;
|
use specta::Type;
|
||||||
@@ -283,6 +284,7 @@ pub struct Repo {
|
|||||||
root: PathBuf,
|
root: PathBuf,
|
||||||
git_dir: PathBuf,
|
git_dir: PathBuf,
|
||||||
inner: std::sync::Mutex<repo::Cache>,
|
inner: std::sync::Mutex<repo::Cache>,
|
||||||
|
ignored: std::sync::RwLock<IgnoredNames>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Repo {
|
impl Repo {
|
||||||
@@ -294,6 +296,10 @@ impl Repo {
|
|||||||
pub fn root(&self) -> &Path {
|
pub fn root(&self) -> &Path {
|
||||||
&self.root
|
&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,
|
/// Staged (HEAD↔index) and unstaged (index↔worktree, incl. untracked) changes,
|
||||||
/// sorted by path. Conflicted files report `unstaged: Conflicted` and no staged kind.
|
/// sorted by path. Conflicted files report `unstaged: Conflicted` and no staged kind.
|
||||||
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
|
pub fn status(&self) -> IdeResult<Vec<FileStatus>> {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
|||||||
let common_dir = absolute(repo.common_dir())?;
|
let common_dir = absolute(repo.common_dir())?;
|
||||||
let files = config_files(&git_dir, &common_dir);
|
let files = config_files(&git_dir, &common_dir);
|
||||||
let cache = Cache { config_stamp: stamp(&files), config_files: files, repo: ts };
|
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> {
|
fn absolute(p: &Path) -> IdeResult<PathBuf> {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
|
|||||||
let repo = r.gix()?;
|
let repo = r.gix()?;
|
||||||
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
|
let mut map: BTreeMap<String, Entry> = BTreeMap::new();
|
||||||
let mut conflicts: HashSet<String> = HashSet::new();
|
let mut conflicts: HashSet<String> = HashSet::new();
|
||||||
|
let ignored = r.ignored.read().unwrap_or_else(|p| p.into_inner()).clone();
|
||||||
|
|
||||||
let platform = repo
|
let platform = repo
|
||||||
.status(gix::progress::Discard)
|
.status(gix::progress::Discard)
|
||||||
@@ -50,6 +51,9 @@ pub(crate) fn status(r: &Repo) -> IdeResult<Vec<FileStatus>> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut path = to_string(entry.rela_path.as_ref());
|
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('/') {
|
if entry.disk_kind.is_some_and(|k| k.is_dir()) && !path.ends_with('/') {
|
||||||
path.push('/');
|
path.push('/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,8 +192,12 @@ fn status_matrix() {
|
|||||||
fx.write("dir/nested/untracked2.txt", "u\n");
|
fx.write("dir/nested/untracked2.txt", "u\n");
|
||||||
fx.write("debug.log", "ignored\n");
|
fx.write("debug.log", "ignored\n");
|
||||||
fx.write("build/out.bin", "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();
|
let repo = fx.repo();
|
||||||
|
repo.set_ignored_names(ide_core::settings::Settings::default().ignored_names());
|
||||||
let st = repo.status().unwrap();
|
let st = repo.status().unwrap();
|
||||||
let check = |rel: &str, staged: Option<ChangeKind>, unstaged: Option<ChangeKind>| {
|
let check = |rel: &str, staged: Option<ChangeKind>, unstaged: Option<ChangeKind>| {
|
||||||
let s = status_of(&st, rel);
|
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_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 == "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("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.
|
// Sorted by path.
|
||||||
let paths: Vec<_> = st.iter().map(|s| s.rel_path.clone()).collect();
|
let paths: Vec<_> = st.iter().map(|s| s.rel_path.clone()).collect();
|
||||||
let mut sorted = paths.clone();
|
let mut sorted = paths.clone();
|
||||||
|
|||||||
@@ -23,11 +23,9 @@ use std::time::Instant;
|
|||||||
use tauri::ipc::Channel;
|
use tauri::ipc::Channel;
|
||||||
use tauri::{State, Window};
|
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>> {
|
fn files_in_scope(project: &Project, scope: &SearchScope, cancel: &CancellationToken) -> IdeResult<Vec<SourceFile>> {
|
||||||
let excluded: Vec<String> =
|
let mask = FileMask::new(&[], &project.search_excludes())?;
|
||||||
project.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect();
|
|
||||||
let mask = FileMask::new(&[], &excluded)?;
|
|
||||||
Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)?
|
Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| SourceFile { path: f.path, rel_path: f.rel_path })
|
.map(|f| SourceFile { path: f.path, rel_path: f.rel_path })
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ pub async fn search_start(
|
|||||||
on_event: Channel<SearchEvent>,
|
on_event: Channel<SearchEvent>,
|
||||||
) -> IdeResult<()> {
|
) -> IdeResult<()> {
|
||||||
let project = state.project(window.label())?;
|
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();
|
let roots = project.roots();
|
||||||
query.exclude.extend(roots.excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))));
|
|
||||||
let set = match query.scope {
|
let set = match query.scope {
|
||||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||||
_ => None,
|
_ => None,
|
||||||
|
|||||||
@@ -376,9 +376,13 @@ impl AppState {
|
|||||||
impl Project {
|
impl Project {
|
||||||
/// Runs `f` with the git repository, or fails when the project is not under git.
|
/// 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> {
|
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() {
|
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")),
|
None => Err(IdeError::unavailable("project is not a git repository")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -436,7 +440,22 @@ impl Project {
|
|||||||
self.cache_dir.join("build")
|
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> {
|
pub fn exclude_globs(&self) -> Vec<String> {
|
||||||
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
|
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> {
|
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.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs());
|
||||||
globs
|
globs
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1854,6 +1854,8 @@ export type FileSettings = {
|
|||||||
/** Save on focus loss / tab switch, like IDEA. */
|
/** Save on focus loss / tab switch, like IDEA. */
|
||||||
autoSave?: boolean,
|
autoSave?: boolean,
|
||||||
exclude?: string[],
|
exclude?: string[],
|
||||||
|
/** Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`]. */
|
||||||
|
ignoredNames?: string[],
|
||||||
trimTrailingWhitespace?: boolean,
|
trimTrailingWhitespace?: boolean,
|
||||||
ensureFinalNewline?: boolean,
|
ensureFinalNewline?: boolean,
|
||||||
/** Local History keeps changes from this many working days (days with changes), like IDEA. */
|
/** Local History keeps changes from this many working days (days with changes), like IDEA. */
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Navigation bar popup (⌘↑) model: the path of the active file as segments
|
* 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.
|
* (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 type { FileEntry, OutlineItem } from "@/platform/ipc";
|
||||||
import { badgeKind, fileIconName } from "@/ui";
|
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);
|
const nameOf = (p: string) => p.slice(p.lastIndexOf("/") + 1);
|
||||||
|
|
||||||
/** Segments for `path`; directories from the project root, or just the parent folder outside it. */
|
/** 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 out: NavSegment[] = [];
|
||||||
const parent = parentOf(path);
|
const parent = parentOf(path);
|
||||||
if (project && (parent === project.root || parent.startsWith(project.root + "/"))) {
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HIDDEN = new Set([".git", ".hg", ".svn", ".DS_Store", "__pycache__"]);
|
|
||||||
|
|
||||||
export function dirChildren(entries: readonly FileEntry[]): NavChild[] {
|
export function dirChildren(entries: readonly FileEntry[]): NavChild[] {
|
||||||
return entries
|
return entries
|
||||||
.filter((e) => !HIDDEN.has(e.name))
|
.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);
|
.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). */
|
/** Children of a file or symbol segment: its outline items (one level). */
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
* incremental refresh for `fsChanged` events. No React, no IPC.
|
* incremental refresh for `fsChanged` events. No React, no IPC.
|
||||||
*/
|
*/
|
||||||
import type { FileEntry } from "@/bindings";
|
import type { FileEntry } from "@/bindings";
|
||||||
|
import { isIgnoredName } from "@/platform/ignoredNames";
|
||||||
import { isSameOrInside, nameOf, parentOf, relativeTo } from "./paths";
|
import { isSameOrInside, nameOf, parentOf, relativeTo } from "./paths";
|
||||||
|
|
||||||
export interface TreeOptions {
|
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;
|
showHidden: boolean;
|
||||||
/** Show entries matched by .gitignore / exclude settings (dimmed). */
|
/** Show entries matched by .gitignore / exclude settings (dimmed). */
|
||||||
showExcluded: boolean;
|
showExcluded: boolean;
|
||||||
@@ -53,16 +54,9 @@ export interface TreeModel {
|
|||||||
|
|
||||||
export type Listings = ReadonlyMap<string, readonly FileEntry[]>;
|
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 {
|
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;
|
if (!opts.showExcluded && entry.ignored) return false;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
* so `validateSettings` never throws — anything unknown or malformed falls
|
* so `validateSettings` never throws — anything unknown or malformed falls
|
||||||
* back to the default (mirrors `ide-core::settings` defaults).
|
* back to the default (mirrors `ide-core::settings` defaults).
|
||||||
*/
|
*/
|
||||||
|
import { DEFAULT_IGNORED_NAMES } from "@/platform/ignoredNames";
|
||||||
import type { Settings } from "@/platform/ipc";
|
import type { Settings } from "@/platform/ipc";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LANGUAGES,
|
DEFAULT_LANGUAGES,
|
||||||
@@ -58,6 +59,7 @@ export interface ValidSettings extends WorkflowSettings {
|
|||||||
files: {
|
files: {
|
||||||
autoSave: boolean;
|
autoSave: boolean;
|
||||||
exclude: string[];
|
exclude: string[];
|
||||||
|
ignoredNames: string[];
|
||||||
trimTrailingWhitespace: boolean;
|
trimTrailingWhitespace: boolean;
|
||||||
ensureFinalNewline: boolean;
|
ensureFinalNewline: boolean;
|
||||||
/** Local History keeps this many working days (days with changes). */
|
/** Local History keeps this many working days (days with changes). */
|
||||||
@@ -95,6 +97,7 @@ export const DEFAULT_SETTINGS: ValidSettings = {
|
|||||||
files: {
|
files: {
|
||||||
autoSave: true,
|
autoSave: true,
|
||||||
exclude: ["**/.git", "**/node_modules", "**/target"],
|
exclude: ["**/.git", "**/node_modules", "**/target"],
|
||||||
|
ignoredNames: [...DEFAULT_IGNORED_NAMES],
|
||||||
trimTrailingWhitespace: true,
|
trimTrailingWhitespace: true,
|
||||||
ensureFinalNewline: true,
|
ensureFinalNewline: true,
|
||||||
localHistoryDays: 5,
|
localHistoryDays: 5,
|
||||||
@@ -188,6 +191,9 @@ export function validateSettings(raw: Settings | unknown): ValidSettings {
|
|||||||
exclude: Array.isArray(f.exclude)
|
exclude: Array.isArray(f.exclude)
|
||||||
? f.exclude.filter((x): x is string => typeof x === "string" && !!x.trim())
|
? f.exclude.filter((x): x is string => typeof x === "string" && !!x.trim())
|
||||||
: [...d.files.exclude],
|
: [...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),
|
trimTrailingWhitespace: bool(f.trimTrailingWhitespace, d.files.trimTrailingWhitespace),
|
||||||
ensureFinalNewline: bool(f.ensureFinalNewline, d.files.ensureFinalNewline),
|
ensureFinalNewline: bool(f.ensureFinalNewline, d.files.ensureFinalNewline),
|
||||||
localHistoryDays: num(f.localHistoryDays, d.files.localHistoryDays, LIMITS.localHistoryDays, true),
|
localHistoryDays: num(f.localHistoryDays, d.files.localHistoryDays, LIMITS.localHistoryDays, true),
|
||||||
|
|||||||
@@ -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[] = [
|
export const BUILTIN_PAGES: PageSpec[] = [
|
||||||
{
|
{
|
||||||
id: "keymap",
|
id: "keymap",
|
||||||
@@ -39,7 +52,10 @@ export const BUILTIN_PAGES: PageSpec[] = [
|
|||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
title: "Ignored Files and Folders",
|
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 },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
Reference in new issue
Заблокувати користувача