diff --git a/README.md b/README.md index 2656c56a..2a21c682 100644 --- a/README.md +++ b/README.md @@ -294,13 +294,17 @@ the UI writes the same config file you can edit by hand. Press `:` to open Settings, move to **Collections**, then: -- **Add a collection**: choose **Add collection** and fill the wizard: - **Name**, a **Source path** (a folder; `~/…` is fine), the file types to - **Include** and patterns to **Exclude**, an optional markdown - **Frontmatter filter**, and a **Follow symlinks** toggle. Press **Ctrl+S** to - save; you land on the new collection's page, where **Update index** builds it. +- **Add a collection**: choose **Add collection** and fill in: + - **Collection**: its **Name**. + - **Source**, its first folder: a **Path** (`~/…` is fine), **Follow + symlinks**, then what gets indexed from it: **Index filters** (file + types, excluded paths, tags, size, dates), the **Frontmatter rule**, and + **Restrict to these paths**. + + Press **Ctrl+S** to save; you land on the new collection's page, where + **Update index** builds it. - **Add a source to an existing collection**: open the collection, then - **Sources → Add source**, and set the path, includes/excludes, an optional + **Sources → Add source**, and set the path, its Index filters, an optional per-source app, and (for Obsidian) the vault name. **Ctrl+S** saves and returns; **Ctrl+A** saves and adds another. - **How editing works**: @@ -359,9 +363,10 @@ Index filters** edits the defaults; a source's **Index filters** row edits it. | Filter | What it does | | --- | --- | -| *(always on)* | Hidden files and folders (`.foo`) are skipped, whatever the filters say. Only an `includes` glob naming a dot-prefixed component (`.obsidian/**`) admits one, and then only the paths that glob itself matches. | +| *(always on)* | Hidden files and folders (`.foo`) are skipped, whatever the filters say. Only an `includes` glob naming a dot-prefixed component (`.obsidian/**`) admits one, and then only the paths that glob itself matches. A file whose own name starts with `.` still meets the default `**/.*` exclude: to index one, give the source its own `excludes` without that glob. | | `respect_gitignore` | Honours every `.gitignore` down the tree, with git's rules: negation, directory patterns, nearest file wins. Not `.git/info/exclude` and not `core.excludesFile`: neither is in the tree, so honouring them would make one corpus index differently on two machines. Use `.fndignore` for a rule that is yours alone. **On by default.** | | `respect_fndignore` | The same syntax in a `.fndignore`, read only by fnd: how to hide something from search without hiding it from git. **On by default.** | +| `excludes` | Path globs never indexed; a folder a `name/**` glob covers is pruned unopened. Settings shows them as **Excluded paths** (presets such as Node modules) plus **Excluded globs** you type. Set once in the defaults for every source; a source's own list replaces it. **System files by default**: `**/.*`, `.DS_Store`, `Thumbs.db`, `desktop.ini` and `.git`. | | `include_tags` | Index only files carrying one of these tags; the tag rows' `●`. Empty means the tag is not consulted. | | `exclude_tags` | Tags that keep a file out; the tag rows' `⊘`. From any source fnd reads: macOS Finder tags and a note's YAML `tags:`. Defaults to `["no_index"]`, so tagging a file `no_index` either way keeps it out. | | `kinds` | Restrict to given file types. Empty means every supported type. A source's `includes` folds into this when it names every suffix of a type (`["**/*.md", "**/*.markdown"]`, not `["**/*.md"]` alone). Anything else stays a glob. | @@ -420,9 +425,11 @@ fuzzy_enabled = true # auto-fuzzy in the cascade fallback (toggle with Ctr [[collections.papers.sources]] path = "~/Documents/Research" includes = ["**/*.pdf", "**/*.md"] # ORed globs; see filters.kinds below -excludes = ["**/.git/**", "archive/**"] follow_symlinks = false +[collections.papers.sources.filters] +excludes = ["**/.git/**", "archive/**"] # replaces [defaults.filters] excludes + [[collections.papers.sources]] path = "~/Notes" @@ -435,6 +442,7 @@ frontmatter = "Status == 'published' AND NOT ('private' in tags)" # notes only [defaults.filters] respect_gitignore = true # honour .gitignore, with git's own rules respect_fndignore = true # same syntax, read only by fnd +excludes = ["**/node_modules/**"] # replaces the System files default exclude_tags = ["no_index"] # Finder tags and YAML tags:, see below # include_tags = ["reference"] # index only files carrying one of these # kinds = ["md", "pdf"] # omit to index every supported type diff --git a/fnd/cli.py b/fnd/cli.py index 3f44f09a..98457e59 100644 --- a/fnd/cli.py +++ b/fnd/cli.py @@ -669,7 +669,7 @@ def collection_add( exclude: list[str] = typer.Option( [], "--exclude", - help="Glob to exclude. Repeatable.", + help="Glob to exclude. Repeatable. Replaces the app-wide excludes (System files by default).", ), filter: str | None = typer.Option( None, @@ -712,14 +712,14 @@ def collection_add( cfg_path.parent.mkdir(parents=True, exist_ok=True) from fnd.config import SourceFilters + # The current shape, not a source-level `excludes` or `frontmatter_filter`: + # a new write should not create something the next migration has to move. + own = SourceFilters(frontmatter=filter or None, excludes=list(exclude) or None) new_source = SourceConfig( path=source[0], includes=list(include), - excludes=list(exclude), follow_symlinks=follow_symlinks, - # The current shape, not the deprecated `frontmatter_filter`: a new - # write should not create something the next migration has to move. - filters=SourceFilters(frontmatter=filter) if filter else None, + filters=own if filter or exclude else None, ) # Read before the write: the sibling set is what the new source is being # compared against, and after the write it contains the new source itself. diff --git a/fnd/config.py b/fnd/config.py index bf5f61dc..d50ab727 100644 --- a/fnd/config.py +++ b/fnd/config.py @@ -55,7 +55,7 @@ def default_config_path() -> Path: # any directory anywhere in the tree with one of these names is skipped # entirely (no scandir into it). Comprehensive because the typical user # is a developer with many code trees on disk and indexing build/cache -# output is never useful. Per-source ``excludes`` still apply on top; +# output is never useful. The ``excludes`` filter still applies on top; # users can disable this list with ``defaults.skip_junk_dirs = false`` # or extend it via ``defaults.extra_junk_dirs``. DEFAULT_JUNK_DIRS: frozenset[str] = frozenset( @@ -162,9 +162,8 @@ def default_config_path() -> Path: spec.id: f"{spec.label} ({'/'.join(spec.suffixes)})" for spec in KIND_SPECS } -# Exclude presets for the Add Collection wizard's Excludes multi-select. Each -# preset defines a set of globs and a default toggle state. Presets marked -# default=True are pre-ticked in the UI. +# The presets Index filters › Excluded paths offers to tick; each is a set of +# globs a preset counts as on for when all are in a source's excludes. EXCLUDES_PRESETS: dict[str, dict[str, Any]] = { # Hidden names are pruned by the walk whatever this says, so the label # promised something unticking it cannot give back. What it uniquely adds @@ -172,27 +171,22 @@ def default_config_path() -> Path: "hidden": { "label": "System files (hidden are always skipped)", "globs": ["**/.*", "**/.DS_Store", "**/Thumbs.db", "**/desktop.ini", "**/.git/**"], - "default": True, }, "node_modules": { "label": "Node modules", "globs": ["**/node_modules/**"], - "default": False, }, "python_caches": { "label": "Python caches", "globs": ["**/__pycache__/**", "**/*.pyc"], - "default": False, }, "build_artefacts": { "label": "Build artefacts", "globs": ["**/dist/**", "**/build/**"], - "default": False, }, "obsidian_meta": { "label": "Obsidian metadata", "globs": ["**/.obsidian/**"], - "default": False, }, } @@ -275,6 +269,10 @@ class DefaultFilters(_ConfigModel): """Honour .fndignore files. Same syntax as .gitignore, but they apply to fnd alone, so they exclude something from search without excluding it from git.""" + excludes: list[str] = Field(default_factory=lambda: list(EXCLUDES_PRESETS["hidden"]["globs"])) + """Glob patterns whose files are never indexed; a folder a `name/**` glob + covers is pruned unopened. Every source inherits these unless it sets its own.""" + include_tags: list[str] | dict[str, list[str]] = Field(default_factory=list) """Index only files carrying one of these tags. Empty means no tag is needed.""" @@ -342,6 +340,7 @@ class SourceFilters(_ConfigModel): respect_gitignore: bool | None = None respect_fndignore: bool | None = None + excludes: list[str] | None = None include_tags: list[str] | dict[str, list[str]] | None = None exclude_tags: list[str] | dict[str, list[str]] | None = None kinds: list[str] | None = None @@ -430,9 +429,6 @@ class SourceConfig(_ConfigModel): """Glob patterns a file must match to be indexed. Empty means every supported type. Matched against the path relative to this source root.""" - excludes: list[str] = Field(default_factory=list) - """Glob patterns that keep a file out, even when it matched an include.""" - follow_symlinks: bool = False """Follow symlinks in this source, and allow the root to be one. Off by default.""" @@ -461,6 +457,32 @@ class SourceConfig(_ConfigModel): """Populated by :class:`Config` once the defaults are known. Private so it never reaches the TOML writer, which must emit only what the user set.""" + @model_validator(mode="before") + @classmethod + def _fold_legacy_excludes(cls, data: Any) -> Any: + """A source-level `excludes` list is the filters' `excludes`, said in the old place.""" + from fnd.config_migrations import legacy_excludes_override + + if not isinstance(data, dict) or "excludes" not in data: + return data + data = dict(data) + legacy = legacy_excludes_override(data.pop("excludes")) + if legacy is not None: + filters = data.get("filters") + filters = ( + dict(filters) + if isinstance(filters, dict) + else (filters.model_dump(exclude_none=True) if filters is not None else {}) + ) + filters.setdefault("excludes", list(legacy)) + data["filters"] = filters + return data + + @property + def excludes(self) -> list[str]: + """The excludes this source walks with: its own, or the inherited ones.""" + return list(self.effective_filters.excludes) + @model_validator(mode="after") def _absorb_type_globs(self) -> SourceConfig: """``includes = ["**/*.md"]`` is ``filters.kinds = ["md"]`` said twice. @@ -569,7 +591,7 @@ class CollectionConfig(_ConfigModel): A collection can be configured in two equivalent shapes: * **New (recommended):** ``[[collections.X.sources]]``. One TOML table - per source, each with its own includes/excludes/frontmatter_filter. + per source, each with its own includes and filters. * **Legacy:** flat ``roots = [...]``, ``includes = [...]``, ``excludes = [...]`` on the collection. Loader normalises this into a single implicit source so downstream code only sees the new shape. @@ -627,7 +649,7 @@ def _normalise_sources(self) -> CollectionConfig: SourceConfig( path=root, includes=list(self.includes), - excludes=list(self.excludes), + filters=_legacy_filters(self.excludes), follow_symlinks=self.follow_symlinks, ) for root in self.roots @@ -638,6 +660,13 @@ def _normalise_sources(self) -> CollectionConfig: return self +def _legacy_filters(excludes: list[str]) -> SourceFilters | None: + from fnd.config_migrations import legacy_excludes_override + + override = legacy_excludes_override(excludes) + return SourceFilters(excludes=override) if override is not None else None + + class RankingProfileConfig(_ConfigModel): """User-facing knobs that map onto :class:`fnd.rerank.RankingProfile`. diff --git a/fnd/config_migrations.py b/fnd/config_migrations.py index 2e502435..43dfc94b 100644 --- a/fnd/config_migrations.py +++ b/fnd/config_migrations.py @@ -25,7 +25,7 @@ from fnd.globs import names_hidden #: What this build writes. Bump when adding a migration. -CONFIG_VERSION: Final = 2 +CONFIG_VERSION: Final = 3 VERSION_KEY: Final = "config_version" @@ -84,6 +84,42 @@ def _to_v2(raw: MutableMapping[str, Any]) -> list[str]: ] +#: What the pre-v3 Add collection pre-ticked, frozen: the master list's default now. +V2_WIZARD_EXCLUDES: Final = frozenset( + {"**/.*", "**/.DS_Store", "**/Thumbs.db", "**/desktop.ini", "**/.git/**"} +) + + +def legacy_excludes_override(globs: Any) -> list[str] | None: + """A pre-v3 excludes list as a source override; ``None`` inherits the master. + + Empty was never a choice to exclude nothing, and the old wizard's default is + the master's now, in either spelling (a released config is respelled by v2).""" + listed = [globs] if isinstance(globs, str) else [str(g) for g in globs or ()] + wizard = list(V2_WIZARD_EXCLUDES) + spellings = {V2_WIZARD_EXCLUDES, frozenset(_respelled(wizard, include=False, narrowed=[]))} + if not listed or frozenset(listed) in spellings: + return None + return listed + + +def _to_v3(raw: MutableMapping[str, Any]) -> None: + """Move each source's ``excludes`` into its ``filters``, where a master list + in ``[defaults.filters]`` can be inherited and overridden like every filter.""" + for collection in (raw.get("collections") or {}).values(): + if not isinstance(collection, dict): + continue + for source in collection.get("sources") or (): + if not isinstance(source, dict) or "excludes" not in source: + continue + legacy = legacy_excludes_override(source.pop("excludes")) + if legacy is None: + continue + filters = source.setdefault("filters", {}) + if isinstance(filters, dict): + filters.setdefault("excludes", legacy) + + _MAX_SPELLINGS: Final = 16 """Path globs one legacy glob may become. Each ``?`` roughly doubles the count.""" @@ -322,6 +358,7 @@ def _collapse_globstars(glob: str) -> str: MIGRATIONS: Final[tuple[tuple[int, str, Transform], ...]] = ( (1, "Adopt the canonical layout and record a config version", _to_v1), (2, "Respell globs so they still select the files they did", _to_v2), + (3, "Move each source's excludes into its index filters", _to_v3), ) diff --git a/fnd/config_render.py b/fnd/config_render.py index 55fb1303..18ea02b1 100644 --- a/fnd/config_render.py +++ b/fnd/config_render.py @@ -73,7 +73,6 @@ _SOURCE_ORDER = ( "path", "includes", - "excludes", "follow_symlinks", "frontmatter_filter", "app", diff --git a/fnd/filters/excludes.py b/fnd/filters/excludes.py new file mode 100644 index 00000000..9fbf3b84 --- /dev/null +++ b/fnd/filters/excludes.py @@ -0,0 +1,56 @@ +"""Exclude globs as the Index filters screen shows them: presets to tick, globs typed by hand. + +A preset counts as on when every glob it ships is present; whatever is left over +was typed. Edits keep the list in the order written: an inherited list rewritten +in any other order would save as the source's own. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence + + +def split_presets(globs: Iterable[str]) -> tuple[list[str], list[str]]: + """``(preset keys on, globs typed by hand)`` for an excludes list.""" + from fnd.config import EXCLUDES_PRESETS + + remaining = list(dict.fromkeys(globs)) + on: list[str] = [] + for key, preset in EXCLUDES_PRESETS.items(): + shipped = preset["globs"] + if all(g in remaining for g in shipped): + on.append(key) + remaining = [g for g in remaining if g not in shipped] + return on, remaining + + +def _globs_of(keys: Iterable[str]) -> list[str]: + from fnd.config import EXCLUDES_PRESETS + + picked = set(keys) + return [g for k, p in EXCLUDES_PRESETS.items() if k in picked for g in p["globs"]] + + +def retick(excludes: Sequence[str], keys: Iterable[str]) -> tuple[str, ...]: + """``excludes`` with exactly the presets ``keys`` on: unticked globs leave, new ones follow.""" + on, _typed = split_presets(excludes) + wanted = set(keys) + if set(on) == wanted: + return tuple(excludes) + dropped = set(_globs_of(set(on) - wanted)) - set(_globs_of(wanted)) + kept = [g for g in excludes if g not in dropped] + return tuple(dict.fromkeys([*kept, *_globs_of(wanted - set(on))])) + + +def retype(excludes: Sequence[str], typed: Sequence[str]) -> tuple[str, ...]: + """``excludes`` with its typed globs replaced by ``typed``; the presets stay put.""" + on, _old = split_presets(excludes) + shipped = set(_globs_of(on)) + return tuple(dict.fromkeys([*(g for g in excludes if g in shipped), *typed])) + + +def invalid_glob(glob: str) -> bool: + """Whether ``glob`` cannot compile, so it would exclude nothing.""" + from fnd.globs import _config_regex # pyright: ignore[reportPrivateUsage] + + return _config_regex(glob) is None diff --git a/fnd/filters/model.py b/fnd/filters/model.py index c91a6ce4..abe150e4 100644 --- a/fnd/filters/model.py +++ b/fnd/filters/model.py @@ -119,6 +119,7 @@ def spec_from_resolved(resolved: object) -> FilterSpec: modified_before=resolved.modified_before, # type: ignore[attr-defined] expression=resolved.expression or "", # type: ignore[attr-defined] frontmatter=resolved.frontmatter or "", # type: ignore[attr-defined] + excludes=tuple(getattr(resolved, "excludes", None) or ()), ) @@ -146,6 +147,8 @@ class FilterSpec: modified_before: dt.date | None = None frontmatter: str = "" expression: str = "" + # Pruned by the walk before any file is read, so the text form never shows them. + excludes: tuple[str, ...] = () raw: tuple[str, ...] = () @property diff --git a/fnd/filters/tree_model.py b/fnd/filters/tree_model.py index 7e28f628..4ab87903 100644 --- a/fnd/filters/tree_model.py +++ b/fnd/filters/tree_model.py @@ -15,6 +15,7 @@ from collections.abc import Mapping from dataclasses import dataclass, replace +from fnd.filters.excludes import retick, split_presets from fnd.filters.model import FilterSpec from fnd.filters.scan import SourceSample from fnd.kinds import ALL_KIND_IDS, CATEGORIES, KIND_BY_ID, KINDS_IN_CATEGORY @@ -66,6 +67,10 @@ #: what the shared line claims. A branch names its own meaning or inherits. IGNORE_LEGEND = "● obey this file ○ ignore it (obeying one indexes fewer files)" RULES_LEGEND = "⏎ opens a branch, then the editor for a rule in it" +#: On the excludes branch ● means "skip", the reverse of "index ONLY these". +EXCLUDES_LEGEND = ( + "● never read ◐ some of these ○ read as usual (typed globs: Rules you type)" +) #: `kinds` is an include-only list in the model, so `⊘` is unreachable here. The #: shared line would send a user looking for an exclude state that does not exist, #: and allow-listing everything else instead drops every file type added later. @@ -317,6 +322,7 @@ def spec_branches( legend=IGNORE_LEGEND, ) ) + branches.append(_excludes_branch()) # Rows stay ordered by the bound they set, so a custom one lands among the # presets rather than ahead of them. sized = [(-1 if v is None else v, f"size:{i}", lbl) for i, lbl, v in _SIZES] @@ -388,8 +394,9 @@ def spec_branches( for i, text in enumerate(spec.raw) if (text or "").strip() ) + _on, typed = split_presets(spec.excludes) set_rules = sum(1 for v in (spec.frontmatter, spec.expression) if (v or "").strip()) - set_rules += len(raw_rows) + set_rules += len(raw_rows) + (1 if typed else 0) branches.append( Branch( "rules", @@ -398,6 +405,7 @@ def spec_branches( ( ("rule:frontmatter", _rule_label("Frontmatter rule", spec.frontmatter)), ("rule:expression", _rule_label("Custom rule", spec.expression)), + ("rule:excludes", _rule_label("Excluded globs", ", ".join(typed))), *raw_rows, ), legend=RULES_LEGEND, @@ -406,6 +414,24 @@ def spec_branches( return branches +def _excludes_branch() -> Branch: + """The exclude presets, ticked when every glob they ship is excluded.""" + from fnd.config import EXCLUDES_PRESETS + + return Branch( + "excludes", + "Excluded paths", + "multi", + tuple( + (f"exclude:{key}", f"{p['label']} {', '.join(p['globs'])}", p["label"].split(" (")[0]) + for key, p in EXCLUDES_PRESETS.items() + ), + empty_label="none", + noun="presets", + legend=EXCLUDES_LEGEND, + ) + + def selection_for( spec: FilterSpec, *, gitignore: bool = True, fndignore: bool = True ) -> tuple[set[str], set[str]]: @@ -427,6 +453,7 @@ def selection_for( selected.add("ignore:git") if fndignore: selected.add("ignore:fnd") + selected |= {f"exclude:{key}" for key in split_presets(spec.excludes)[0]} selected.add(f"size:{_size_id(spec.max_size)}") selected.add(f"modified:{_window_id(spec.modified_after)}") selected.add(f"created:{_window_id(spec.created_after)}") @@ -529,7 +556,7 @@ def apply_selection( picked = {i.removeprefix("kind:") for i in selected if i.startswith("kind:")} # Every box ticked means "every type", not today's list, which would never # index a PDF added tomorrow while an untouched branch does, both reading - # "all types". `AddCollectionWizard._set_includes` collapses the same way. + # "all types". shown = {i.removeprefix("kind:") for i in offered if i.startswith("kind:")} if offered else None # Only when the tree offered EVERY type does ticking them all mean "no # restriction": on a homogeneous folder a genuine `kinds = ["md"]` is already @@ -561,8 +588,10 @@ def apply_selection( ) bounds[f"{field_name}_after"] = today - dt.timedelta(days=days) if days else None + presets = [i.removeprefix("exclude:") for i in selected if i.startswith("exclude:")] updated = replace( spec, + excludes=retick(spec.excludes, presets), kinds=kinds, include_tags=keep, exclude_tags=tags, diff --git a/fnd/globs.py b/fnd/globs.py index d109beca..b277de6e 100644 --- a/fnd/globs.py +++ b/fnd/globs.py @@ -188,3 +188,12 @@ def __bool__(self) -> bool: def matches(self, rel: str) -> bool: return any(g.matches(rel) for g in self.globs) + + def covers_dir(self, rel: str) -> bool: + """Whether every path under folder ``rel`` matches: a ``prefix/**`` glob whose prefix does.""" + return any( + g.pattern.endswith("/**") + and len(g.pattern) > 3 + and PathGlob(g.pattern[:-3]).matches(rel) + for g in self.globs + ) diff --git a/fnd/index_freshness.py b/fnd/index_freshness.py index 00fdc408..dfa6ae3d 100644 --- a/fnd/index_freshness.py +++ b/fnd/index_freshness.py @@ -32,7 +32,6 @@ _SOURCE_FIELDS: dict[str, str] = { "path": "Path", "includes": "Restrict to these paths", - "excludes": "Excludes", "follow_symlinks": "Follow symlinks", "filters": "Index filters", } @@ -78,10 +77,11 @@ def indexed_with(collection: CollectionConfig, defaults: Defaults) -> dict[str, { "path": str(s.path), "includes": list(s.includes), - "excludes": list(s.excludes), "follow_symlinks": bool(s.follow_symlinks), "filters": { **s.effective_filters.model_dump(mode="json"), + # Order never changes what the walk prunes. + "excludes": sorted(set(s.effective_filters.excludes)), "legacy_frontmatter": s.legacy_frontmatter or "", }, } @@ -121,9 +121,18 @@ def _extraction_reasons(now: dict[str, Any], then: dict[str, Any]) -> tuple[str, return tuple(reasons) +def _as_today(source: dict[str, Any]) -> dict[str, Any]: + """A recorded source in today's shape: excludes were a source field before + they were a filter, so an upgrade alone must not read as a change.""" + filters = dict(source.get("filters") or {}) + filters.setdefault("excludes", source.get("excludes") or []) + filters["excludes"] = sorted(set(filters["excludes"])) + return {**{k: v for k, v in source.items() if k != "excludes"}, "filters": filters} + + def _selection_reasons(now: dict[str, Any], then: dict[str, Any]) -> tuple[str, ...]: reasons: dict[str, None] = {} - old = then.get("sources") or [] + old = [_as_today(s) for s in then.get("sources") or []] if len(now["sources"]) != len(old): reasons["Sources"] = None for new_source, old_source in zip(now["sources"], old, strict=False): diff --git a/fnd/tui/menu.py b/fnd/tui/menu.py index e67481a0..2b2e065d 100644 --- a/fnd/tui/menu.py +++ b/fnd/tui/menu.py @@ -165,8 +165,6 @@ class MenuItem: # PICKER multi: bool = False choices_provider: Callable[[FNDApp], list[ChoiceOption]] | None = None - # TREE_PICKER: category→item model for the nested ToggleTree picker. - groups_provider: Callable[[FNDApp], list[Any]] | None = None picker_getter: Callable[[FNDApp], Any] | None = None picker_setter: Callable[[FNDApp, Any], None] | None = None @@ -516,7 +514,8 @@ def _keys_filter_browser() -> tuple[tuple[str, str, str, str], ...]: "Edit as text", "", "Open the whole filter set as its expression, for anything the " - "pickers cannot say. Esc carries it back into this screen.", + "pickers cannot say. Esc carries it back into this screen. Excluded " + "paths are not in it: they skip folders before any file is read.", ), ( "y", @@ -1679,8 +1678,7 @@ def _provider_collection(app: FNDApp, name: str) -> tuple[MenuItem, ...]: id=f"col.{name}.sources", label="Sources", description=( - "The folders this collection indexes, and each one's filters, " - "excludes and opening app." + "The folders this collection indexes, and each one's index filters and opening app." ), kind=KIND_EXTERNAL, external=_make_open_sources_screen(name), @@ -1870,6 +1868,8 @@ def _folder_glob(root: Path, globs: list[str]) -> str | None: def _narrowing_dimensions(f: Any) -> list[str]: """Which dimensions of a filter set narrow anything.""" named = [] + if f.excludes: + named.append("excludes") if f.include_tags or f.exclude_tags: named.append("tags") if f.min_size is not None or f.max_size is not None: @@ -1891,10 +1891,6 @@ def _other_filters(src: Any) -> list[str]: """ own = src.filters named = [] - # Excludes drop files before any other rule runs, and are the source's own - # field, never inherited. - if getattr(src, "excludes", None): - named.append("excludes") if own is not None: named.extend(_narrowing_dimensions(own)) inherited = set(_narrowing_dimensions(src.effective_filters)) - set(named) @@ -2544,6 +2540,8 @@ def _summary_index_filters(app: FNDApp) -> str: bits.append("size") if any((f.created_after, f.created_before, f.modified_after, f.modified_before)): bits.append("dates") + if f.excludes: + bits.append("excluded paths") if f.frontmatter or f.expression: bits.append("custom") return " · ".join(bits) if bits else "off" @@ -2556,8 +2554,8 @@ def _provider_index_filters(_app: FNDApp) -> tuple[MenuItem, ...]: id="filters.browse", label="Index filters", description=( - "Which files enter the index: file types, tags, size, dates " - "and ignore files, as branches you tick, or as one expression " + "Which files enter the index: file types, tags, size, dates, " + "ignore files and excluded paths, as branches you tick, or as one expression " "if you prefer. Applies at the next Update index: files that " "now match are added, files that no longer match are dropped." ), @@ -3211,7 +3209,7 @@ def _provider_root(_app: FNDApp) -> tuple[MenuItem, ...]: label="Filters", description=( "What enters the index, and what a search returns from it: " - "file types, tags, size, dates and ignore files." + "file types, tags, size, dates, ignore files and excluded paths." ), kind=KIND_EXTERNAL, external=_open_section(SECTION_FILTERS), diff --git a/fnd/tui/settings_screen.py b/fnd/tui/settings_screen.py index e2cabf6c..9971dfde 100644 --- a/fnd/tui/settings_screen.py +++ b/fnd/tui/settings_screen.py @@ -28,6 +28,7 @@ import contextlib import copy +import dataclasses import textwrap from collections.abc import Callable, Iterable, Sequence from pathlib import Path @@ -325,31 +326,6 @@ def _display_path(raw: str) -> str: return under_home(Path(raw).expanduser()) -def _discard_custom_globs(screen: Any, field_key: str) -> None: - """Clear a custom-glob field, keeping the text on offer for the visit. - - The tick is derived from the text, so the value cannot simply stay; but - dropping it outright would lose typed globs to one keypress, with no undo. - """ - text = str(screen._fields.get(field_key) or "").strip() - if not text: - return - screen._discarded_globs[field_key] = text - screen._fields[field_key] = "" - screen.app.notify(f"Custom globs cleared. Tick again to restore: {text}") - - -def _custom_seed(screen: Any, field_key: str) -> str: - """What the glob prompt opens with: the current value, else the one the - last untick cleared.""" - current = str(screen._fields.get(field_key) or "") - return current or screen._discarded_globs.get(field_key, "") - - -# Textual selectors are type selectors and a widget's own CSS is scoped to it, -# so neither `CSS = OtherScreen.CSS` nor a shared class selector matches the -# borrowing screen, which then renders with no background, border or docked footer. -# One definition, stamped with each screen's own name. _PROMPT_CSS = """ {cls} {{ background: $surface; }} {cls} > #settings_box {{ @@ -1891,9 +1867,7 @@ def _activate_item(self, item: MenuItem) -> None: self._refresh_hint_bar() return if item.kind == KIND_PICKER: - self.app.push_screen( - TreePickerScreen(item) if item.groups_provider is not None else PickerScreen(item) - ) + self.app.push_screen(PickerScreen(item)) return if item.kind == KIND_EXTERNAL: if item.external is not None: @@ -2185,7 +2159,6 @@ def open_source_filter_browser( root: Any, on_change: Callable[[], None], globs: list[str] | None = None, - excludes: list[str] | None = None, ) -> None: """The source's *effective* filters, edited as branches. @@ -2225,8 +2198,8 @@ def _sample(spec: Any = None) -> Any: root, budget_s=0.8, ignore_names=names, - # The pane names these in its own footer as paths it is skipping. - excludes=list(excludes or ()), + # The excludes the screen is showing, so a tick changes the counts at once. + excludes=list(gating.excludes), gate=build_gate(dataclasses.replace(gating, kinds=())), ) @@ -2254,7 +2227,6 @@ def _save(spec: Any, gitignore: bool, fndignore: bool) -> None: sample_provider=_sample, no_tags_note="no tags found in this source", globs=list(globs or ()), - excludes=list(excludes or ()), inherited=( _spec_from_filters(defaults), defaults.respect_gitignore, @@ -2265,14 +2237,6 @@ def _save(spec: Any, gitignore: bool, fndignore: bool) -> None: ) -def _default_filters(app: Any) -> Any: - """The global default filters, or the shipped ones when no config.""" - from fnd.config import DefaultFilters - - cfg = getattr(app, "_config", None) - return getattr(getattr(cfg, "defaults", None), "filters", None) or DefaultFilters() - - def _default_frontmatter(app: Any) -> str: cfg = getattr(app, "_config", None) return (getattr(cfg.defaults.filters, "frontmatter", None) or "") if cfg else "" @@ -2344,32 +2308,6 @@ def _source_filters_or_none(raw: dict[str, Any] | None) -> Any: return SourceFilters.model_validate(cleaned) if cleaned else None -def _exclude_globs(fields: dict[str, Any]) -> list[str]: - """Every exclude glob in force, presets expanded.""" - from fnd.config import EXCLUDES_PRESETS - - out: list[str] = [] - for key in fields.get("excludes_presets") or (): - if key in EXCLUDES_PRESETS: - out.extend(EXCLUDES_PRESETS[key]["globs"]) - out += [g.strip() for g in str(fields.get("excludes_custom") or "").split(",") if g.strip()] - return out - - -def _excludes_summary(fields: dict[str, Any]) -> str: - """The presets and globs by name: a count would show the globs nowhere in - the UI.""" - from fnd.config import EXCLUDES_PRESETS - - named = [ - str(EXCLUDES_PRESETS[key]["label"]) - for key in fields.get("excludes_presets") or () - if key in EXCLUDES_PRESETS - ] - named += [g.strip() for g in str(fields.get("excludes_custom") or "").split(",") if g.strip()] - return ", ".join(named) if named else "(none)" - - def _overridden_fields(overrides: dict[str, Any] | None) -> list[str]: """The settings a source overrides, one name each. @@ -2381,141 +2319,176 @@ def _overridden_fields(overrides: dict[str, Any] | None) -> list[str]: return sorted(names) -def _includes_groups() -> list[ToggleGroup]: - """Category → kind model for the Includes nested picker (all registry - kinds, since a source can index any supported type).""" - from fnd.kinds import CATEGORIES, KIND_BY_ID, KINDS_IN_CATEGORY +#: One title wherever the frontmatter rule is edited. +_FRONTMATTER_RULE_TITLE = "Frontmatter rule · files with frontmatter" - groups: list[ToggleGroup] = [] - for cat in CATEGORIES: - items = tuple( - ToggleItem(k, f"{KIND_BY_ID[k].label} ({'/'.join(KIND_BY_ID[k].suffixes)})") - for k in KINDS_IN_CATEGORY[cat.id] - ) - if items: - groups.append(ToggleGroup(cat.id, cat.label, items)) - return groups +def _repopulate_keeping_cursor(lst: SettingsList, items: list[MenuItem]) -> None: + """Rebuild a form's rows on the row the user was on, so returning from a part + lands where it was opened rather than on the first field.""" + here = lst._items[lst.cursor_index].id if 0 <= lst.cursor_index < len(lst._items) else None + lst.set_items(items, cursor_id=here) -class TreePickerScreen(Screen[None]): - """Nested category→item multi-select for a picker item that supplies a - ``groups_provider``. Reuses the shared :class:`ToggleTree`, so it toggles, - cascades, and repaints exactly like the file-type filter. Changes apply as - they are toggled, so leaving is all there is to do.""" - ROLE: ClassVar[Role] = Role.SETTING +class _SourceFields(Screen[None]): + """What a source's fields do, wherever a source is edited: the source form and + Add collection share one implementation, so the two cannot drift. - BINDINGS = [ # noqa: RUF012 - Binding("escape,left", "back", "Back", show=False), - ] + The host is a Screen holding ``_fields`` with ``path``, ``includes_custom`` + and ``filters``, and the sample-tester widgets.""" - CSS = """ - TreePickerScreen { background: $surface; } - TreePickerScreen > #settings_box { - height: 1fr; border: round $primary 50%; padding: 0 1; - } - TreePickerScreen > #settings_box:focus-within { border: round $accent; } - TreePickerScreen > #footer_hints { - dock: bottom; height: 1; background: $surface; padding: 0 1; color: $text-muted; - } - """ + _fields: dict[str, Any] - def __init__(self, item: MenuItem) -> None: - super().__init__() - self._item = item + def _populate_fields(self) -> None: + raise NotImplementedError - def compose(self) -> ComposeResult: - with Vertical(id="settings_box") as box: - box.border_title = self._item.label - yield ToggleTree(id="tree_picker") - yield Static("", id="footer_hints") + def _render_footer(self) -> None: + raise NotImplementedError - def on_mount(self) -> None: - app: FNDApp = self.app # type: ignore[assignment] - groups = list(self._item.groups_provider(app)) if self._item.groups_provider else [] - current = self._item.picker_getter(app) if self._item.picker_getter else [] - selected = set(current) if isinstance(current, list | tuple | set) else set() - tree = self.query_one("#tree_picker", ToggleTree) - tree.set_model(groups, selected, expanded={g.id for g in groups}) - tree.focus() - self.query_one("#footer_hints", Static).update( - _hint_bar( - app, - (("⏎", "Toggle"), ("←/→", "Collapse/Expand"), leave_hint()), - ) - ) + def _frontmatter_text(self) -> str: + """This source's frontmatter rule. - @on(ToggleTree.SelectionChanged) - def _on_changed(self, ev: ToggleTree.SelectionChanged) -> None: - # Commit live so the row summary updates as the user toggles. - self._commit(ev.selected) + Only the overrides: a legacy ``frontmatter_filter`` is folded into + them at load, so falling back to it here would resurrect a rule the + user has just cleared in the browser. + """ + return str(self._fields["filters"].get("frontmatter") or "") - @on(ToggleTree.NavigatedOut) - def _on_navigated_out(self, _ev: ToggleTree.NavigatedOut) -> None: - """← at the outermost level leaves, as it does everywhere else in - Settings. The tree's own binding would otherwise swallow it.""" - self.action_back() + def _effective_frontmatter(self) -> tuple[str, bool]: + """The rule that actually applies, and whether it came from the + defaults. A source with no override of its own still has files dropped + by `[defaults.filters].frontmatter`, and testing a sample against + nothing told the user the opposite.""" + own = self._frontmatter_text().strip() + if own: + return own, False + cfg = getattr(self.app, "_config", None) + inherited = getattr(getattr(cfg, "defaults", None), "filters", None) + return str(getattr(inherited, "frontmatter", None) or "").strip(), True - def action_back(self) -> None: - self.app.pop_screen() + def _frontmatter_into_filters(self, text: str) -> dict[str, Any]: + """The frontmatter rule as part of this source's filter overrides. - def _commit(self, values: frozenset[str]) -> None: - if self._item.picker_setter is None: - return - try: - self._item.picker_setter(self.app, sorted(values)) # type: ignore[arg-type] - except Exception as e: - self.notify(_summarise(e), severity="error", title="Save failed") + As a separate field beside the filters, the browser and the row could + disagree about the same rule with neither showing the other's value. + """ + return _merge_frontmatter( + dict(self._fields["filters"]), + text, + _default_frontmatter(self.app), + had_override=self._fields["filters"].get("frontmatter") is not None, + ) + def _open_filters(self) -> None: + app: FNDApp = self.app # type: ignore[assignment] + raw_path = str(self._fields.get("path") or "").strip() + root = Path(raw_path).expanduser() if raw_path else None + globs = [ + g.strip() + for g in str(self._fields.get("includes_custom") or "").split(",") + if g.strip() + ] + open_source_filter_browser( + app, + self._fields["filters"], + root, + self._populate_fields, + globs, + ) -# ── Collection-form screens (rebuilt from CollectionsScreen) ──────── + def _filters_summary(self) -> str: + count = len(_overridden_fields(self._fields.get("filters"))) + return f"{count} overridden" if count else "inherited" + def _refresh_sample_tester(self) -> None: + """The tester only appears once there is a rule for it to test. -def _repopulate_keeping_cursor(lst: SettingsList, items: list[MenuItem]) -> None: - """Rebuild a form's rows on the row the user was on, so returning from a part - lands where it was opened rather than on the first field.""" - here = lst._items[lst.cursor_index].id if 0 <= lst.cursor_index < len(lst._items) else None - lst.set_items(items, cursor_id=here) + It cost a third of the form on every source, and named a rule that + lives two screens away without saying which. + """ + rule, inherited = self._effective_frontmatter() + for wid in ("#form_sample_sep", "#frontmatter_sample", "#match_status"): + self.query_one(wid).display = bool(rule) + self._render_footer() + if not rule: + return + source = " (inherited)" if inherited else "" + shown = sanitise_display_text(rule) + width = max(20, self.size.width - 12) + if len(shown) > width: + shown = shown[: width - 1] + "…" + self.query_one("#form_sample_sep", Static).update( + f"─── Paste frontmatter to test: {shown}{source} ───" + ) + def _refresh_match_status(self) -> None: + sample = self.query_one("#frontmatter_sample", TextArea).text + filter_text, inherited = self._effective_frontmatter() + status = self.query_one("#match_status", Static) + status.remove_class("-match") + status.remove_class("-no-match") + if not sample.strip(): + status.update("(no sample)") + return + from fnd.filter_dsl import parse_or_error + from fnd.frontmatter import FrontmatterParseError, read_frontmatter_from_text -def _kinds_to_include_globs(kind_ids: list[str]) -> list[str]: - """Expand selected kind ids to include globs for all their suffixes.""" - from fnd.kinds import KIND_BY_ID + try: + fm: dict[str, object] = read_frontmatter_from_text(sample) or {} + except FrontmatterParseError as e: + status.update(f"✗ frontmatter parse error: {e}") + status.add_class("-no-match") + return + if not filter_text: + status.update("(no rule, here or in the defaults)") + return + source = " (inherited from the defaults)" if inherited else "" + pred, err = parse_or_error(filter_text) + if err is not None or pred is None: + status.update(f"✗ filter syntax: col {err.column}" if err else "✗ syntax error") + status.add_class("-no-match") + return + if pred(fm): + status.update(f"✓ sample matches the rule{source}") + status.add_class("-match") + else: + status.update(f"✗ sample does not match the rule{source}") + status.add_class("-no-match") - globs: list[str] = [] - for kid in kind_ids: - spec = KIND_BY_ID.get(kid) - if spec is not None: - globs.extend(f"**/*{sfx}" for sfx in spec.suffixes) - return globs + def _edit_frontmatter(self) -> None: + """The frontmatter rule in the same editor Index filters uses, on the same value.""" + def _save(text: str) -> None: + self._fields["filters"] = self._frontmatter_into_filters(text) + self._populate_fields() # type: ignore[attr-defined] -def _split_excludes_globs(globs: list[str]) -> tuple[list[str], str]: - """Map excludes globs back to ``(preset_keys, custom_blob)``. + self.app.push_screen( # type: ignore[attr-defined] + RuleTextScreen( + title=_FRONTMATTER_RULE_TITLE, + value=self._frontmatter_text(), + note_scoped=True, + on_save=_save, + ) + ) - A preset is considered selected iff every glob it ships is present. - Once a preset's globs are consumed they are removed from the remaining - pool; whatever's left becomes the comma-joined custom blob. - """ - from fnd.config import EXCLUDES_PRESETS + def _frontmatter_with_status(self) -> str: + """The rule with a live parse mark, or the inherited one, or (none).""" + own = self._frontmatter_text().strip() + if not own: + inherited = _default_frontmatter(self.app).strip() # type: ignore[attr-defined] + return f"{inherited} (inherited)" if inherited else "(none)" + from fnd.filter_dsl import parse_or_error - remaining = list(globs) - preset_keys: list[str] = [] - for key, preset in EXCLUDES_PRESETS.items(): - preset_globs = preset["globs"] - if all(g in remaining for g in preset_globs): - preset_keys.append(key) - for g in preset_globs: - remaining.remove(g) - return preset_keys, ", ".join(remaining) + _pred, err = parse_or_error(own) + return f"{own} ✓" if err is None else f"{own} ✗ col {err.column}" -class SourceFormScreen(DocumentScreen): +class SourceFormScreen(_SourceFields, DocumentScreen): """Per-source editor: a document (see :mod:`fnd.tui.editing`). - Multi-field form (Path, Includes, Excludes, Filter, Follow symlinks) - plus a TextArea below for the pasted-frontmatter sample tester. Same - chrome as the other Settings screens. + Multi-field form (Path, Follow symlinks, Index filters, Restrict to these + paths, Opening) plus a TextArea below for the pasted-frontmatter sample + tester. Same chrome as the other Settings screens. """ SUBJECT = "this source" @@ -2554,13 +2527,9 @@ def __init__(self, *, collection_name: str, source_index: int | None) -> None: super().__init__() self._collection_name = collection_name self._source_index = source_index # None = adding new - # Globs an untick cleared, so re-ticking can offer them back. - self._discarded_globs: dict[str, str] = {} self._fields: dict[str, Any] = { "path": "", "includes_custom": "", # comma-separated free-form globs - "excludes_presets": [], # list[str] of EXCLUDES_PRESETS keys - "excludes_custom": "", # comma-separated free-form globs "filter": "", "follow_symlinks": False, # Per-source app override + Obsidian vault. @@ -2641,12 +2610,9 @@ def _load_snapshot(self) -> None: # Every include glob, type globs too: the model folds those into # `kinds` only when nothing else is listed, so one still here is an # ORed path glob like its neighbours, not a file type. - preset_keys, excludes_custom = _split_excludes_globs(list(s.excludes)) self._fields = { "path": str(s.path), "includes_custom": ", ".join(s.includes), - "excludes_presets": preset_keys, - "excludes_custom": excludes_custom, "filter": _source_frontmatter(s), "follow_symlinks": bool(s.follow_symlinks), "app": s.app or "", @@ -2657,62 +2623,6 @@ def _load_snapshot(self) -> None: # never equals the fields, so every save would force a rebuild. self._snapshot = copy.deepcopy(self._fields) - def _frontmatter_text(self) -> str: - """This source's frontmatter rule. - - Only the overrides: a legacy ``frontmatter_filter`` is folded into - them at load, so falling back to it here would resurrect a rule the - user has just cleared in the browser. - """ - return str(self._fields["filters"].get("frontmatter") or "") - - def _effective_frontmatter(self) -> tuple[str, bool]: - """The rule that actually applies, and whether it came from the - defaults. A source with no override of its own still has files dropped - by `[defaults.filters].frontmatter`, and testing a sample against - nothing told the user the opposite.""" - own = self._frontmatter_text().strip() - if own: - return own, False - cfg = getattr(self.app, "_config", None) - inherited = getattr(getattr(cfg, "defaults", None), "filters", None) - return str(getattr(inherited, "frontmatter", None) or "").strip(), True - - def _frontmatter_into_filters(self, text: str) -> dict[str, Any]: - """The frontmatter rule as part of this source's filter overrides. - - As a separate field beside the filters, the browser and the row could - disagree about the same rule with neither showing the other's value. - """ - return _merge_frontmatter( - dict(self._fields["filters"]), - text, - _default_frontmatter(self.app), - had_override=self._fields["filters"].get("frontmatter") is not None, - ) - - def _open_filters(self) -> None: - app: FNDApp = self.app # type: ignore[assignment] - raw_path = str(self._fields.get("path") or "").strip() - root = Path(raw_path).expanduser() if raw_path else None - globs = [ - g.strip() - for g in str(self._fields.get("includes_custom") or "").split(",") - if g.strip() - ] - open_source_filter_browser( - app, - self._fields["filters"], - root, - self._populate_fields, - globs, - _exclude_globs(self._fields), - ) - - def _filters_summary(self) -> str: - count = len(_overridden_fields(self._fields.get("filters"))) - return f"{count} overridden" if count else "inherited" - def _populate_fields(self) -> None: _repopulate_keeping_cursor(self.query_one(SettingsList), self._build_field_items()) self._refresh_sample_tester() @@ -2720,29 +2630,7 @@ def _populate_fields(self) -> None: # very field it named, so "Name is required." sat above a filled name. self._clear_error() - def _refresh_sample_tester(self) -> None: - """The tester only appears once there is a rule for it to test. - - It cost a third of the form on every source, and named a rule that - lives two screens away without saying which. - """ - rule, inherited = self._effective_frontmatter() - for wid in ("#form_sample_sep", "#frontmatter_sample", "#match_status"): - self.query_one(wid).display = bool(rule) - self._render_footer() - if not rule: - return - source = " (inherited)" if inherited else "" - shown = sanitise_display_text(rule) - width = max(20, self.size.width - 12) - if len(shown) > width: - shown = shown[: width - 1] + "…" - self.query_one("#form_sample_sep", Static).update( - f"─── Paste frontmatter to test: {shown}{source} ───" - ) - def _build_field_items(self) -> list[MenuItem]: - from fnd.config import EXCLUDES_PRESETS return [ header("Source", level=2), @@ -2794,35 +2682,6 @@ def _build_field_items(self) -> list[MenuItem]: "filters." ), ), - MenuItem( - id="form.excludes", - label="Excludes", - value_getter=lambda _app: _excludes_summary(self._fields), - description=( - "Paths to skip, as ready-made presets or your own globs. " - "Applied before any filter, so an excluded folder is never " - "read at all." - ), - kind=KIND_PICKER, - multi=True, - choices_provider=lambda _app: [ - *( - ChoiceOption( - value=key, - label=preset["label"], - description=", ".join(preset["globs"]), - ) - for key, preset in EXCLUDES_PRESETS.items() - ), - ChoiceOption( - value="__custom__", - label="Custom glob…", - description="Add a free-form glob pattern (comma-separated).", - ), - ], - picker_getter=lambda _app: self._excludes_picker_state(), - picker_setter=lambda _app, vs: self._set_excludes(vs), - ), header("Opening", level=2), MenuItem( id="form.app", @@ -2911,40 +2770,6 @@ def _set_app(self, value: str) -> None: self._fields["app_params_vault"] = detected self.query_one(SettingsList).refresh_values() - def _excludes_picker_state(self) -> list[str]: - state = list(self._fields["excludes_presets"]) - if str(self._fields.get("excludes_custom") or "").strip(): - state.append("__custom__") - return state - - def _set_excludes(self, values: list[str]) -> None: - picked = list(values) - wants_custom = "__custom__" in picked - self._fields["excludes_presets"] = [v for v in picked if v != "__custom__"] - if wants_custom and not str(self._fields.get("excludes_custom") or "").strip(): - self._prompt_custom("excludes_custom", "Excludes custom globs (comma-separated)") - elif not wants_custom: - self._discard_custom("excludes_custom") - self.query_one(SettingsList).refresh_values() - - def _discard_custom(self, field_key: str) -> None: - """Untick clears the globs, but keeps them for the visit. - - The tick is derived from the text, so leaving it set would re-tick the - row; dropping it outright would lose typed globs to one keypress. - """ - _discard_custom_globs(self, field_key) - - def _prompt_custom(self, field_key: str, label: str) -> None: - item = MenuItem( - id=f"form.{field_key}", - label=label, - hint=_GLOB_HINT, - kind=KIND_SCALAR, - value_getter=lambda _app, key=field_key: str(self._fields.get(key) or ""), - ) - self.query_one(EditBar).open(item, _custom_seed(self, field_key)) - def _field_item(self, key: str, label: str, *, hint: str, description: str = "") -> MenuItem: def _get(_app: Any) -> str: v = self._fields[key] @@ -2976,9 +2801,7 @@ def _set_follow(self, value: bool) -> None: def _on_field_activated(self, ev: SettingsList.Activated) -> None: item = ev.item if item.kind == KIND_PICKER: - self.app.push_screen( - TreePickerScreen(item) if item.groups_provider is not None else PickerScreen(item) - ) + self.app.push_screen(PickerScreen(item)) elif item.kind == KIND_SCALAR: current = self._fields.get(item.id.split(".", 1)[-1], "") self.query_one(EditBar).open(item, str(current or "")) @@ -3015,40 +2838,6 @@ def _on_edit_committed(self, ev: EditBar.EditCommitted) -> None: def _on_sample_changed(self, _ev: TextArea.Changed) -> None: self._refresh_match_status() - def _refresh_match_status(self) -> None: - sample = self.query_one("#frontmatter_sample", TextArea).text - filter_text, inherited = self._effective_frontmatter() - status = self.query_one("#match_status", Static) - status.remove_class("-match") - status.remove_class("-no-match") - if not sample.strip(): - status.update("(no sample)") - return - from fnd.filter_dsl import parse_or_error - from fnd.frontmatter import FrontmatterParseError, read_frontmatter_from_text - - try: - fm: dict[str, object] = read_frontmatter_from_text(sample) or {} - except FrontmatterParseError as e: - status.update(f"✗ frontmatter parse error: {e}") - status.add_class("-no-match") - return - if not filter_text: - status.update("(no rule, here or in the defaults)") - return - source = " (inherited from the defaults)" if inherited else "" - pred, err = parse_or_error(filter_text) - if err is not None or pred is None: - status.update(f"✗ filter syntax: col {err.column}" if err else "✗ syntax error") - status.add_class("-no-match") - return - if pred(fm): - status.update(f"✓ sample matches the rule{source}") - status.add_class("-match") - else: - status.update(f"✗ sample does not match the rule{source}") - status.add_class("-no-match") - def _parse_status(self, filter_text: str) -> str: from fnd.filter_dsl import parse_or_error @@ -3127,7 +2916,6 @@ def write(self) -> str: from pathlib import Path from fnd.config import ( - EXCLUDES_PRESETS, CollectionConfig, SourceConfig, default_config_path, @@ -3144,13 +2932,6 @@ def write(self) -> str: g = g.strip() if g: includes_globs.append(g) - excludes_globs: list[str] = [] - for preset_id in self._fields["excludes_presets"]: - excludes_globs.extend(EXCLUDES_PRESETS[preset_id]["globs"]) - for g in str(self._fields.get("excludes_custom") or "").split(","): - g = g.strip() - if g: - excludes_globs.append(g) app: FNDApp = self.app # type: ignore[assignment] # Read the file, not the launch snapshot: `write_collection` replaces the # collection table wholesale, so a stale model deletes sources added by @@ -3192,7 +2973,6 @@ def write(self) -> str: values.update( path=Path(path), includes=includes_globs, - excludes=excludes_globs, follow_symlinks=bool(self._fields["follow_symlinks"]), frontmatter_filter=None, filters=_source_filters_or_none( @@ -3285,7 +3065,7 @@ def action_cycle_focus(self, direction: int) -> None: target.focus() -class AddCollectionWizard(DocumentScreen): +class AddCollectionWizard(_SourceFields, DocumentScreen): """Single-screen form for creating a new collection + its first source. Field rows live in a SettingsList; the frontmatter sample tester docks @@ -3331,21 +3111,13 @@ class AddCollectionWizard(DocumentScreen): def __init__(self) -> None: super().__init__() - from fnd.config import EXCLUDES_PRESETS - - # Globs an untick cleared, so re-ticking can offer them back. - self._discarded_globs: dict[str, str] = {} + # The same field names the source form uses, so the shared methods serve both. self._fields: dict[str, Any] = { "name": "", "path": "", - "includes": [], - "includes_custom": "", - "excludes_presets": [ - key for key, preset in EXCLUDES_PRESETS.items() if preset["default"] - ], - "excludes_custom": "", - "filter": "", "follow_symlinks": False, + "includes_custom": "", + "filters": {}, } def compose(self) -> ComposeResult: @@ -3407,21 +3179,9 @@ def _populate_fields(self) -> None: # very field it named, so "Name is required." sat above a filled name. self._clear_error() - def _refresh_sample_tester(self) -> None: - """As on the source form: nothing to test without a rule.""" - rule = str(self._fields.get("filter") or "").strip() - for wid in ("#form_sample_sep", "#frontmatter_sample", "#match_status"): - self.query_one(wid).display = bool(rule) - if rule: - self.query_one("#form_sample_sep", Static).update( - f"─── Paste frontmatter to test: {sanitise_display_text(rule)} ───" - ) - self._render_footer() - def _build_field_items(self) -> list[MenuItem]: - from fnd.config import EXCLUDES_PRESETS - - return [ + """The collection's own name, then a Source box holding everything that is the source's.""" + collection = [ MenuItem( id="wiz.name", label="Name", @@ -3429,173 +3189,77 @@ def _build_field_items(self) -> list[MenuItem]: kind=KIND_SCALAR, value_getter=lambda _app: self._fields["name"] or "(required)", ), + ] + source = [ MenuItem( id="wiz.path", - label="Source path", - description="The folder to index. Add more sources to it afterwards.", + label="Path", + description=( + "The folder to index. ~ expands; the path must exist. Add more " + "sources to the collection afterwards." + ), kind=KIND_SCALAR, + hint="path or ~/path", value_getter=lambda _app: self._fields["path"] or "(required)", elide="head", ), MenuItem( - id="wiz.includes", - label="File types", - value_getter=lambda _app: self._summarise_includes(), - description=( - "Which types to index. Tick none for every supported type, " - "which also picks up ones added in later versions." - ), - kind=KIND_PICKER, - multi=True, - groups_provider=lambda _app: _includes_groups(), - picker_getter=lambda _app: self._includes_picker_state(), - picker_setter=lambda _app, vs: self._set_includes(vs), + id="wiz.follow_symlinks", + label="Follow symlinks", + description="Index through symlinked folders. Off avoids indexing a tree twice.", + kind=KIND_TOGGLE, + toggle_getter=lambda _app: bool(self._fields["follow_symlinks"]), + toggle_setter=lambda _app, v: self._set_follow(v), ), + header("What gets indexed", level=2), MenuItem( - id="wiz.excludes", - label="Excludes", - value_getter=lambda _app: self._summarise_excludes(), + id="wiz.filters", + label="Index filters", description=( - "Paths to skip, as presets or your own globs. Applied " - "before any filter, so an excluded folder is never read." + "File types, excluded paths, skipped tags, size and dates for this " + "source. Each inherits the global default until you change it here." ), - kind=KIND_PICKER, - multi=True, - choices_provider=lambda _app: [ - *( - ChoiceOption( - value=key, - label=preset["label"], - description=", ".join(preset["globs"]), - ) - for key, preset in EXCLUDES_PRESETS.items() - ), - ChoiceOption( - value="__custom__", - label="Custom glob…", - description="Add a free-form glob pattern (comma-separated).", - ), - ], - picker_getter=lambda _app: self._excludes_picker_state(), - picker_setter=lambda _app, vs: self._set_excludes_presets(vs), + kind=KIND_EXTERNAL, + external=lambda _app: self._open_filters(), + value_getter=lambda _app: self._filters_summary(), ), MenuItem( - id="wiz.filter", + id="wiz.frontmatter", label="Frontmatter rule", description=( "Index only notes whose YAML frontmatter matches, e.g. " - "status == 'done'. Files without frontmatter are unaffected." + "status == 'done'. The same rule as Index filters › Rules you type." ), - kind=KIND_SCALAR, - hint="frontmatter DSL", - value_getter=lambda _app: self._filter_with_status(), + kind=KIND_EXTERNAL, + external=lambda _app: self._edit_frontmatter(), + value_getter=lambda _app: self._frontmatter_with_status(), ), MenuItem( - id="wiz.follow_symlinks", - label="Follow symlinks", - description="Index through symlinked folders. Off avoids indexing a tree twice.", - kind=KIND_TOGGLE, - toggle_getter=lambda _app: bool(self._fields["follow_symlinks"]), - toggle_setter=lambda _app, v: self._set_follow(v), + id="wiz.includes_custom", + label="Restrict to these paths", + description=( + "Leave empty to index the whole folder. Set it and ONLY matching " + "paths are indexed. File types belong in Index filters." + ), + kind=KIND_SCALAR, + hint="glob patterns, comma-separated", + value_getter=lambda _app: self._fields["includes_custom"] or "(unset)", ), ] - - def _summarise_includes(self) -> str: - """What the new collection will actually index. - - Setting nothing here does not mean "every type": the source inherits - `defaults.filters`, so with a default of `kinds = ["md"]` "every type" - would be false while the collection indexes 3 files of 12. - """ - from fnd.kinds import ALL_KIND_IDS - - n = len(self._fields["includes"]) - if n == len(ALL_KIND_IDS): - return "every type" - if n: - return f"{n} of {len(ALL_KIND_IDS)} types" - inherited = list(_default_filters(self.app).kinds) - if inherited: - return f"{', '.join(inherited)} (inherited)" - return "every type" - - def _summarise_excludes(self) -> str: - return _excludes_summary(self._fields) + return [ + header("Collection", level=2), + *collection, + *(dataclasses.replace(item, subsection="Source") for item in source), + ] def _set_follow(self, value: bool) -> None: self._fields["follow_symlinks"] = bool(value) - def _filter_with_status(self) -> str: - """Trailing column for the Frontmatter filter row — shows the - DSL string plus a live ``✓`` / ``✗ col N`` parse indicator so - syntax mistakes surface without leaving the form.""" - text = str(self._fields.get("filter") or "").strip() - if not text: - # Same reason as `_summarise_includes`: an unset rule here means - # the default's rule applies, not that nothing does. - inherited = _default_frontmatter(self.app).strip() - return f"{inherited} (inherited)" if inherited else "(none)" - from fnd.filter_dsl import parse_or_error - - _pred, err = parse_or_error(text) - if err is None: - return f"{text} ✓" - return f"{text} ✗ col {err.column}" - - def _includes_picker_state(self) -> list[str]: - """Nested tree picker seed: current kinds, or ALL kinds when empty so a - new source opens with every type selected (empty includes = index all).""" - from fnd.kinds import ALL_KIND_IDS - - inc = list(self._fields["includes"]) - return inc if inc else list(ALL_KIND_IDS) - - def _excludes_picker_state(self) -> list[str]: - state = list(self._fields["excludes_presets"]) - if str(self._fields.get("excludes_custom") or "").strip(): - state.append("__custom__") - return state - - def _set_includes(self, values: list[str]) -> None: - """Tree picker commit: store selected kind ids. All selected → store - empty (= index every supported type, future-proof). Preserves any - existing custom-glob value untouched.""" - from fnd.kinds import ALL_KIND_IDS - - picked = [v for v in values if v in set(ALL_KIND_IDS)] - self._fields["includes"] = [] if set(picked) >= set(ALL_KIND_IDS) else picked - self.query_one(SettingsList).refresh_values() - - def _set_excludes_presets(self, values: list[str]) -> None: - picked = list(values) - wants_custom = "__custom__" in picked - presets = [v for v in picked if v != "__custom__"] - self._fields["excludes_presets"] = presets - if wants_custom and not str(self._fields.get("excludes_custom") or "").strip(): - self._prompt_custom("excludes_custom", "Excludes custom globs (comma-separated)") - elif not wants_custom: - _discard_custom_globs(self, "excludes_custom") - self.query_one(SettingsList).refresh_values() - - def _prompt_custom(self, field_key: str, label: str) -> None: - """Open the wizard's EditBar to capture a custom glob value and - store it in ``self._fields[field_key]`` on submit.""" - item = MenuItem( - id=f"wiz.{field_key}", - label=label, - hint=_GLOB_HINT, - kind=KIND_SCALAR, - value_getter=lambda _app, key=field_key: str(self._fields.get(key) or ""), - ) - self.query_one(EditBar).open(item, _custom_seed(self, field_key)) - @on(SettingsList.Activated) def _on_field_activated(self, ev: SettingsList.Activated) -> None: item = ev.item - if item.kind == KIND_PICKER: - self.app.push_screen( - TreePickerScreen(item) if item.groups_provider is not None else PickerScreen(item) - ) + if item.kind == KIND_EXTERNAL and item.external is not None: + item.external(self.app) # type: ignore[arg-type] elif item.kind == KIND_SCALAR: field_key = item.id.split(".", 1)[-1] current = self._fields.get(field_key, "") @@ -3625,51 +3289,9 @@ def _on_sample_changed(self, _ev: TextArea.Changed) -> None: actually functional, not just visually present.""" self._refresh_match_status() - def _refresh_match_status(self) -> None: - sample = self.query_one("#frontmatter_sample", TextArea).text - filter_text = str(self._fields.get("filter") or "").strip() - status = self.query_one("#match_status", Static) - status.remove_class("-match") - status.remove_class("-no-match") - if not sample.strip(): - status.update("(no sample)") - return - from fnd.filter_dsl import parse_or_error - from fnd.frontmatter import FrontmatterParseError, read_frontmatter_from_text - - try: - fm: dict[str, object] = read_frontmatter_from_text(sample) or {} - except FrontmatterParseError as e: - status.update(f"✗ frontmatter parse error: {e}") - status.add_class("-no-match") - return - if not filter_text: - status.update("(no filter)") - return - pred, err = parse_or_error(filter_text) - if err is not None or pred is None: - status.update(f"✗ filter syntax: col {err.column}" if err else "✗ syntax error") - status.add_class("-no-match") - return - if pred(fm): - status.update("✓ sample matches filter") - status.add_class("-match") - else: - status.update("✗ sample does not match filter") - status.add_class("-no-match") - @on(EditBar.EditCommitted) def _on_edit_committed(self, ev: EditBar.EditCommitted) -> None: field_key = ev.item.id.split(".", 1)[-1] - if field_key == "filter": - text = str(ev.value or "").strip() - if text: - from fnd.filter_dsl import parse_or_error - - _pred, err = parse_or_error(text) - if err is not None: - self.query_one(EditBar).show_error(f"col {err.column}: {err.message}") - return self._fields[field_key] = ev.value self.query_one(EditBar).close() self.query_one(SettingsList).refresh_values() @@ -3729,7 +3351,6 @@ def write(self) -> str: from pathlib import Path from fnd.config import ( - EXCLUDES_PRESETS, CollectionConfig, InvalidCollectionNameError, SourceConfig, @@ -3744,21 +3365,11 @@ def write(self) -> str: path = str(self._fields["path"]).strip().strip("'\"") p = Path(path).expanduser() - includes_globs: list[str] = _kinds_to_include_globs(list(self._fields["includes"])) - includes_custom = str(self._fields.get("includes_custom") or "") - for g in includes_custom.split(","): - g = g.strip() - if g: - includes_globs.append(g) - - excludes_globs: list[str] = [] - for preset_id in self._fields["excludes_presets"]: - excludes_globs.extend(EXCLUDES_PRESETS[preset_id]["globs"]) - custom = str(self._fields["excludes_custom"] or "") - for g in custom.split(","): - g = g.strip() - if g: - excludes_globs.append(g) + includes_globs = [ + g.strip() + for g in str(self._fields.get("includes_custom") or "").split(",") + if g.strip() + ] app: FNDApp = self.app # type: ignore[assignment] @@ -3769,17 +3380,8 @@ def write(self) -> str: source = SourceConfig( path=p, includes=includes_globs, - excludes=excludes_globs, follow_symlinks=bool(self._fields["follow_symlinks"]), - frontmatter_filter=None, - filters=_source_filters_or_none( - _merge_frontmatter( - dict(self._fields.get("filters", {})), - str(self._fields["filter"]), - _default_frontmatter(self.app), - had_override=self._fields.get("filters", {}).get("frontmatter") is not None, - ) - ), + filters=_source_filters_or_none(self._fields["filters"]), ) except ValueError as e: # pydantic's ValidationError is a ValueError; `_summarise` renders @@ -5891,6 +5493,7 @@ def _describe_spec(spec: Any) -> str: "modified_before", "frontmatter", "expression", + "excludes", ) @@ -6094,6 +5697,73 @@ def hand_back(self) -> str: return "" +class GlobTextScreen(PartScreen): + """Exclude globs typed by hand, comma-separated: a part of the browser. + + The presets are rows to tick; this holds whatever else the walk should skip. + """ + + CSS = RuleTextScreen.CSS.replace("RuleTextScreen", "GlobTextScreen").replace("rule_", "glob_") + + def __init__( + self, *, title: str, value: Sequence[str], on_save: Callable[[tuple[str, ...]], None] + ) -> None: + super().__init__() + self._title = title + self._value = tuple(value) + self._on_save = on_save + + def compose(self) -> ComposeResult: + with Vertical(id="settings_box") as box: + box.border_title = self._title + yield TextArea(", ".join(self._value), id="glob_text") + yield Static("", id="glob_status") + yield Static(_GLOB_HINT, id="glob_help") + yield Static("", id="footer_hints") + + def on_mount(self) -> None: + self.query_one("#glob_text", TextArea).focus() + self._refresh_status() + self.on_refused() + + def on_refused(self) -> None: + esc = ("Esc", CANCEL) if self.refused else leave_hint(with_left=False) + self.query_one("#footer_hints", Static).update(_editor_hint_bar((esc,))) + + @on(TextArea.Changed, "#glob_text") + def _on_changed(self, _ev: TextArea.Changed) -> None: + self.edited() + self._refresh_status() + + def _globs(self) -> tuple[str, ...]: + text = self.query_one("#glob_text", TextArea).text + return tuple(g.strip() for g in text.replace("\n", ",").split(",") if g.strip()) + + def _problem(self) -> str: + from fnd.filters.excludes import invalid_glob + + bad = [g for g in self._globs() if invalid_glob(g)] + return f"{bad[0]!r} is not a valid glob" if bad else "" + + def _refresh_status(self) -> None: + status = self.query_one("#glob_status", Static) + status.remove_class("-ok", "-bad") + problem = self._problem() + status.add_class("-bad" if problem else "-ok") + count = len(self._globs()) + status.update(f"✗ {problem}" if problem else f"✓ {count} glob{'s' if count != 1 else ''}") + + def hand_back(self) -> str: + globs = self._globs() + if globs == self._value: + return "" + if problem := self._problem(): + return problem + self._on_save(globs) + self._value = globs + return "" + + #: Spec fields as the screens name them, so a message reads like the UI. Two #: fields sharing a name collapse to one entry. _FIELD_WORDS: dict[str, str] = { @@ -6108,6 +5778,7 @@ def hand_back(self) -> str: "modified_before": "modified dates", "frontmatter": "the frontmatter rule", "expression": "the custom rule", + "excludes": "excluded paths", } @@ -6122,7 +5793,7 @@ def _cleared_note(before: Any, after: Any) -> str: dict.fromkeys( _FIELD_WORDS[name] for name in _SPEC_FIELDS - if getattr(before, name) and not getattr(after, name) + if getattr(before, name) != getattr(after, name) ) ) if not dropped: @@ -6203,7 +5874,6 @@ def __init__( fndignore: bool, sample_provider: Callable[[Any], Any] | None = None, globs: list[str] | None = None, - excludes: list[str] | None = None, inherited: tuple[Any, bool, bool] | None = None, no_tags_note: str = "", unindexed_note: str = "", @@ -6222,9 +5892,6 @@ def __init__( # as ticked kinds: saving them back as kinds would widen a glob that # names one suffix of a multi-suffix type. Say so instead. self._globs = list(globs or ()) - # Excludes drop files before any filter runs, so a summary that names - # only the includes is silent about half of what is skipped. - self._excludes = list(excludes or ()) self._title = title self._spec = spec self._gitignore = gitignore @@ -6278,9 +5945,12 @@ def _on_rule_selected(self, ev: ToggleTree.ActionSelected) -> None: # editor that can rather than being a dead end. self.action_edit_text() return + if ev.item_id == "rule:excludes": + self._edit_typed_excludes() + return field_name = ev.item_id.removeprefix("rule:") titles = { - "frontmatter": "Frontmatter rule · files with frontmatter", + "frontmatter": _FRONTMATTER_RULE_TITLE, "expression": "Custom rule · any file", } if field_name not in titles: @@ -6618,8 +6288,9 @@ def _refresh_summary(self) -> None: head.append("skipping hidden files") if self._globs: head.append("restricted to paths: " + ", ".join(self._globs)) - if self._excludes: - head.append("skipping paths: " + ", ".join(self._excludes)) + # Excludes drop files before any filter runs, so they are named here too. + if self._spec.excludes: + head.append("skipping paths: " + ", ".join(self._spec.excludes)) for clash in self._spec.impossible_bounds(): # Decidable without a corpus, and the outcome is an empty index. head.append(f"nothing can match: {clash}") @@ -6686,12 +6357,26 @@ def action_clear_all(self) -> None: # a tag exclusion, which is a protection rather than a preference. self.notify(_cleared_note(before, self._spec)) + def _edit_typed_excludes(self) -> None: + from dataclasses import replace as _replace + + from fnd.filters.excludes import retype, split_presets + + _presets, typed = split_presets(self._spec.excludes) + + def _save(globs: tuple[str, ...]) -> None: + self._spec = _replace(self._spec, excludes=retype(self._spec.excludes, globs)) + self._rebuild() + + self.app.push_screen(GlobTextScreen(title="Excluded globs", value=typed, on_save=_save)) + def action_edit_text(self) -> None: def _save(spec: Any) -> None: # Named when it lands, not only while typing: the text screen's # warning is gone by the time the tree is back. dropped = _protection_dropped(self._spec, spec) - self._spec = spec + # The text form cannot say excludes, so it never carries them: keep them. + self._spec = dataclasses.replace(spec, excludes=self._spec.excludes) self._rebuild() if dropped: self.app.notify(f"{dropped} files are no longer excluded", severity="warning") diff --git a/fnd/walk.py b/fnd/walk.py index 3458ed8e..15d0229b 100644 --- a/fnd/walk.py +++ b/fnd/walk.py @@ -61,7 +61,7 @@ def resolve_skip_dirs(defaults: object | None = None) -> frozenset[str]: overrides (disable, extend) take effect. ``None`` resolves to the built-in :data:`fnd.config.DEFAULT_JUNK_DIRS`. An empty frozenset disables the prune (the rest of the walk still applies the existing - hidden-file and per-source ``excludes`` rules). + hidden-file and ``excludes`` filter rules). """ from fnd.config import DEFAULT_JUNK_DIRS @@ -259,6 +259,8 @@ def _scandir_walk( child = Path(entry.path) if scope and scope.ignored(child, is_dir=True): continue + if exc and exc.covers_dir(child.relative_to(root).as_posix()): + continue stack.append((child, scope)) continue diff --git a/tests/test_a_migrated_glob_selects_the_files_it_did.py b/tests/test_a_migrated_glob_selects_the_files_it_did.py index f238611b..10ebd32b 100644 --- a/tests/test_a_migrated_glob_selects_the_files_it_did.py +++ b/tests/test_a_migrated_glob_selects_the_files_it_did.py @@ -138,7 +138,8 @@ def _legacy_selects(includes: list[str], excludes: list[str], rel: str) -> bool: def _migrated(includes: list[str], excludes: list[str]) -> tuple[list[str], list[str]]: source: dict[str, Any] = {"path": "~/x", "includes": list(includes), "excludes": excludes} migrate({"collections": {"c": {"sources": [source]}}}) - return source["includes"], source["excludes"] + # v3 moves a source's excludes into its filters, after v2 respelled them. + return source["includes"], source.get("filters", {}).get("excludes", []) def _mismatches(glob: str) -> list[str]: diff --git a/tests/test_a_prompt_never_offers_a_save_that_fails.py b/tests/test_a_prompt_never_offers_a_save_that_fails.py index 31ac7a5d..97890fc5 100644 --- a/tests/test_a_prompt_never_offers_a_save_that_fails.py +++ b/tests/test_a_prompt_never_offers_a_save_that_fails.py @@ -163,7 +163,7 @@ async def test_a_buried_form_can_be_saved_from_the_prompt( await pilot.pause() form = app.screen assert isinstance(form, SourceFormScreen) - form._fields["excludes_custom"] = "build/**" + form._fields["includes_custom"] = "build/**" app.push_screen(AddCollectionWizard()) for _ in range(20): await pilot.pause() diff --git a/tests/test_a_source_row_does_not_claim_inherited_filters.py b/tests/test_a_source_row_does_not_claim_inherited_filters.py index c142b2c1..e271be1e 100644 --- a/tests/test_a_source_row_does_not_claim_inherited_filters.py +++ b/tests/test_a_source_row_does_not_claim_inherited_filters.py @@ -39,6 +39,27 @@ def test_a_source_with_its_own_rule_names_it(tmp_path: Path) -> None: def test_two_sources_that_differ_read_differently(tmp_path: Path) -> None: """The point of the row: it has to tell them apart.""" bare = _other_filters(SourceConfig(path=tmp_path)) - own = _other_filters(SourceConfig(path=tmp_path, excludes=["build/**"])) + own = _other_filters(SourceConfig(path=tmp_path, filters=SourceFilters(excludes=["build/**"]))) assert bare != own, (bare, own) + + +def test_master_excludes_read_as_inherited(tmp_path: Path) -> None: + """Excludes now inherit like every filter, so the master list is not the source's own.""" + from fnd.config import CollectionConfig, Config, DefaultFilters, Defaults + + cfg = Config( + defaults=Defaults(filters=DefaultFilters(excludes=["a/**"])), + collections={"notes": CollectionConfig(sources=[SourceConfig(path=tmp_path)])}, + ) + named = _other_filters(cfg.collections["notes"].sources[0]) + + assert "excludes" not in named, named + assert "inherited" in named, named + + +def test_own_excludes_are_named(tmp_path: Path) -> None: + """A source's own list is its own dimension.""" + own = _other_filters(SourceConfig(path=tmp_path, filters=SourceFilters(excludes=["b/**"]))) + + assert "excludes" in own, own diff --git a/tests/test_a_source_row_names_its_excludes.py b/tests/test_a_source_row_names_its_excludes.py index 68ce3386..ca8f2e5d 100644 --- a/tests/test_a_source_row_names_its_excludes.py +++ b/tests/test_a_source_row_names_its_excludes.py @@ -9,12 +9,12 @@ from pathlib import Path -from fnd.config import SourceConfig +from fnd.config import SourceConfig, SourceFilters from fnd.tui.menu import _other_filters def test_an_excluding_source_says_so(tmp_path: Path) -> None: - src = SourceConfig(path=tmp_path, excludes=["build/**"]) + src = SourceConfig(path=tmp_path, filters=SourceFilters(excludes=["build/**"])) assert "excludes" in _other_filters(src) @@ -35,8 +35,7 @@ def test_it_sits_beside_the_others(tmp_path: Path) -> None: src = SourceConfig( path=tmp_path, - excludes=["build/**"], - filters=SourceFilters(max_size=1000), + filters=SourceFilters(excludes=["build/**"], max_size=1000), ) named = _other_filters(src) diff --git a/tests/test_add_collection_matches_the_form.py b/tests/test_add_collection_matches_the_form.py new file mode 100644 index 00000000..da9f99e0 --- /dev/null +++ b/tests/test_add_collection_matches_the_form.py @@ -0,0 +1,150 @@ +"""Add collection: the collection's name, then its first source exactly as the source form shows it.""" + +from __future__ import annotations + +import dataclasses +from pathlib import Path +from typing import Any + +import pytest +from textual.widgets import TextArea + +from fnd.config import EXCLUDES_PRESETS, load, write_collection +from fnd.tui import FNDApp +from fnd.tui.menu import KIND_HEADER +from fnd.tui.settings_screen import AddCollectionWizard, SettingsList + +NODE = EXCLUDES_PRESETS["node_modules"]["globs"] + + +@pytest.fixture +def config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + from fnd.config import CollectionConfig, SourceConfig + + cfg_path = tmp_path / "config.toml" + (tmp_path / "notes").mkdir() + monkeypatch.setattr("fnd.config.default_config_path", lambda: cfg_path) + write_collection( + config_path=cfg_path, + name="notes", + collection=CollectionConfig(sources=[SourceConfig(path=tmp_path / "notes")]), + ) + return cfg_path + + +async def _settle(pilot: Any, n: int = 15) -> None: + for _ in range(n): + await pilot.pause() + + +async def _wizard(app: FNDApp, pilot: Any) -> AddCollectionWizard: + app.push_screen(AddCollectionWizard()) + await _settle(pilot) + wizard = app.screen + assert isinstance(wizard, AddCollectionWizard) + return wizard + + +def _rows(wizard: AddCollectionWizard) -> list[tuple[str | None, str]]: + return [ + (i.subsection, f"[{i.label}]" if i.kind == KIND_HEADER else i.id) + for i in wizard.query_one(SettingsList)._items + ] + + +@pytest.mark.asyncio +async def test_what_gets_indexed_sits_inside_the_source( + config_file: Path, tmp_index_dir: Path +) -> None: + """A heading beside Source would read as the collection's; the Source box holds them.""" + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + async with app.run_test(size=(120, 40)) as pilot: + await _settle(pilot, 5) + rows = _rows(await _wizard(app, pilot)) + assert rows == [ + (None, "[Collection]"), + (None, "wiz.name"), + ("Source", "wiz.path"), + ("Source", "wiz.follow_symlinks"), + ("Source", "[What gets indexed]"), + ("Source", "wiz.filters"), + ("Source", "wiz.frontmatter"), + ("Source", "wiz.includes_custom"), + ] + + +@pytest.mark.asyncio +async def test_the_frontmatter_row_and_index_filters_edit_one_rule( + config_file: Path, tmp_index_dir: Path +) -> None: + """One value, one editor: a rule typed on the row is the Index filters rule.""" + from fnd.tui.settings_screen import FilterBrowserScreen, RuleTextScreen + + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + async with app.run_test(size=(120, 40)) as pilot: + await _settle(pilot, 5) + wizard = await _wizard(app, pilot) + wizard._edit_frontmatter() + await _settle(pilot) + editor = app.screen + assert isinstance(editor, RuleTextScreen) + editor.query_one("#rule_text", TextArea).text = "status == 'done'" + await _settle(pilot, 4) + await pilot.press("escape") + await _settle(pilot) + wizard._open_filters() + await _settle(pilot) + browser = app.screen + assert isinstance(browser, FilterBrowserScreen) + shown = browser._spec.frontmatter + assert shown == "status == 'done'" + + +@pytest.mark.asyncio +async def test_index_filters_before_a_path_save_with_the_collection( + config_file: Path, tmp_index_dir: Path, tmp_path: Path +) -> None: + """No path yet means no sample, not a crash; the filters still save.""" + research = tmp_path / "research" + research.mkdir() + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + async with app.run_test(size=(120, 40)) as pilot: + await _settle(pilot, 5) + wizard = await _wizard(app, pilot) + wizard._open_filters() + await _settle(pilot) + browser: Any = app.screen + browser._spec = dataclasses.replace(browser._spec, excludes=tuple(NODE), kinds=("md",)) + await pilot.press("escape") + await _settle(pilot) + wizard._fields["name"] = "research" + wizard._fields["path"] = str(research) + await pilot.press("ctrl+s") + await _settle(pilot) + source = load(config_file).collections["research"].sources[0] + assert source.filters is not None + assert source.filters.excludes == NODE + assert source.filters.kinds == ["md"] + + +@pytest.mark.asyncio +async def test_an_untouched_new_collection_inherits_the_master_excludes( + config_file: Path, tmp_index_dir: Path, tmp_path: Path +) -> None: + """Nothing set in the wizard records nothing: the source follows the master list.""" + from fnd.config import write_settings + + write_settings(config_path=config_file, values={"defaults.filters.excludes": NODE}) + research = tmp_path / "research" + research.mkdir() + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + async with app.run_test(size=(120, 40)) as pilot: + await _settle(pilot, 5) + wizard = await _wizard(app, pilot) + wizard._fields["name"] = "research" + wizard._fields["path"] = str(research) + await pilot.press("ctrl+s") + await _settle(pilot) + source = load(config_file).collections["research"].sources[0] + assert source.filters is None or source.filters.excludes is None + assert source.excludes == NODE diff --git a/tests/test_cli_collection_add.py b/tests/test_cli_collection_add.py index 343db27f..8dcf145d 100644 --- a/tests/test_cli_collection_add.py +++ b/tests/test_cli_collection_add.py @@ -195,3 +195,21 @@ def test_no_message_points_at_a_command_that_does_not_exist() -> None: assert "status --errors" not in inspect.getsource(walk) assert CliRunner().invoke(app, ["status", "--errors"]).exit_code != 0 + + +def test_collection_add_writes_excludes_into_the_sources_filters( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """The CLI writes the current place, so no later migration has to move it.""" + import tomllib + + runner, cfg_path = _runner_with_config(monkeypatch, tmp_path) + notes = tmp_path / "n" + notes.mkdir() + result = runner.invoke( + app, ["collection", "add", "c", "--source", str(notes), "--exclude", "**/.trash/**"] + ) + assert result.exit_code == 0, result.output + source = tomllib.loads(cfg_path.read_text(encoding="utf-8"))["collections"]["c"]["sources"][0] + assert "excludes" not in source + assert source["filters"]["excludes"] == ["**/.trash/**"] diff --git a/tests/test_clone_source.py b/tests/test_clone_source.py index 085bd574..514eb939 100644 --- a/tests/test_clone_source.py +++ b/tests/test_clone_source.py @@ -28,10 +28,10 @@ def _setup_two_collections(tmp_path: Path) -> Path: src = SourceConfig( path=tmp_path / "notes", includes=["**/*.md", "**/*.txt"], - excludes=["**/.git/**", "**/drafts/**"], follow_symlinks=True, frontmatter_filter="type == 'note'", filters=SourceFilters( + excludes=["**/.git/**", "**/drafts/**"], respect_gitignore=False, exclude_tags=["no_index", "wip"], kinds=["md"], diff --git a/tests/test_config_migration_v3.py b/tests/test_config_migration_v3.py new file mode 100644 index 00000000..2907ce46 --- /dev/null +++ b/tests/test_config_migration_v3.py @@ -0,0 +1,72 @@ +"""Config v3 moves each source's excludes into its index filters.""" + +from __future__ import annotations + +from typing import Any + +from fnd.config_migrations import CONFIG_VERSION, migrate + + +def _v2(**source: Any) -> dict[str, Any]: + return {"config_version": 2, "collections": {"notes": {"sources": [{"path": "~/n", **source}]}}} + + +def _source(raw: dict[str, Any]) -> dict[str, Any]: + return raw["collections"]["notes"]["sources"][0] + + +def test_a_source_list_moves_into_its_filters() -> None: + """The same globs, in the place every other filter lives.""" + raw = _v2(excludes=["build/**"]) + migrate(raw) + assert "excludes" not in _source(raw) + assert _source(raw)["filters"]["excludes"] == ["build/**"] + + +def test_an_empty_list_is_dropped_so_the_source_inherits() -> None: + """An empty list was never a choice to exclude nothing; it inherits.""" + raw = _v2(excludes=[]) + migrate(raw) + assert "excludes" not in _source(raw) + assert "excludes" not in _source(raw).get("filters", {}) + + +def test_the_old_wizard_default_is_dropped_so_the_source_follows_the_master() -> None: + """The System files globs, in any order, are the master's default now.""" + raw = _v2(excludes=["**/.git/**", "**/desktop.ini", "**/Thumbs.db", "**/.DS_Store", "**/.*"]) + migrate(raw) + assert "excludes" not in _source(raw) + assert "excludes" not in _source(raw).get("filters", {}) + + +def test_an_existing_filters_list_is_kept() -> None: + """The current place is never overwritten by the legacy one.""" + raw = _v2(excludes=["old/**"], filters={"excludes": ["new/**"]}) + migrate(raw) + assert _source(raw)["filters"]["excludes"] == ["new/**"] + assert "excludes" not in _source(raw) + + +def test_the_step_is_reported() -> None: + """The user is told what the upgrade did.""" + version, applied = migrate(_v2(excludes=["a/**"])) + assert version == CONFIG_VERSION == 3 + assert any("excludes" in note for note in applied) + + +def test_a_released_config_with_the_old_wizard_default_follows_the_master() -> None: + """No version means v2 respells the globs first; the respelled default still inherits.""" + wizard = ["**/.*", "**/.DS_Store", "**/Thumbs.db", "**/desktop.ini", "**/.git/**"] + raw: dict[str, Any] = { + "collections": {"notes": {"sources": [{"path": "~/n", "excludes": wizard}]}} + } + migrate(raw) + assert "excludes" not in _source(raw) + assert "excludes" not in _source(raw).get("filters", {}) + + +def test_a_single_string_is_one_glob() -> None: + """A hand-written string is one glob, never one per character.""" + raw = _v2(excludes="**/node_modules/**") + migrate(raw) + assert _source(raw)["filters"]["excludes"] == ["**/node_modules/**"] diff --git a/tests/test_config_render.py b/tests/test_config_render.py index 8332e33b..44554344 100644 --- a/tests/test_config_render.py +++ b/tests/test_config_render.py @@ -56,10 +56,11 @@ def _sample() -> conf.Config: sources=[ conf.SourceConfig( path=Path("~/Notes"), - excludes=["**/.git/**"], app="obsidian", app_params={"vault": "Main"}, - filters=conf.SourceFilters(kinds=["md"], max_size=50_000_000), + filters=conf.SourceFilters( + excludes=["**/.git/**"], kinds=["md"], max_size=50_000_000 + ), ) ] ), @@ -87,6 +88,7 @@ def _maximal() -> conf.Config: filters: dict[str, Any] = { "respect_gitignore": False, "respect_fndignore": False, + "excludes": ["**/node_modules/**"], "include_tags": ["keep"], "exclude_tags": ["drop"], "kinds": ["md"], @@ -133,7 +135,6 @@ def _maximal() -> conf.Config: conf.SourceConfig( path=Path("~/Notes"), includes=["**/*.md"], - excludes=["**/.git/**"], follow_symlinks=True, # `clears` exists only on a source, so the shared # `filters` dict cannot carry it. diff --git a/tests/test_config_sources.py b/tests/test_config_sources.py index 379474b9..4b08cd2e 100644 --- a/tests/test_config_sources.py +++ b/tests/test_config_sources.py @@ -8,7 +8,7 @@ import pytest from pydantic import ValidationError -from fnd.config import SourceConfig, load +from fnd.config import DefaultFilters, SourceConfig, load def _write_config(path: Path, text: str) -> Path: @@ -108,7 +108,7 @@ def test_paths_tilde_expanded(tmp_path: Path) -> None: assert "~" not in str(s.path) -def test_default_includes_excludes_empty_when_omitted(tmp_path: Path) -> None: +def test_omitted_includes_are_empty_and_excludes_inherit(tmp_path: Path) -> None: p = _write_config( tmp_path / "c.toml", """ @@ -118,5 +118,5 @@ def test_default_includes_excludes_empty_when_omitted(tmp_path: Path) -> None: ) s = load(p).collection("x").sources[0] assert s.includes == [] - assert s.excludes == [] + assert s.excludes == DefaultFilters().excludes assert s.follow_symlinks is False diff --git a/tests/test_editing_roles.py b/tests/test_editing_roles.py index 8197646f..d16ec26a 100644 --- a/tests/test_editing_roles.py +++ b/tests/test_editing_roles.py @@ -270,8 +270,8 @@ async def test_a_multi_picker_applies_each_toggle( def test_no_picker_binds_a_save() -> None: """A ^s on a picker would say Esc does not keep the choice.""" - from fnd.tui.settings_screen import PickerScreen, TreePickerScreen + from fnd.tui.settings_screen import PickerScreen - for cls in (PickerScreen, TreePickerScreen): + for cls in (PickerScreen,): keys = {k for b in cls.BINDINGS for k in str(getattr(b, "key", "")).split(",")} assert "ctrl+s" not in keys, cls.__name__ diff --git a/tests/test_every_editing_screen_speaks_one_language.py b/tests/test_every_editing_screen_speaks_one_language.py index 5216378e..37b7bd84 100644 --- a/tests/test_every_editing_screen_speaks_one_language.py +++ b/tests/test_every_editing_screen_speaks_one_language.py @@ -15,7 +15,7 @@ _ROOT = Path(__file__).resolve().parent.parent / "fnd" / "tui" #: A base whose subclasses take the role of what opens them; never pushed itself. -_BASES = frozenset({"FilterBrowserScreen"}) +_BASES = frozenset({"FilterBrowserScreen", "_SourceFields"}) def _screens() -> list[type[Screen[object]]]: diff --git a/tests/test_excluded_paths_branch.py b/tests/test_excluded_paths_branch.py new file mode 100644 index 00000000..d9c8916d --- /dev/null +++ b/tests/test_excluded_paths_branch.py @@ -0,0 +1,301 @@ +"""Excluded paths live on the Index filters screen: presets to tick, globs to type.""" + +from __future__ import annotations + +import dataclasses +from pathlib import Path +from typing import Any + +import pytest +from textual.widgets import TextArea + +from fnd.config import EXCLUDES_PRESETS +from fnd.filters import FilterSpec +from fnd.filters.tree_model import apply_selection, selection_for, spec_branches +from fnd.tui import FNDApp + +NODE = EXCLUDES_PRESETS["node_modules"]["globs"] +HIDDEN = EXCLUDES_PRESETS["hidden"]["globs"] + + +def _branch(spec: FilterSpec, branch_id: str) -> Any: + return next(b for b in spec_branches(spec) if b.id == branch_id) + + +def test_the_branch_offers_every_preset() -> None: + """One row per preset, after the ignore files.""" + ids = [b.id for b in spec_branches(FilterSpec())] + assert ids.index("excludes") == ids.index("ignore") + 1 + items = [i[0] for i in _branch(FilterSpec(), "excludes").items] + assert items == [f"exclude:{key}" for key in EXCLUDES_PRESETS] + + +def test_a_preset_is_ticked_when_all_its_globs_are_excluded() -> None: + """The form's old rule: a preset is on when every glob it ships is present.""" + selected, _excluded = selection_for(FilterSpec(excludes=(*NODE, "x/**"))) + assert "exclude:node_modules" in selected + assert "exclude:hidden" not in selected + + +def test_ticking_presets_keeps_typed_globs() -> None: + """Toggling a preset never drops a glob typed by hand outside it; the new globs follow.""" + spec = FilterSpec(excludes=("x/**",)) + selected, excluded = selection_for(spec) + updated, _git, _fnd = apply_selection( + spec, selected | {"exclude:hidden", "exclude:node_modules"}, excluded + ) + assert updated.excludes == ("x/**", *HIDDEN, *NODE) + + +def test_a_change_elsewhere_leaves_the_list_as_written() -> None: + """Rewriting an inherited list in registry order would save it as the source's own.""" + written = FilterSpec(excludes=("archive/**", *NODE, *HIDDEN)) + selected, excluded = selection_for(written) + ticked, _g, _f = apply_selection(written, selected | {"kind:md"}, excluded) + back, _g, _f = apply_selection(ticked, selected, excluded) + assert ticked.excludes == written.excludes + assert back == written + + +def test_unticking_a_preset_keeps_the_order_written() -> None: + """Only the unticked preset's globs leave; the rest stay where the user put them.""" + written = FilterSpec(excludes=("b/**", *NODE, "a/**", *HIDDEN)) + selected, excluded = selection_for(written) + updated, _g, _f = apply_selection(written, selected - {"exclude:node_modules"}, excluded) + assert updated.excludes == ("b/**", "a/**", *HIDDEN) + + +def test_unticking_a_preset_removes_only_its_globs() -> None: + """The other excludes stay.""" + spec = FilterSpec(excludes=(*NODE, "x/**")) + selected, excluded = selection_for(spec) + updated, _git, _fnd = apply_selection(spec, selected - {"exclude:node_modules"}, excluded) + assert updated.excludes == ("x/**",) + + +def test_typed_globs_are_a_rule_you_type() -> None: + """Globs beyond the presets are typed, beside the frontmatter and custom rules.""" + rules = _branch(FilterSpec(excludes=(*NODE, "x/**", "y/**")), "rules") + row = next(i for i in rules.items if i[0] == "rule:excludes") + assert row[1] == "Excluded globs x/**, y/**" + + +def test_the_text_view_never_shows_excludes() -> None: + """They prune folders before a file is read, which no expression can say.""" + from fnd.filters.text_form import render + + assert render(FilterSpec(excludes=("x/**",))) == "" + + +@pytest.fixture +def config_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + from fnd.config import CollectionConfig, SourceConfig, write_collection + + cfg_path = tmp_path / "config.toml" + (tmp_path / "notes").mkdir() + monkeypatch.setattr("fnd.config.default_config_path", lambda: cfg_path) + write_collection( + config_path=cfg_path, + name="notes", + collection=CollectionConfig(sources=[SourceConfig(path=tmp_path / "notes")]), + ) + return cfg_path + + +async def _settle(pilot: Any, n: int = 15) -> None: + for _ in range(n): + await pilot.pause() + + +async def _global_filters(app: FNDApp, pilot: Any) -> Any: + from fnd.tui.menu import _open_filter_browser + + _open_filter_browser(app) + await _settle(pilot) + return app.screen + + +@pytest.mark.asyncio +async def test_the_text_view_round_trip_keeps_excludes( + config_file: Path, tmp_index_dir: Path +) -> None: + """Editing the expression as text never drops the excluded paths.""" + from fnd.config import load + + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + async with app.run_test(size=(120, 34)) as pilot: + await _settle(pilot, 5) + browser = await _global_filters(app, pilot) + browser._spec = dataclasses.replace(browser._spec, excludes=("x/**",)) + browser.action_edit_text() + await _settle(pilot) + app.screen.query_one("#filter_text", TextArea).text = "file.size <= 1000" + await _settle(pilot, 4) + await pilot.press("escape") + await _settle(pilot) + kept = browser._spec.excludes + assert kept == ("x/**",) + + +@pytest.mark.asyncio +async def test_a_global_save_writes_the_master_excludes( + config_file: Path, tmp_index_dir: Path +) -> None: + """Ticking a preset on the global screen and saving writes [defaults.filters] excludes.""" + from fnd.config import load + + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + async with app.run_test(size=(120, 34)) as pilot: + await _settle(pilot, 5) + browser = await _global_filters(app, pilot) + browser._spec = dataclasses.replace(browser._spec, excludes=tuple(NODE)) + await pilot.press("ctrl+s") + await _settle(pilot) + assert load(config_file).defaults.filters.excludes == NODE + + +@pytest.mark.asyncio +async def test_typed_globs_hold_on_an_invalid_one_then_cancel( + config_file: Path, tmp_index_dir: Path +) -> None: + """The glob editor is a part: an invalid glob holds the user, a second Esc abandons it.""" + from fnd.config import load + from fnd.tui.settings_screen import GlobTextScreen + + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + got: list[tuple[str, ...]] = [] + async with app.run_test(size=(120, 34)) as pilot: + await _settle(pilot, 5) + app.push_screen(GlobTextScreen(title="Excluded globs", value=(), on_save=got.append)) + await _settle(pilot) + editor = app.screen + editor.query_one("#glob_text", TextArea).text = "a/**, b/[z-a]" + await _settle(pilot, 4) + await pilot.press("escape") + await _settle(pilot, 4) + held = app.screen is editor + await pilot.press("escape") + await _settle(pilot, 4) + gone = app.screen is not editor + assert held + assert gone + assert got == [] + + +@pytest.mark.asyncio +async def test_typed_globs_carry_back_on_esc(config_file: Path, tmp_index_dir: Path) -> None: + """Valid globs hand back as a tuple, trimmed, without empties.""" + from fnd.config import load + from fnd.tui.settings_screen import GlobTextScreen + + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + got: list[tuple[str, ...]] = [] + async with app.run_test(size=(120, 34)) as pilot: + await _settle(pilot, 5) + app.push_screen(GlobTextScreen(title="Excluded globs", value=(), on_save=got.append)) + await _settle(pilot) + app.screen.query_one("#glob_text", TextArea).text = " a/** ,, b/*.tmp " + await _settle(pilot, 4) + await pilot.press("escape") + await _settle(pilot, 4) + assert got == [("a/**", "b/*.tmp")] + + +@pytest.mark.asyncio +async def test_the_source_form_edits_excludes_in_its_index_filters( + config_file: Path, tmp_index_dir: Path +) -> None: + """No Excludes row on the form; a preset ticked in Index filters saves as the source's own.""" + from fnd.config import load + from fnd.tui.settings_screen import SettingsList, SourceFormScreen + + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + async with app.run_test(size=(120, 34)) as pilot: + await _settle(pilot, 5) + app.push_screen(SourceFormScreen(collection_name="notes", source_index=0)) + await _settle(pilot) + form: Any = app.screen + rows = [i.id for i in form.query_one(SettingsList)._items] + form._open_filters() + await _settle(pilot) + browser: Any = app.screen + browser._spec = dataclasses.replace(browser._spec, excludes=tuple(NODE)) + await pilot.press("escape") + await _settle(pilot) + await pilot.press("ctrl+s") + await _settle(pilot) + assert "form.excludes" not in rows + source = load(config_file).collections["notes"].sources[0] + assert source.filters is not None + assert source.filters.excludes == NODE + + +def test_the_branch_has_its_own_legend() -> None: + """Ticked here means skipped, so the shared "index ONLY these" line would be false.""" + from fnd.filters.tree_model import EXCLUDES_LEGEND + + assert _branch(FilterSpec(), "excludes").legend == EXCLUDES_LEGEND + assert "never read" in EXCLUDES_LEGEND + + +def test_the_filters_hints_name_excluded_paths() -> None: + """Both rows into the screen list what is on it.""" + from fnd.tui.menu import SECTION_FILTERS, _provider_index_filters, _provider_root + + app: Any = None + root = [i for i in _provider_root(app) if i.id == f"root.{SECTION_FILTERS}"] + rows = [*_provider_index_filters(app), *root] + assert len(rows) == 2 + assert all("excluded paths" in (r.description or "") for r in rows) + + +@pytest.mark.asyncio +async def test_the_excluded_globs_row_keeps_the_ticked_presets( + config_file: Path, tmp_index_dir: Path +) -> None: + """Typing globs on the row replaces the typed ones only.""" + from types import SimpleNamespace + + from fnd.config import load + + app = FNDApp(index_dir=tmp_index_dir, config=load(config_file)) + async with app.run_test(size=(120, 34)) as pilot: + await _settle(pilot, 5) + browser = await _global_filters(app, pilot) + browser._spec = dataclasses.replace(browser._spec, excludes=(*NODE, "old/**")) + browser._on_rule_selected(SimpleNamespace(item_id="rule:excludes")) + await _settle(pilot) + app.screen.query_one("#glob_text", TextArea).text = "new/**" + await _settle(pilot, 4) + await pilot.press("escape") + await _settle(pilot) + saved = browser._spec.excludes + assert saved == (*NODE, "new/**") + + +def test_the_global_summary_names_excluded_paths() -> None: + """A master list in force shows on the Index filters row.""" + from types import SimpleNamespace + + from fnd.config import DefaultFilters + from fnd.tui.menu import _summary_index_filters + + app: Any = SimpleNamespace( + _config=SimpleNamespace(defaults=SimpleNamespace(filters=DefaultFilters(excludes=NODE))) + ) + assert "excluded paths" in _summary_index_filters(app) + + +def test_the_branch_names_presets_as_the_screen_does() -> None: + """A collapsed branch reads "(System files)", never the config key "hidden".""" + keys = [i[2] for i in _branch(FilterSpec(), "excludes").items] + assert keys[:2] == ["System files", "Node modules"] + + +def test_returning_to_defaults_names_a_dropped_excludes_override() -> None: + """The master is never empty now, so "dropped" means changed, not emptied.""" + from fnd.tui.settings_screen import _cleared_note + + note = _cleared_note(FilterSpec(excludes=tuple(NODE)), FilterSpec(excludes=tuple(HIDDEN))) + assert note != "Nothing to return" + assert "excluded paths" in note diff --git a/tests/test_excludes_are_a_filter.py b/tests/test_excludes_are_a_filter.py new file mode 100644 index 00000000..c92f98ba --- /dev/null +++ b/tests/test_excludes_are_a_filter.py @@ -0,0 +1,225 @@ +"""Excludes are an index filter: a master list every source inherits, overridden per source.""" + +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Any + +from fnd.config import ( + CollectionConfig, + Config, + DefaultFilters, + Defaults, + SourceConfig, + SourceFilters, + resolve_filters, +) + +SYSTEM = ["**/.*", "**/.DS_Store", "**/Thumbs.db", "**/desktop.ini", "**/.git/**"] + + +def test_the_master_list_starts_with_system_files() -> None: + """What the old Add collection pre-ticked is now every source's default.""" + from fnd.config import EXCLUDES_PRESETS + + assert DefaultFilters().excludes == EXCLUDES_PRESETS["hidden"]["globs"] == SYSTEM + + +def test_a_legacy_system_files_list_follows_the_master(tmp_path: Path) -> None: + """The old wizard's default, in any order, is the master's: the source inherits it.""" + source = SourceConfig.model_validate({"path": tmp_path, "excludes": SYSTEM[::-1]}) + assert source.filters is None or source.filters.excludes is None + assert source.excludes == SYSTEM + + +def test_a_sources_own_list_replaces_the_master() -> None: + """Per-field override, as every filter: a source can drop an inherited exclude.""" + merged = resolve_filters(SourceFilters(excludes=["b/**"]), DefaultFilters(excludes=["a/**"])) + assert merged.excludes == ["b/**"] + + +def test_an_unset_list_inherits_the_master() -> None: + """None means inherit.""" + merged = resolve_filters(SourceFilters(), DefaultFilters(excludes=["a/**"])) + assert merged.excludes == ["a/**"] + + +def test_a_source_reads_its_effective_excludes(tmp_path: Path) -> None: + """Readers keep one name: the source's excludes are its resolved list.""" + cfg = Config( + defaults=Defaults(filters=DefaultFilters(excludes=["a/**"])), + collections={"notes": CollectionConfig(sources=[SourceConfig(path=tmp_path)])}, + ) + assert cfg.collections["notes"].sources[0].excludes == ["a/**"] + + +def test_a_legacy_source_level_list_folds_into_its_filters(tmp_path: Path) -> None: + """A hand-written `excludes = [...]` on a source still loads, as an override.""" + source = SourceConfig.model_validate({"path": tmp_path, "excludes": ["x/**"]}) + assert source.filters is not None + assert source.filters.excludes == ["x/**"] + + +def test_an_explicit_filters_list_wins_over_a_legacy_one(tmp_path: Path) -> None: + """The current place is never overwritten by the legacy one.""" + source = SourceConfig.model_validate( + {"path": tmp_path, "excludes": ["old/**"], "filters": {"excludes": ["new/**"]}} + ) + assert source.filters is not None + assert source.filters.excludes == ["new/**"] + + +def test_an_empty_legacy_list_inherits(tmp_path: Path) -> None: + """An empty source list was never a choice to exclude nothing; it inherits.""" + source = SourceConfig.model_validate({"path": tmp_path, "excludes": []}) + assert source.filters is None or source.filters.excludes is None + + +def test_a_legacy_flat_collection_promotes_its_excludes(tmp_path: Path) -> None: + """The old collection-level shape lands in the source's filters.""" + col = CollectionConfig.model_validate({"roots": [str(tmp_path)], "excludes": ["build/**"]}) + filters = col.sources[0].filters + assert filters is not None + assert filters.excludes == ["build/**"] + + +def _walked(cfg: Config) -> set[str]: + from fnd.walk import walk_sources + + col = cfg.collections["notes"] + root = col.sources[0].path.resolve() + return {p.relative_to(root).as_posix() for p in walk_sources(sources=col.sources)} + + +def _tree(tmp_path: Path) -> Path: + root = tmp_path / "notes" + (root / "skip").mkdir(parents=True) + (root / "keep.md").write_text("k\n", encoding="utf-8") + (root / "skip" / "gone.md").write_text("g\n", encoding="utf-8") + return root + + +def test_the_walk_obeys_the_master_list(tmp_path: Path) -> None: + """A source that sets nothing inherits the master excludes.""" + root = _tree(tmp_path) + cfg = Config( + defaults=Defaults(filters=DefaultFilters(excludes=["skip/**"])), + collections={"notes": CollectionConfig(sources=[SourceConfig(path=root)])}, + ) + assert _walked(cfg) == {"keep.md"} + + +def test_a_sources_empty_list_overrides_the_master(tmp_path: Path) -> None: + """Replace semantics: a source can choose to exclude nothing.""" + root = _tree(tmp_path) + cfg = Config( + defaults=Defaults(filters=DefaultFilters(excludes=["skip/**"])), + collections={ + "notes": CollectionConfig( + sources=[SourceConfig(path=root, filters=SourceFilters(excludes=[]))] + ) + }, + ) + assert _walked(cfg) == {"keep.md", "skip/gone.md"} + + +def _cfg_with(tmp_path: Path, filters: SourceFilters | None = None) -> Config: + (tmp_path / "notes").mkdir(exist_ok=True) + return Config( + collections={ + "notes": CollectionConfig( + sources=[SourceConfig(path=tmp_path / "notes", filters=filters)] + ) + } + ) + + +def test_an_excludes_change_reads_as_index_filters(tmp_path: Path) -> None: + """Excludes live in Index filters, so the reason names that.""" + from fnd.index_freshness import compare, indexed_with + + before = _cfg_with(tmp_path) + after = _cfg_with(tmp_path, SourceFilters(excludes=["x/**"])) + now = indexed_with(after.collections["notes"], after.defaults) + then = indexed_with(before.collections["notes"], before.defaults) + assert compare(now, then).reasons == ("Index filters",) + + +def _record_from_before_the_move(cfg: Config, legacy: list[str]) -> dict[str, Any]: + from fnd.index_freshness import indexed_with + + old = copy.deepcopy(indexed_with(cfg.collections["notes"], cfg.defaults)) + for source in old["selection"]["sources"]: + source["filters"].pop("excludes") + source["excludes"] = legacy + return old + + +def test_a_record_from_before_the_move_still_reads_current(tmp_path: Path) -> None: + """Same globs, old place: current. So is the old wizard's default, in any order.""" + from fnd.index_freshness import State, compare, indexed_with + + cfg = _cfg_with(tmp_path, SourceFilters(excludes=["x/**"])) + now = indexed_with(cfg.collections["notes"], cfg.defaults) + assert compare(now, _record_from_before_the_move(cfg, ["x/**"])).state is State.CURRENT + + bare = _cfg_with(tmp_path) + now_bare = indexed_with(bare.collections["notes"], bare.defaults) + old = _record_from_before_the_move(bare, SYSTEM[::-1]) + assert compare(now_bare, old).state is State.CURRENT + + +def test_a_source_that_excluded_nothing_reads_outdated(tmp_path: Path) -> None: + """It now skips desktop.ini and Thumbs.db, so saying current would be false.""" + from fnd.index_freshness import State, compare, indexed_with + + bare = _cfg_with(tmp_path) + now = indexed_with(bare.collections["notes"], bare.defaults) + verdict = compare(now, _record_from_before_the_move(bare, [])) + assert verdict.state is State.NEEDS_UPDATE + assert verdict.reasons == ("Index filters",) + + +def test_a_legacy_flat_collection_without_excludes_inherits_the_master(tmp_path: Path) -> None: + """No excludes in the old shape is no override: the master list still applies.""" + cfg = Config.model_validate( + { + "defaults": {"filters": {"excludes": ["a/**"]}}, + "collections": {"notes": {"roots": [str(tmp_path)]}}, + } + ) + assert cfg.collections["notes"].sources[0].excludes == ["a/**"] + + +def test_a_folder_glob_covers_its_folder_only() -> None: + """`skip/**` excludes everything under skip; `skip/*.md` only some of it.""" + from fnd.globs import GlobSet + + assert GlobSet.parse(["skip/**"]).covers_dir("skip") + assert GlobSet.parse(["**/node_modules/**"]).covers_dir("a/node_modules") + assert not GlobSet.parse(["skip/*.md"]).covers_dir("skip") + assert not GlobSet.parse(["skip/**"]).covers_dir("skipped") + + +def test_an_excluded_folder_is_never_opened(tmp_path: Path, monkeypatch: Any) -> None: + """The walk prunes it at descent instead of reading every file below.""" + import os + + import fnd.walk + + root = _tree(tmp_path) + opened: list[str] = [] + real = os.scandir + + def spy(path: Any) -> Any: + opened.append(Path(path).name) + return real(path) + + monkeypatch.setattr(fnd.walk.os, "scandir", spy) + cfg = Config( + defaults=Defaults(filters=DefaultFilters(excludes=["skip/**"])), + collections={"notes": CollectionConfig(sources=[SourceConfig(path=root)])}, + ) + assert _walked(cfg) == {"keep.md"} + assert "skip" not in opened diff --git a/tests/test_filters_coverage.py b/tests/test_filters_coverage.py index 9499a062..406259bd 100644 --- a/tests/test_filters_coverage.py +++ b/tests/test_filters_coverage.py @@ -37,10 +37,11 @@ "modified_before": (dt.date(2030, 6, 1), dt.date(2031, 6, 1)), "frontmatter": ("Course == 'A'", "Course == 'B'"), "expression": ("file.size > 1", "file.size > 2"), + "excludes": (["a/**"], ["b/**"]), } #: Fields whose empty value means "override to nothing", not "unset". -EMPTIABLE = ("include_tags", "exclude_tags", "kinds") +EMPTIABLE = ("include_tags", "exclude_tags", "kinds", "excludes") class TestEveryFieldResolves: @@ -144,6 +145,7 @@ def _corpus(root: Path) -> None: "created_before": (dt.date(2100, 1, 1), None), "frontmatter": ("Course == 'A'", "other.md"), "expression": ("file.name != 'other.md'", "other.md"), + "excludes": (["**/other.md"], "other.md"), } @@ -269,6 +271,7 @@ def test_order_of_application_does_not_matter(self, seed: int, tmp_path: Path) - "modified_after": dt.date(1990, 1, 1), "frontmatter": "", "expression": "", + "excludes": [], } @@ -398,7 +401,7 @@ def test_clearing_to_an_empty_set_would_have_readmitted_it(self, tmp_path: Path) from fnd.filters import FilterSpec self._corpus(tmp_path) - defaults = DefaultFilters(exclude_tags=["no_index"]) + defaults = DefaultFilters(exclude_tags=["no_index"], excludes=[]) recorded = self._recorded(FilterSpec(), defaults) assert recorded == {"exclude_tags": []} assert "private.md" in _walked(tmp_path, defaults, _source_filters_or_none(recorded)) diff --git a/tests/test_index_freshness.py b/tests/test_index_freshness.py index 821083b3..ec3f5151 100644 --- a/tests/test_index_freshness.py +++ b/tests/test_index_freshness.py @@ -40,7 +40,8 @@ def test_an_exclude_needs_an_update_and_names_it(tmp_path: Path) -> None: before = _now(_config(tmp_path)) verdict = compare(_now(_config(tmp_path, excludes=["build/**"])), before) assert verdict.state is State.NEEDS_UPDATE - assert verdict.reasons == ("Excludes",) + # Excludes are an index filter, so that is the setting named. + assert verdict.reasons == ("Index filters",) def test_an_index_filter_needs_an_update(tmp_path: Path) -> None: @@ -116,7 +117,7 @@ def test_a_summary_names_the_state_and_the_reasons(tmp_path: Path) -> None: before = _now(_config(tmp_path)) after = _now(_config(tmp_path, excludes=["b/**"], follow_symlinks=True)) assert compare(after, before).summary == ( - "Outdated: Excludes and Follow symlinks changed since the last index" + "Outdated: Follow symlinks and Index filters changed since the last index" ) diff --git a/tests/test_leaving_a_picker_needs_no_new_gesture.py b/tests/test_leaving_a_picker_needs_no_new_gesture.py deleted file mode 100644 index 72d675f3..00000000 --- a/tests/test_leaving_a_picker_needs_no_new_gesture.py +++ /dev/null @@ -1,83 +0,0 @@ -"""The gestures that leave a settings editor work on every settings editor. - -The tree picker applies each toggle, so leaving is all there is to do: Esc and -←, learnt on every other screen, leave it, and no save key is offered. -""" - -from __future__ import annotations - -from pathlib import Path - -import pytest - -from fnd.tui import FNDApp -from fnd.tui.settings_screen import TreePickerScreen -from fnd.tui.widgets import COMMIT_KEY -from fnd.tui.widgets.toggle_tree import ToggleGroup, ToggleItem, ToggleTree - - -def _item(saved: list[list[str]]) -> object: - from fnd.tui.menu import MenuItem - - group = ToggleGroup( - "sources", - "Tag sources", - (ToggleItem("os", "System tags"), ToggleItem("frontmatter", "Note tags")), - ) - return MenuItem( - id="tag_sources", - label="Tag sources", - groups_provider=lambda _app: [group], - picker_getter=lambda _app: ["os"], - picker_setter=lambda _app, values: saved.append(list(values)), - ) - - -async def _picker(app: FNDApp, pilot: object) -> list[list[str]]: - saved: list[list[str]] = [] - app.push_screen(TreePickerScreen(_item(saved))) # type: ignore[arg-type] - for _ in range(12): - await pilot.pause() # type: ignore[attr-defined] - return saved - - -@pytest.mark.parametrize("key", ["escape", "left"]) -@pytest.mark.asyncio -async def test_every_leaving_gesture_leaves(tmp_index_dir: Path, key: str) -> None: - app = FNDApp(index_dir=tmp_index_dir) - async with app.run_test(size=(90, 24)) as pilot: - await pilot.pause() - await _picker(app, pilot) - assert isinstance(app.screen, TreePickerScreen), "the premise" - app.screen.query_one("#tree_picker", ToggleTree).focus() - for _ in range(4): - await pilot.pause() - if key == "left": - # ← collapses first and leaves from the outermost level, which is - # what it does in the filter browser too. - await pilot.press("left") - for _ in range(6): - await pilot.pause() - await pilot.press(key) - for _ in range(10): - await pilot.pause() - left = app.screen.__class__ is not TreePickerScreen - - assert left, f"{key!r} did nothing on the picker" - - -@pytest.mark.asyncio -async def test_the_footer_names_the_keys_that_work(tmp_index_dir: Path) -> None: - """A key that works unadvertised is the mirror of one advertised that - does not.""" - app = FNDApp(index_dir=tmp_index_dir) - async with app.run_test(size=(90, 24)) as pilot: - await pilot.pause() - await _picker(app, pilot) - on_screen = "\n".join( - "".join(s.text for s in strip) for strip in app.screen._compositor.render_strips() - ) - - footer = on_screen.splitlines()[-1] - assert "Back" in footer, footer - assert COMMIT_KEY not in footer, footer diff --git a/tests/test_out_of_date_collections_are_shown.py b/tests/test_out_of_date_collections_are_shown.py index 7c26df2f..1a22210d 100644 --- a/tests/test_out_of_date_collections_are_shown.py +++ b/tests/test_out_of_date_collections_are_shown.py @@ -225,7 +225,7 @@ async def test_the_update_row_is_labelled_needed_with_its_reason( await _settle(pilot) row = next(i for i in _provider_collection(app, "notes") if i.id == "col.notes.reindex") assert row.label == "Update index" - assert row.description.startswith("Outdated: Excludes changed since the last index. ") + assert row.description.startswith("Outdated: Index filters changed since the last index. ") from fnd.tui.freshness_view import BADGE_STYLE from fnd.tui.settings_screen import _trailing_segments diff --git a/tests/test_q_quits_and_esc_goes_back.py b/tests/test_q_quits_and_esc_goes_back.py index 2058f753..5c387e63 100644 --- a/tests/test_q_quits_and_esc_goes_back.py +++ b/tests/test_q_quits_and_esc_goes_back.py @@ -88,7 +88,7 @@ async def test_q_on_a_dirty_form_asks_first(tmp_path: Path) -> None: await pilot.pause() form = app.screen assert isinstance(form, SourceFormScreen) - form._fields["excludes_custom"] = "build/**" + form._fields["includes_custom"] = "build/**" await pilot.press("q") await pilot.pause() asked = isinstance(app.screen, UnsavedChangesScreen) diff --git a/tests/test_reindex_prunes_removed_files.py b/tests/test_reindex_prunes_removed_files.py index 2b746f02..6bb5786d 100644 --- a/tests/test_reindex_prunes_removed_files.py +++ b/tests/test_reindex_prunes_removed_files.py @@ -20,7 +20,7 @@ import pytest -from fnd.config import CollectionConfig, SourceConfig +from fnd.config import CollectionConfig, SourceConfig, SourceFilters from fnd.index import build_index_from_config from fnd.index_runner import run_indexer from fnd.query import Searcher @@ -61,7 +61,11 @@ def test_reindex_drops_file_newly_excluded(tmp_path: Path, tmp_index_dir: Path) assert "wip.md" in _names(tmp_index_dir, "penguin", "notes") after = CollectionConfig( - sources=[SourceConfig(path=notes, includes=["**/*.md"], excludes=["drafts/**"])] + sources=[ + SourceConfig( + path=notes, includes=["**/*.md"], filters=SourceFilters(excludes=["drafts/**"]) + ) + ] ) build_index_from_config(config=after, collection="notes", index_dir=tmp_index_dir) @@ -166,7 +170,11 @@ def test_prune_from_one_collection_leaves_shared_file_in_the_other( # Drop it from alpha only, by narrowing alpha's globs. narrowed = CollectionConfig( - sources=[SourceConfig(path=notes, includes=["**/*.md"], excludes=["shared.md"])] + sources=[ + SourceConfig( + path=notes, includes=["**/*.md"], filters=SourceFilters(excludes=["shared.md"]) + ) + ] ) build_index_from_config(config=narrowed, collection="alpha", index_dir=tmp_index_dir) @@ -192,7 +200,11 @@ def test_pruned_pdf_keeps_texture_cache_entry_when_another_collection_holds_it( sha = sha256_file(pdf) alpha_narrowed = CollectionConfig( - sources=[SourceConfig(path=src, includes=["**/*.pdf"], excludes=["paper.pdf"])] + sources=[ + SourceConfig( + path=src, includes=["**/*.pdf"], filters=SourceFilters(excludes=["paper.pdf"]) + ) + ] ) beta = CollectionConfig(sources=[SourceConfig(path=src, includes=["**/*.pdf"])]) cfg = Config(collections={"alpha": alpha_narrowed, "beta": beta}) diff --git a/tests/test_saving_never_reindexes.py b/tests/test_saving_never_reindexes.py index 8e67bee7..3ff74b15 100644 --- a/tests/test_saving_never_reindexes.py +++ b/tests/test_saving_never_reindexes.py @@ -81,7 +81,7 @@ async def test_saving_a_source_starts_no_run( async with app.run_test(size=(110, 30)) as pilot: await _settle(pilot, 5) form = await _open_form(app, pilot) - form._fields["excludes_custom"] = "build/**" + form._fields["includes_custom"] = "build/**" await pilot.press("ctrl+s") await _settle(pilot, 8) left = not isinstance(app.screen, SourceFormScreen) @@ -100,7 +100,7 @@ async def test_the_save_toast_names_the_update_and_the_key( await _settle(pilot, 5) seen = _notices(app, monkeypatch) form = await _open_form(app, pilot) - form._fields["excludes_custom"] = "build/**" + form._fields["includes_custom"] = "build/**" await pilot.press("ctrl+s") await _settle(pilot, 8) assert any("is outdated" in m and "press u" in m for m in seen), seen @@ -156,7 +156,7 @@ async def test_deleting_over_unsaved_edits_says_they_go_too( async with app.run_test(size=(110, 30)) as pilot: await _settle(pilot, 5) form = await _open_form(app, pilot) - form._fields["excludes_custom"] = "build/**" + form._fields["includes_custom"] = "build/**" await pilot.press("ctrl+d") await _settle(pilot, 8) painted = "\n".join( diff --git a/tests/test_settings_hints_say_what_the_key_does.py b/tests/test_settings_hints_say_what_the_key_does.py index 1b6d0d76..7c2ac228 100644 --- a/tests/test_settings_hints_say_what_the_key_does.py +++ b/tests/test_settings_hints_say_what_the_key_does.py @@ -144,19 +144,20 @@ async def test_a_source_form_names_the_edit_bar_keys_while_it_is_open( async def test_the_wizard_names_tab_once_a_rule_gives_it_a_sample_to_test( built_index: Path, one_source: None ) -> None: - from textual.widgets import Input + from textual.widgets import TextArea - from fnd.tui.settings_screen import AddCollectionWizard, EditBar + from fnd.tui.settings_screen import AddCollectionWizard app = FNDApp(index_dir=built_index) async with app.run_test(size=(140, 45)) as pilot: wizard = await _open_form(pilot, app, AddCollectionWizard()) assert "Test a sample" not in _footer(wizard) - _put_cursor_on(wizard, "wiz.filter") - await pilot.press("enter") - await wait_until(pilot, lambda: _shows(wizard, "⏎", "Set")) - wizard.query_one(EditBar).query_one(Input).value = "draft == true" + _put_cursor_on(wizard, "wiz.frontmatter") await pilot.press("enter") + await wait_until(pilot, lambda: app.screen is not wizard) + app.screen.query_one("#rule_text", TextArea).text = "draft == true" + await pilot.press("escape") + await wait_until(pilot, lambda: app.screen is wizard) await wait_until(pilot, lambda: _shows(wizard, "Tab", "Test a sample")) await pilot.press("tab") await wait_until(pilot, lambda: _shows(wizard, "Tab", "Fields")) diff --git a/tests/test_settings_index_filters.py b/tests/test_settings_index_filters.py index 9de32afb..9e0a7aa5 100644 --- a/tests/test_settings_index_filters.py +++ b/tests/test_settings_index_filters.py @@ -8,6 +8,7 @@ import pytest +from fnd.config import SourceFilters from fnd.tui import FNDApp from fnd.tui.settings_screen import DefaultFiltersScreen, UnsavedChangesScreen from fnd.tui.widgets import COMMIT_KEY @@ -665,27 +666,6 @@ async def _separator(rule: str | None) -> tuple[bool, str]: assert "status == 'done'" in text, text -@pytest.mark.asyncio -async def test_unticking_custom_globs_keeps_them_on_offer(built_index: Path) -> None: - """Untick discarded typed globs to one keypress, with no undo.""" - from fnd.config import CollectionConfig, Config, SourceConfig - from fnd.tui.settings_screen import SourceFormScreen, _custom_seed - - config = Config(collections={"c": CollectionConfig(sources=[SourceConfig(path=Path("~/x"))])}) - app = FNDApp(index_dir=built_index, config=config) - async with app.run_test() as pilot: - await pilot.pause() - app.push_screen(SourceFormScreen(collection_name="c", source_index=0)) - for _ in range(20): - await pilot.pause() - form = app.screen - assert isinstance(form, SourceFormScreen) - form._fields["excludes_custom"] = "build/**, dist/**" - form._set_excludes([]) - assert form._fields["excludes_custom"] == "", "untick must stop applying them" - assert _custom_seed(form, "excludes_custom") == "build/**, dist/**" - - def test_every_settings_screen_styles_itself() -> None: """Textual selectors are type selectors and a widget's own CSS is scoped to it, so a screen borrowing another's CSS rendered with no chrome.""" @@ -772,7 +752,9 @@ async def test_the_wizard_refuses_an_invalid_rule_instead_of_crashing(built_inde await pilot.pause() wizard = app.screen assert isinstance(wizard, AddCollectionWizard) - wizard._fields.update({"name": "probe", "path": "~", "filter": "status =="}) + wizard._fields.update( + {"name": "probe", "path": "~", "filters": {"frontmatter": "status =="}} + ) wizard.action_save() for _ in range(8): await pilot.pause() @@ -879,18 +861,6 @@ def test_a_radio_group_keeps_one_option_selected() -> None: assert tree._selected == {"size:1mb"} -def test_the_pickers_name_what_they_hold() -> None: - """ "40 selected" is the ABSENCE of a type restriction, and a count never - showed the exclude globs anywhere in the UI.""" - from fnd.kinds import ALL_KIND_IDS - from fnd.tui.settings_screen import _excludes_summary - - assert _excludes_summary({}) == "(none)" - assert _excludes_summary({"excludes_custom": "build/**, dist/**"}) == "build/**, dist/**" - assert "**/*.csv" in _excludes_summary({"excludes_custom": "**/*.csv"}) - assert len(ALL_KIND_IDS) > 1, "the wizard summary below depends on there being many" - - @pytest.mark.asyncio async def test_only_the_global_route_offers_a_save(built_index: Path) -> None: """The defaults are a document with ^s; a source's filters are a part of its form.""" @@ -1176,7 +1146,11 @@ async def test_the_summary_names_the_excludes_too(built_index: Path) -> None: collections={ "c": CollectionConfig( sources=[ - SourceConfig(path=Path("~/x"), includes=["notes/**"], excludes=["**/*.csv"]) + SourceConfig( + path=Path("~/x"), + includes=["notes/**"], + filters=SourceFilters(excludes=["**/*.csv"]), + ) ] ) } @@ -1716,7 +1690,7 @@ async def test_the_wizard_neither_offers_nor_focuses_it(self, built_index: Path) await pilot.pause() assert type(app.focused).__name__ != "TextArea", "focus entered a hidden pane" - wizard._fields["filter"] = "status == 'done'" + wizard._fields["filters"] = {"frontmatter": "status == 'done'"} wizard._populate_fields() for _ in range(8): await pilot.pause() diff --git a/tests/test_settings_p3_visual.py b/tests/test_settings_p3_visual.py index d524cdc1..bd36c245 100644 --- a/tests/test_settings_p3_visual.py +++ b/tests/test_settings_p3_visual.py @@ -685,7 +685,7 @@ def _row(tmp_path, includes): # type: ignore[no-untyped-def] cfg = Config.model_validate( { - "defaults": {"filters": {"exclude_tags": []}}, + "defaults": {"filters": {"exclude_tags": [], "excludes": []}}, "collections": {"c": {"sources": [{"path": str(tmp_path), "includes": includes}]}}, } ) @@ -722,7 +722,7 @@ def _row(tmp_path: Path, **source: object) -> str: cfg = Config.model_validate( { - "defaults": {"filters": {"exclude_tags": []}}, + "defaults": {"filters": {"exclude_tags": [], "excludes": []}}, "collections": {"c": {"sources": [{"path": str(tmp_path), **source}]}}, } ) @@ -765,11 +765,10 @@ class TestTheCustomGlobPromptSaysWhatAGlobDoes: """The rule box one screen over teaches this exact rule; the field that takes globs had no hint, no example and no validation.""" - def test_both_prompts_carry_the_hint(self) -> None: + def test_the_glob_editor_carries_the_hint(self) -> None: import inspect - from fnd.tui.settings_screen import _GLOB_HINT, AddCollectionWizard, SourceFormScreen + from fnd.tui.settings_screen import _GLOB_HINT, GlobTextScreen assert "/**" in _GLOB_HINT - for screen in (SourceFormScreen, AddCollectionWizard): - assert "_GLOB_HINT" in inspect.getsource(screen._prompt_custom), screen.__name__ + assert "_GLOB_HINT" in inspect.getsource(GlobTextScreen.compose) diff --git a/tests/test_settings_p3_wizard.py b/tests/test_settings_p3_wizard.py index 4f7be9a6..9d629db7 100644 --- a/tests/test_settings_p3_wizard.py +++ b/tests/test_settings_p3_wizard.py @@ -24,7 +24,7 @@ def built_index(fixtures_dir: Path, tmp_index_dir: Path) -> Path: def test_excludes_presets_exposed() -> None: - """Spec: Wizard › Excludes — preset patterns, with safe defaults.""" + """The presets Excluded paths offers.""" from fnd.config import EXCLUDES_PRESETS assert "hidden" in EXCLUDES_PRESETS @@ -33,9 +33,7 @@ def test_excludes_presets_exposed() -> None: # so the label may not offer to bring them back. assert "always" in str(hidden["label"]).lower(), hidden["label"] assert any(".git" in g for g in hidden["globs"]) - assert hidden["default"] is True # pre-ticked assert "node_modules" in EXCLUDES_PRESETS - assert EXCLUDES_PRESETS["node_modules"]["default"] is False @pytest.mark.asyncio @@ -67,77 +65,24 @@ async def test_add_collection_pushes_wizard_with_expected_fields(built_index: Pa await pilot.press("enter") await pilot.pause() assert isinstance(app.screen, AddCollectionWizard) - # All six field rows present. + # The collection's name, then its first source as the source form shows it. wlst = app.screen.query_one(SettingsList) - labels = [it.label for it in wlst._items] - for required in ( + from fnd.tui.menu import KIND_HEADER + + rows = [it for it in wlst._items if it.kind != KIND_HEADER] + labels = [it.label for it in rows] + assert labels == [ "Name", - "Source path", - "File types", - "Excludes", - "Frontmatter rule", + "Path", "Follow symlinks", - ): - assert required in labels, f"missing field {required!r}; got {labels}" - undescribed = [it.label for it in wlst._items if not it.description] + "Index filters", + "Frontmatter rule", + "Restrict to these paths", + ], labels + undescribed = [it.label for it in rows if not it.description] assert not undescribed, f"rows with nothing to explain them: {undescribed}" -@pytest.mark.asyncio -async def test_includes_field_opens_filetypes_picker(built_index: Path) -> None: - """Wizard › Includes opens the nested ToggleTree picker, pre-selected to - ALL types (a new source indexes every supported type by default).""" - from fnd.kinds import ALL_KIND_IDS - from fnd.tui.settings_screen import ( - AddCollectionWizard, - SettingsList, - TreePickerScreen, - ) - from fnd.tui.widgets.toggle_tree import ToggleTree - - app = FNDApp(index_dir=built_index) - async with app.run_test() as pilot: - await pilot.pause() - app.push_screen(AddCollectionWizard()) - await screen_ready(pilot, app, AddCollectionWizard) - wiz = app.screen - assert isinstance(wiz, AddCollectionWizard) - lst = wiz.query_one(SettingsList) - inc_idx = next(i for i, it in enumerate(lst._items) if it.id == "wiz.includes") - lst.cursor_index = inc_idx - await pilot.press("enter") - await pilot.pause() - assert isinstance(app.screen, TreePickerScreen) - tree = app.screen.query_one("#tree_picker", ToggleTree) - assert tree.selected == frozenset(ALL_KIND_IDS) - - -@pytest.mark.asyncio -async def test_excludes_field_opens_presets_picker_with_defaults(built_index: Path) -> None: - """Spec: Wizard › Excludes — preset multi-select, hidden pre-checked.""" - from fnd.tui.settings_screen import ( - AddCollectionWizard, - PickerScreen, - SettingsList, - ) - - app = FNDApp(index_dir=built_index) - async with app.run_test() as pilot: - await pilot.pause() - app.push_screen(AddCollectionWizard()) - await screen_ready(pilot, app, AddCollectionWizard) - wiz = app.screen - assert isinstance(wiz, AddCollectionWizard) - lst = wiz.query_one(SettingsList) - exc_idx = next(i for i, it in enumerate(lst._items) if it.id == "wiz.excludes") - lst.cursor_index = exc_idx - await pilot.press("enter") - await pilot.pause() - assert isinstance(app.screen, PickerScreen) - # `hidden` preset is pre-selected. - assert "hidden" in app.screen._selected - - @pytest.mark.asyncio async def test_path_validation_inline(tmp_path: Path, built_index: Path) -> None: """Spec: Wizard › Source path — live ✓/✗ inline validation.""" @@ -225,8 +170,7 @@ async def test_save_writes_collection_and_reindexes( wiz = AddCollectionWizard() wiz._fields["name"] = "research" wiz._fields["path"] = str(real_dir) - wiz._fields["includes"] = ["md"] - wiz._fields["excludes_presets"] = ["hidden"] + wiz._fields["filters"] = {"kinds": ["md"], "excludes": ["**/.git/**"]} app.push_screen(wiz) await pilot.pause() # Trigger save. @@ -243,8 +187,8 @@ async def test_save_writes_collection_and_reindexes( # Includes are mapped to globs. assert src.filters is not None assert "md" in (src.filters.kinds or []) - # Excludes from the `hidden` preset are present. - assert any(".git" in g for g in src.excludes) + # Excludes set in the wizard's Index filters are the source's own. + assert src.filters.excludes == ["**/.git/**"] @pytest.mark.asyncio @@ -279,73 +223,6 @@ async def test_esc_discards_wizard_with_no_side_effects( assert set(after.keys()) == set(before.keys()) -@pytest.mark.asyncio -async def test_includes_tree_picker_preserves_custom_glob(built_index: Path) -> None: - """The nested Includes picker edits kinds only; an existing custom-glob - include is preserved untouched when it commits.""" - from fnd.tui import FNDApp - from fnd.tui.settings_screen import AddCollectionWizard - - app = FNDApp(index_dir=built_index) - async with app.run_test() as pilot: - await pilot.pause() - wiz = AddCollectionWizard() - app.push_screen(wiz) - await pilot.pause() - wiz._fields["includes_custom"] = "**/*.org" - wiz._set_includes(["md", "txt"]) - assert wiz._fields["includes"] == ["md", "txt"] - assert wiz._fields["includes_custom"] == "**/*.org" - - -@pytest.mark.asyncio -async def test_excludes_picker_includes_custom_entry(built_index: Path) -> None: - """Spec: Wizard › Excludes — `Custom glob…` escape hatch.""" - from fnd.tui import FNDApp - from fnd.tui.settings_screen import ( - AddCollectionWizard, - PickerScreen, - SettingsList, - ) - - app = FNDApp(index_dir=built_index) - async with app.run_test() as pilot: - await pilot.pause() - app.push_screen(AddCollectionWizard()) - await screen_ready(pilot, app, AddCollectionWizard) - wiz = app.screen - assert isinstance(wiz, AddCollectionWizard) - lst = wiz.query_one(SettingsList) - idx = next(i for i, it in enumerate(lst._items) if it.id == "wiz.excludes") - lst.cursor_index = idx - await pilot.press("enter") - await pilot.pause() - picker = app.screen - assert isinstance(picker, PickerScreen) - values = [c.value for c in picker._choices] - assert "__custom__" in values, f"expected `__custom__` choice; got {values}" - - -@pytest.mark.asyncio -async def test_set_includes_stores_kinds_all_maps_to_empty(built_index: Path) -> None: - """_set_includes stores a selected-kind subset explicitly, and maps the - all-selected case to an empty list (= index every supported type).""" - from fnd.kinds import ALL_KIND_IDS - from fnd.tui import FNDApp - from fnd.tui.settings_screen import AddCollectionWizard - - app = FNDApp(index_dir=built_index) - async with app.run_test() as pilot: - await pilot.pause() - wiz = AddCollectionWizard() - app.push_screen(wiz) - await pilot.pause() - wiz._set_includes(["md", "txt"]) - assert wiz._fields["includes"] == ["md", "txt"] - wiz._set_includes(list(ALL_KIND_IDS)) - assert wiz._fields["includes"] == [] - - @pytest.mark.asyncio async def test_source_form_shows_include_globs_as_ticked_file_types( built_index: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -417,64 +294,6 @@ async def test_source_form_shows_include_globs_as_ticked_file_types( assert "**/*.md" in summary -@pytest.mark.asyncio -async def test_source_form_excludes_picker_round_trips_hidden_preset( - built_index: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - """Excludes globs that match the `hidden` preset round-trip to a - pre-selected `hidden` entry.""" - from fnd.config import ( - EXCLUDES_PRESETS, - CollectionConfig, - SourceConfig, - write_collection, - ) - from fnd.tui import FNDApp - from fnd.tui.settings_screen import ( - PickerScreen, - SettingsList, - SourceFormScreen, - ) - - cfg_path = tmp_path / "config.toml" - monkeypatch.setattr("fnd.config.default_config_path", lambda: cfg_path) - - real = tmp_path / "vault" - real.mkdir() - write_collection( - config_path=cfg_path, - name="probe3", - collection=CollectionConfig( - sources=[ - SourceConfig( - path=real, - includes=["**/*.md"], - excludes=list(EXCLUDES_PRESETS["hidden"]["globs"]), - ) - ] - ), - ) - - app = FNDApp(index_dir=built_index) - async with app.run_test() as pilot: - await pilot.pause() - from fnd.config import load - - app._config = load() - app.push_screen(SourceFormScreen(collection_name="probe3", source_index=0)) - await screen_ready(pilot, app, SourceFormScreen) - form = app.screen - assert isinstance(form, SourceFormScreen) - lst = form.query_one(SettingsList) - idx = next(i for i, it in enumerate(lst._items) if it.id == "form.excludes") - lst.cursor_index = idx - await pilot.press("enter") - await pilot.pause() - picker = app.screen - assert isinstance(picker, PickerScreen) - assert "hidden" in picker._selected - - @pytest.mark.asyncio async def test_save_with_missing_name_shows_inline_error( built_index: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -527,7 +346,8 @@ async def test_the_wizard_editor_stays_inside_its_panel(built_index: Path) -> No wizard = app.screen panel = wizard.query_one("#settings_box") closed_width = panel.region.width - wizard.query_one(SettingsList).cursor_index = 0 + rows = wizard.query_one(SettingsList) + rows.cursor_index = next(i for i, it in enumerate(rows._items) if it.id == "wiz.name") await pilot.press("enter") for _ in range(8): await pilot.pause() @@ -538,9 +358,8 @@ async def test_the_wizard_editor_stays_inside_its_panel(built_index: Path) -> No class TestTheWizardShowsWhatWillBeIndexed: - """Setting nothing does not mean "every type": the source it writes - inherits `defaults.filters`, so a default of `kinds = ["md"]` was painted - as "every type" while the new collection indexed 3 files of 12.""" + """Setting nothing inherits `defaults.filters`, and the rows say so rather + than implying no filter applies.""" @staticmethod async def _rows(config: Config, pilot_size: tuple[int, int] = (110, 26)) -> dict[str, Any]: @@ -569,15 +388,13 @@ async def test_inherited_filters_are_named(self) -> None: defaults=Defaults(filters=DefaultFilters(kinds=["md"], frontmatter="Course == 'A'")) ) rows = await self._rows(config) - assert "md" in rows["wiz.includes"] - assert "inherited" in rows["wiz.includes"], rows["wiz.includes"] - assert "Course == 'A'" in rows["wiz.filter"] - assert "every type" not in rows["wiz.includes"], "the claim that was false" + assert rows["wiz.filters"] == "inherited", rows["wiz.filters"] + assert rows["wiz.frontmatter"] == "Course == 'A' (inherited)", rows["wiz.frontmatter"] @pytest.mark.asyncio async def test_without_defaults_it_still_reads_plainly(self) -> None: from fnd.config import Config rows = await self._rows(Config()) - assert rows["wiz.includes"] == "every type" - assert rows["wiz.filter"] == "(none)" + assert rows["wiz.filters"] == "inherited" + assert rows["wiz.frontmatter"] == "(none)" diff --git a/tests/test_source_form_preserves_fields.py b/tests/test_source_form_preserves_fields.py index 50c11fed..272ed86c 100644 --- a/tests/test_source_form_preserves_fields.py +++ b/tests/test_source_form_preserves_fields.py @@ -39,9 +39,8 @@ def _source(root: Path) -> SourceConfig: return SourceConfig( path=root, includes=["notes/**"], - excludes=["**/scratch/**"], follow_symlinks=True, - filters=SourceFilters(kinds=["md"], min_size=10), + filters=SourceFilters(excludes=["**/scratch/**"], kinds=["md"], min_size=10), app="vscode", app_for={"md": "obsidian", "pdf": "preview"}, app_params={"vault": "MyVault", "profile": "work"}, diff --git a/tests/test_the_glob_editor_has_a_visible_field.py b/tests/test_the_glob_editor_has_a_visible_field.py index c032b900..d70b21fa 100644 --- a/tests/test_the_glob_editor_has_a_visible_field.py +++ b/tests/test_the_glob_editor_has_a_visible_field.py @@ -1,4 +1,4 @@ -"""The custom-glob editor's label must not wrap over its own field. +"""A glob field's editor label must not wrap over its own field. Measured at 100 cols: the label Static rendered 4 rows inside a 2-row bar, so the input was clipped and the typed value painted at the end of the wrapped @@ -39,8 +39,16 @@ async def _open_glob_editor(app: FNDApp, pilot: Any) -> SourceFormScreen: await screen_ready(pilot, app, SourceFormScreen) screen = app.screen assert isinstance(screen, SourceFormScreen) - screen._set_excludes(["__custom__"]) + from fnd.tui.settings_screen import SettingsList + + rows = screen.query_one(SettingsList) + rows.cursor_index = next( + i for i, it in enumerate(rows._items) if it.id == "form.includes_custom" + ) await pilot.pause() + await pilot.press("enter") + for _ in range(4): + await pilot.pause() return screen diff --git a/tests/test_unsaved_changes_gate.py b/tests/test_unsaved_changes_gate.py index 53e5b343..97e933b0 100644 --- a/tests/test_unsaved_changes_gate.py +++ b/tests/test_unsaved_changes_gate.py @@ -42,7 +42,7 @@ async def test_a_dirty_form_asks_instead_of_discarding(tmp_path: Path) -> None: await pilot.pause() form = app.screen assert isinstance(form, SourceFormScreen) - form._fields["excludes_custom"] = "build/**" + form._fields["includes_custom"] = "build/**" await pilot.press("escape") await pilot.pause() asked = isinstance(app.screen, UnsavedChangesScreen) @@ -76,13 +76,13 @@ async def test_keep_editing_returns_to_the_form(tmp_path: Path) -> None: await pilot.pause() form = app.screen assert isinstance(form, SourceFormScreen) - form._fields["excludes_custom"] = "build/**" + form._fields["includes_custom"] = "build/**" await pilot.press("escape") await pilot.pause() await pilot.press("escape") # Esc on the prompt = keep editing await pilot.pause() back_on_form = app.screen is form - kept = form._fields["excludes_custom"] + kept = form._fields["includes_custom"] assert back_on_form, "Esc on the prompt must return to the form" assert kept == "build/**", "and must not have discarded the edit" @@ -98,7 +98,7 @@ async def test_discard_leaves_and_drops_the_edit(tmp_path: Path) -> None: await pilot.pause() form = app.screen assert isinstance(form, SourceFormScreen) - form._fields["excludes_custom"] = "build/**" + form._fields["includes_custom"] = "build/**" await pilot.press("escape") await pilot.pause() # Reach Discard from wherever the prompt lands, and say so: it lands on diff --git a/tests/test_walk_per_source.py b/tests/test_walk_per_source.py index 8b3766f0..dd00ef63 100644 --- a/tests/test_walk_per_source.py +++ b/tests/test_walk_per_source.py @@ -4,7 +4,7 @@ from pathlib import Path -from fnd.config import SourceConfig +from fnd.config import SourceConfig, SourceFilters from fnd.walk import walk_sources @@ -77,7 +77,7 @@ def test_excludes_still_apply_under_filter(tmp_path: Path) -> None: SourceConfig( path=root, includes=["**/*.md"], - excludes=["**/.trash/**"], + filters=SourceFilters(excludes=["**/.trash/**"]), frontmatter_filter="Course == 'DPwC'", ) ]