diff --git a/README.md b/README.md index f1631e5..a37cb1b 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,17 @@ If you need explicit confirmation, wait for `Saved.` before closing settings. ![DEMO](/docs/images/read-only-view-obsidian-plugin-demo.gif) +Rule volume safeguards: + +- soft warning when include or exclude has more than `50` effective rules +- strong warning when include or exclude has more than `150` effective rules +- hard caps: + - include max: `200` + - exclude max: `300` + - total max: `400` +- when hard caps are exceeded, save still succeeds, but extra lines are ignored and marked in diagnostics (`Ignored due to rule limit.`) +- rules counter in settings shows effective used rules: `Include: X · Exclude: Y · Total: Z` with `(+N ignored)` when applicable + ### Rule examples Common scenarios: @@ -127,6 +138,10 @@ Use the Command Palette: - Prevent switching matched files to Source mode or Live Preview. - Matching: - Include and exclude rule lists (`exclude` has priority when both match). + - Rule limit policy: + - include list is capped first (`200`) + - exclude list is capped second (`300`) + - if combined total still exceeds `400`, tail of exclude is trimmed first (include keeps priority) - Two modes: - Glob mode (`*`, `**`, `?`) - Literal prefix mode (compatibility mode) @@ -186,6 +201,9 @@ Use the Command Palette: - Changes in rules do not appear immediately: - Run command **Re-apply rules now**. - Switch tabs or reopen the note to trigger workspace events. +- You see `Too many rules. Extra lines are ignored.`: + - Reduce rule count, merge similar paths, and prefer `**` in glob mode for broader coverage. + - Check diagnostics entries marked `Ignored due to rule limit.` to see which lines are not used.
Advanced troubleshooting @@ -293,6 +311,7 @@ See repository [Releases](../../releases). How enforcement works 1. On workspace events (`file-open`, `active-leaf-change`, `layout-change`), the plugin coalesces bursts into one re-apply pass (150 ms window) before scanning open Markdown leaves. + - If the burst contains only `active-leaf-change` and/or `file-open`, the plugin applies enforcement only to the changed leaf to reduce unnecessary full-vault UI work. 2. For each Markdown file, it evaluates `shouldForceReadOnly(file.path, settings)`. 3. If the file should be protected, the plugin forces the leaf view mode to `preview`. 4. If a user or UI action tries to switch back to edit mode, the plugin re-applies preview mode. diff --git a/docs/PROJECT_STATE.md b/docs/PROJECT_STATE.md index 5fda144..853ac89 100644 --- a/docs/PROJECT_STATE.md +++ b/docs/PROJECT_STATE.md @@ -1,6 +1,6 @@ # PROJECT_STATE -Last updated: 2026-02-23 +Last updated: 2026-02-26 This document is a living system map for the `read-only-view` Obsidian plugin. @@ -24,6 +24,11 @@ High-level modules: - `src/settings-tab.ts` - `ForceReadModeSettingTab` UI module (settings controls, rules editor, diagnostics panel, path tester) - `DebouncedRuleChangeSaver` for input-save debounce and flush +- `src/constants.ts` + - Rule volume thresholds and hard limits (`50/150`, `200/300/400`) +- `src/rule-limits.ts` + - Single source of truth for effective include/exclude rules after cleanup + caps + - Line-level ignored index tracking for settings diagnostics/UI - `src/popover-observer.ts` - Typed popover observer service with explicit lifecycle (`start`, `stop`) - Centralized popover/editor selectors and mutation prefiltering @@ -67,6 +72,8 @@ High-level modules: - Debounced rules-save coverage for settings module: burst collapse, immediate flush, and latest-value persistence - `tests/rule-diagnostics.test.ts` - Diagnostics and path tester helper coverage for inline warnings and include/exclude/result computation +- `tests/rule-limits.test.ts` + - Rule cap/warning coverage and matching behavior with ignored tail rules - `tests/debug-logging.test.ts` - Debug logging privacy coverage for path redaction/verbose mode and fallback error diagnostics @@ -99,6 +106,7 @@ Workspace-event coalescing: - `file-open`, `active-leaf-change`, and `layout-change` are combined in a 150 ms window. - One coalesced run executes with reason format `workspace-events:`. +- Optimization: when a coalesced batch contains only `active-leaf-change` and/or `file-open`, enforcement is applied only to the affected leaf instead of scanning all markdown leaves. - Manual command `Re-apply rules now` still runs immediately. Observer optimization: @@ -113,6 +121,7 @@ Loop protection: - Global lock (`enforcing`) + pending reason queue (`pendingReapply`) - Per-leaf throttle (`WeakMap`) to reduce repeated `setViewState` calls. +- Layout-change bursts use an extended per-leaf throttle window to reduce repeated reflow-prone mode flips during heavy UI relayouts. Command entry points: @@ -128,7 +137,11 @@ Command entry points: 2. If `useGlobPatterns=true`: anchored regex (`^...$`) using internal glob conversion. - Compiled regex entries are cached with fixed FIFO cap (`512`) to bound memory for highly unique rule sets. 3. If `useGlobPatterns=false`: literal prefix mode with optional folder slash hint. -4. Include must match, then exclude must *not* match. +4. Build effective rule sets from settings using hard-cap policy: + - include is capped first (`200`) + - exclude is capped second (`300`) + - if total still exceeds `400`, exclude tail is trimmed first (include priority) +5. Include must match, then exclude must *not* match. ### D. Settings UX flow @@ -140,6 +153,12 @@ UI module split: - Toggles: `Enabled`, `Use glob patterns`, `Case sensitive`, `Debug logging` - `Debug: verbose paths` toggle allows full file paths in debug logs; default keeps paths redacted - Rule textareas: include/exclude (one rule per line) +- Rule usage summary: + - `Include: X rules · Exclude: Y rules · Total: Z` (`+N ignored` when capped) +- Rule volume warnings (inline banner, no toast): + - soft warning when include or exclude has more than `50` effective rules + - strong warning when include or exclude has more than `150` effective rules + - hard-cap warning `Too many rules. Extra lines are ignored.` when caps are exceeded - Rules-save behavior: - save on `input` with 400 ms debounce - flush on `blur` and `change` @@ -147,6 +166,7 @@ UI module split: - Diagnostics list per line: - `✅` healthy - `⚠️` suspicious (empty lines, wildcard in prefix mode, normalization/folder-hint changes) + - ignored line marker (`Ignored`) and inline warning (`Ignored due to rule limit.`) for rules truncated by caps - empty lines render as `(empty line)` and do not receive synthetic `/` normalization - warning details are rendered inline in nested semantic lists (`ul/li`) and announced via `aria-live` - diagnostics panel is capped with local scroll for mobile/tablet readability diff --git a/just/test.just b/just/test.just index c7bc6a9..5ee6e4a 100644 --- a/just/test.just +++ b/just/test.just @@ -2,3 +2,10 @@ [doc('Compile and run unit tests')] test: @npm test + +[group('Test')] +[doc('Run tests with Node coverage report (line/branch/function %)')] +coverage: + @npm exec tsc -- -p tsconfig.test.json + @node tests/helpers/prepare-obsidian-runtime.mjs + @node --experimental-test-coverage --test build-tests/**/*.test.js diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..e078206 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,6 @@ +export const RULE_WARNING_SOFT_THRESHOLD = 50; +export const RULE_WARNING_STRONG_THRESHOLD = 150; + +export const RULE_LIMIT_INCLUDE_MAX = 200; +export const RULE_LIMIT_EXCLUDE_MAX = 300; +export const RULE_LIMIT_TOTAL_MAX = 400; diff --git a/src/enforcement.ts b/src/enforcement.ts index fcaccd0..4cd0947 100644 --- a/src/enforcement.ts +++ b/src/enforcement.ts @@ -16,6 +16,20 @@ export interface EnforcementService { } const LEAF_FORCE_PREVIEW_THROTTLE_MS = 120; +const LAYOUT_CHANGE_FORCE_PREVIEW_THROTTLE_MS = 700; + +function waitForNextFrame(): Promise { + if (typeof requestAnimationFrame === 'function') { + return new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + } + return Promise.resolve(); +} + +function isLayoutChangeReason(reason: string): boolean { + return reason.includes('workspace-events:layout-change'); +} function describeError(error: unknown): { errorType: string; errorMessage: string } { if (error instanceof Error) { @@ -78,6 +92,9 @@ class DefaultEnforcementService implements EnforcementService { if (file.extension !== 'md') { return; } + if (this.getLeafMode(leaf) === 'preview') { + return; + } const settings = this.dependencies.getSettings(); if (!shouldForceReadOnly(file.path, settings)) { @@ -91,11 +108,14 @@ class DefaultEnforcementService implements EnforcementService { if (!(leaf.view instanceof MarkdownView)) { return null; } + const stateMode = (leaf.getViewState().state as { mode?: string } | undefined)?.mode; + if (stateMode) { + return stateMode; + } if (typeof leaf.view.getMode === 'function') { return leaf.view.getMode(); } - const stateMode = (leaf.getViewState().state as { mode?: string } | undefined)?.mode; - return stateMode ?? null; + return null; } async ensurePreview(leaf: WorkspaceLeaf, reason: string): Promise { @@ -106,20 +126,41 @@ class DefaultEnforcementService implements EnforcementService { if (!file) { return; } + const settings = this.dependencies.getSettings(); + const filePath = this.dependencies.formatPathForDebug(file.path, settings.debugVerbosePaths); const beforeMode = this.getLeafMode(leaf); if (beforeMode === 'preview') { + this.dependencies.logDebug('ensure-preview-skip', { + reason, + filePath, + skipReason: 'already-preview', + }); return; } const now = this.now(); const last = this.lastForcedAt.get(leaf) ?? 0; - if (now - last < LEAF_FORCE_PREVIEW_THROTTLE_MS) { + const throttleMs = isLayoutChangeReason(reason) + ? LAYOUT_CHANGE_FORCE_PREVIEW_THROTTLE_MS + : LEAF_FORCE_PREVIEW_THROTTLE_MS; + if (now - last < throttleMs) { + this.dependencies.logDebug('ensure-preview-skip', { + reason, + filePath, + skipReason: 'throttled', + throttleMs, + }); return; } const currentState = leaf.getViewState(); if (currentState.type !== 'markdown') { + this.dependencies.logDebug('ensure-preview-skip', { + reason, + filePath, + skipReason: 'non-markdown-state', + }); return; } @@ -131,8 +172,29 @@ class DefaultEnforcementService implements EnforcementService { }, }; - const settings = this.dependencies.getSettings(); this.lastForcedAt.set(leaf, now); + // Defer the actual mode write to the next frame to avoid forcing it + // in the middle of CodeMirror measurement/layout work. + await waitForNextFrame(); + + const refreshedState = leaf.getViewState(); + if (refreshedState.type !== 'markdown') { + this.dependencies.logDebug('ensure-preview-skip', { + reason, + filePath, + skipReason: 'non-markdown-state-after-frame', + }); + return; + } + if ((refreshedState.state as { mode?: string } | undefined)?.mode === 'preview') { + this.dependencies.logDebug('ensure-preview-skip', { + reason, + filePath, + skipReason: 'already-preview-after-frame', + }); + return; + } + try { const setState = leaf.setViewState.bind(leaf) as ( state: ViewState, @@ -143,17 +205,17 @@ class DefaultEnforcementService implements EnforcementService { const errorInfo = describeError(error); this.dependencies.logDebug('ensure-preview-fallback', { reason, - filePath: this.dependencies.formatPathForDebug(file.path, settings.debugVerbosePaths), + filePath, errorType: errorInfo.errorType, errorMessage: errorInfo.errorMessage, }); await leaf.setViewState(nextState, false); } - const afterMode = this.getLeafMode(leaf); + const afterMode = (leaf.getViewState().state as { mode?: string } | undefined)?.mode ?? this.getLeafMode(leaf); this.dependencies.logDebug('ensure-preview', { reason, - filePath: this.dependencies.formatPathForDebug(file.path, settings.debugVerbosePaths), + filePath, beforeMode, afterMode, }); diff --git a/src/main.ts b/src/main.ts index 117cea3..b696099 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,6 @@ import { Plugin, + MarkdownView, WorkspaceLeaf, } from 'obsidian'; import { @@ -30,11 +31,13 @@ export function formatPathForDebug(path: string, verbosePaths: boolean): string export default class ReadOnlyViewPlugin extends Plugin { settings: ForceReadModeSettings = { ...DEFAULT_SETTINGS }; private static readonly WORKSPACE_EVENT_COALESCE_MS = 150; + private static readonly TARGETED_WORKSPACE_REASONS = new Set(['active-leaf-change', 'file-open']); private enforcementService: EnforcementService | null = null; private popoverObserverService: PopoverObserverService | null = null; private workspaceEventTimer: ReturnType | null = null; private workspaceEventReasons = new Set(); + private workspaceEventLeaves = new Set(); async onload(): Promise { await this.loadSettings(); @@ -86,8 +89,8 @@ export default class ReadOnlyViewPlugin extends Plugin { this.registerEvent(this.app.workspace.on('file-open', () => { this.scheduleWorkspaceEventReapply('file-open'); })); - this.registerEvent(this.app.workspace.on('active-leaf-change', () => { - this.scheduleWorkspaceEventReapply('active-leaf-change'); + this.registerEvent(this.app.workspace.on('active-leaf-change', (leaf: WorkspaceLeaf | null) => { + this.scheduleWorkspaceEventReapply('active-leaf-change', leaf ?? null); })); this.registerEvent(this.app.workspace.on('layout-change', () => { this.invalidateLeafContainerCache(); @@ -106,6 +109,7 @@ export default class ReadOnlyViewPlugin extends Plugin { this.workspaceEventTimer = null; } this.workspaceEventReasons.clear(); + this.workspaceEventLeaves.clear(); this.invalidateLeafContainerCache(); this.enforcementService = null; if (this.popoverObserverService) { @@ -169,20 +173,63 @@ export default class ReadOnlyViewPlugin extends Plugin { await this.getEnforcementService().applyAllOpenMarkdownLeaves(reason); } - private scheduleWorkspaceEventReapply(reason: string): void { + private isTargetedWorkspaceEventBurst(reasons: string[]): boolean { + if (reasons.length === 0) { + return false; + } + return reasons.every((reason) => ReadOnlyViewPlugin.TARGETED_WORKSPACE_REASONS.has(reason)); + } + + private scheduleWorkspaceEventReapply(reason: string, leaf: WorkspaceLeaf | null = null): void { this.workspaceEventReasons.add(reason); + if (leaf) { + this.workspaceEventLeaves.add(leaf); + } if (this.workspaceEventTimer) { return; } this.workspaceEventTimer = setTimeout(() => { const reasons = Array.from(this.workspaceEventReasons); + const leaves = Array.from(this.workspaceEventLeaves); this.workspaceEventReasons.clear(); + this.workspaceEventLeaves.clear(); this.workspaceEventTimer = null; - void this.applyAllOpenMarkdownLeaves(`workspace-events:${reasons.join(',')}`); + void this.applyWorkspaceEventBurst(reasons, leaves); }, ReadOnlyViewPlugin.WORKSPACE_EVENT_COALESCE_MS); } + private async applyWorkspaceEventBurst(reasons: string[], leaves: WorkspaceLeaf[]): Promise { + const reasonText = `workspace-events:${reasons.join(',')}`; + this.logDebug('workspace-reapply-plan', { + reason: reasonText, + sourceReasons: reasons, + leafCount: leaves.length, + strategy: this.isTargetedWorkspaceEventBurst(reasons) ? 'targeted' : 'full', + }); + if (!this.isTargetedWorkspaceEventBurst(reasons)) { + await this.applyAllOpenMarkdownLeaves(reasonText); + return; + } + + if (leaves.length === 0) { + await this.applyAllOpenMarkdownLeaves(reasonText); + return; + } + + for (const leaf of leaves) { + const filePath = leaf.view instanceof MarkdownView && leaf.view.file + ? formatPathForDebug(leaf.view.file.path, this.settings.debugVerbosePaths) + : null; + this.logDebug('workspace-reapply-target', { + reason: reasonText, + source: 'active-leaf-change', + filePath, + }); + await this.getEnforcementService().applyReadOnlyForLeaf(leaf, `${reasonText}:targeted-leaf`); + } + } + private invalidateLeafContainerCache(): void { this.getPopoverObserverService().invalidateLeafCache(); } diff --git a/src/matcher.ts b/src/matcher.ts index 4413dab..f37bd8f 100644 --- a/src/matcher.ts +++ b/src/matcher.ts @@ -1,3 +1,6 @@ +import { normalizeVaultPath } from './path-utils'; +import { buildEffectiveRules } from './rule-limits'; + export interface ForceReadModeSettings { enabled: boolean; useGlobPatterns: boolean; @@ -48,13 +51,7 @@ function setGlobRegexCache(cacheKey: string, compiled: RegExp): void { globRegexCache.set(cacheKey, compiled); } -export function normalizeVaultPath(path: string): string { - let normalized = path.trim(); - normalized = normalized.replace(/\\/g, '/'); - normalized = normalized.replace(/^(\.\/)+/, ''); - normalized = normalized.replace(/\/+/g, '/'); - return normalized; -} +export { normalizeVaultPath }; function escapeRegexLiteral(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -143,12 +140,13 @@ export function shouldForceReadOnly(filePath: string, settings: ForceReadModeSet useGlobPatterns: settings.useGlobPatterns, caseSensitive: settings.caseSensitive, }; + const effectiveRules = buildEffectiveRules(settings.includeRules, settings.excludeRules); - const hasIncludeMatch = settings.includeRules.some((rule) => matchPath(normalizedFilePath, rule, options)); + const hasIncludeMatch = effectiveRules.effectiveIncludeRules.some((rule) => matchPath(normalizedFilePath, rule, options)); if (!hasIncludeMatch) { return false; } - const hasExcludeMatch = settings.excludeRules.some((rule) => matchPath(normalizedFilePath, rule, options)); + const hasExcludeMatch = effectiveRules.effectiveExcludeRules.some((rule) => matchPath(normalizedFilePath, rule, options)); return !hasExcludeMatch; } diff --git a/src/path-utils.ts b/src/path-utils.ts new file mode 100644 index 0000000..cc7f1fe --- /dev/null +++ b/src/path-utils.ts @@ -0,0 +1,7 @@ +export function normalizeVaultPath(path: string): string { + let normalized = path.trim(); + normalized = normalized.replace(/\\/g, '/'); + normalized = normalized.replace(/^(\.\/)+/, ''); + normalized = normalized.replace(/\/+/g, '/'); + return normalized; +} diff --git a/src/rule-diagnostics.ts b/src/rule-diagnostics.ts index d4355e0..6fa5330 100644 --- a/src/rule-diagnostics.ts +++ b/src/rule-diagnostics.ts @@ -1,4 +1,5 @@ import { matchPath, normalizeVaultPath, shouldForceReadOnly, type ForceReadModeSettings } from './matcher'; +import { buildEffectiveRules, type RuleVolumeWarningLevel } from './rule-limits'; export type RuleDiagnosticsEntry = { lineNumber: number; @@ -6,6 +7,7 @@ export type RuleDiagnosticsEntry = { normalized: string; isOk: boolean; warnings: string[]; + ignoredByRuleLimit: boolean; }; export function splitRulesFromText(value: string): string[] { @@ -39,12 +41,21 @@ function normalizeRuleForMode(rule: string, useGlobPatterns: boolean): { normali } export function buildRuleDiagnostics(rulesText: string, useGlobPatterns: boolean): RuleDiagnosticsEntry[] { + return buildRuleDiagnosticsWithIgnoredLines(rulesText, useGlobPatterns, new Set()); +} + +export function buildRuleDiagnosticsWithIgnoredLines( + rulesText: string, + useGlobPatterns: boolean, + ignoredLineIndexes: ReadonlySet, +): RuleDiagnosticsEntry[] { const lines = rulesText.split('\n'); return lines.map((line, index) => { const trimmed = line.trim(); const normalizedBase = normalizeVaultPath(line); const normalizedInfo = normalizeRuleForMode(line, useGlobPatterns); const warnings: string[] = []; + const ignoredByRuleLimit = ignoredLineIndexes.has(index); if (trimmed.length === 0) { warnings.push('Empty or whitespace-only line.'); @@ -58,6 +69,9 @@ export function buildRuleDiagnostics(rulesText: string, useGlobPatterns: boolean if (normalizedInfo.changedByFolderHint) { warnings.push(`Prefix mode folder hint applied: "${normalizedInfo.normalized}".`); } + if (ignoredByRuleLimit) { + warnings.push('Ignored due to rule limit.'); + } return { lineNumber: index + 1, @@ -65,6 +79,7 @@ export function buildRuleDiagnostics(rulesText: string, useGlobPatterns: boolean normalized: normalizedInfo.normalized, isOk: warnings.length === 0, warnings, + ignoredByRuleLimit, }; }); } @@ -80,18 +95,29 @@ export function buildPathTesterResult(filePathInput: string, settings: ForceRead finalReadOnly: boolean; } { const testPath = normalizeVaultPath(filePathInput); + const effectiveRules = buildEffectiveRules(settings.includeRules, settings.excludeRules); const includeMatches = matchRules( testPath, - settings.includeRules, + effectiveRules.effectiveIncludeRules, settings.useGlobPatterns, settings.caseSensitive, ); const excludeMatches = matchRules( testPath, - settings.excludeRules, + effectiveRules.effectiveExcludeRules, settings.useGlobPatterns, settings.caseSensitive, ); const finalReadOnly = shouldForceReadOnly(testPath, settings); return { testPath, includeMatches, excludeMatches, finalReadOnly }; } + +export function getRuleVolumeWarningMessage(warningLevel: RuleVolumeWarningLevel): string | null { + if (warningLevel === 'strong') { + return 'Very many rules. This may slow down Obsidian, especially on mobile. Consider merging rules and using **.'; + } + if (warningLevel === 'soft') { + return 'Many rules. Consider merging rules and using ** to simplify.'; + } + return null; +} diff --git a/src/rule-limits.ts b/src/rule-limits.ts new file mode 100644 index 0000000..ab3c806 --- /dev/null +++ b/src/rule-limits.ts @@ -0,0 +1,133 @@ +import { + RULE_LIMIT_EXCLUDE_MAX, + RULE_LIMIT_INCLUDE_MAX, + RULE_LIMIT_TOTAL_MAX, + RULE_WARNING_SOFT_THRESHOLD, + RULE_WARNING_STRONG_THRESHOLD, +} from './constants'; +import { normalizeVaultPath } from './path-utils'; + +type EffectiveRuleEntry = { + value: string; + lineIndex: number; +}; + +export type RuleVolumeWarningLevel = 'none' | 'soft' | 'strong'; + +export type EffectiveRulesResult = { + effectiveIncludeRules: string[]; + effectiveExcludeRules: string[]; + ignoredIncludeLineIndexes: number[]; + ignoredExcludeLineIndexes: number[]; + counts: { + includeUsed: number; + excludeUsed: number; + totalUsed: number; + includeIgnored: number; + excludeIgnored: number; + totalIgnored: number; + }; + rawCounts: { + includeEffective: number; + excludeEffective: number; + totalEffective: number; + }; + hardCapExceeded: boolean; + warningLevel: RuleVolumeWarningLevel; +}; + +function toEffectiveEntries(lines: string[]): EffectiveRuleEntry[] { + const entries: EffectiveRuleEntry[] = []; + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + if (line === undefined) { + continue; + } + const normalized = normalizeVaultPath(line); + if (normalized.length === 0) { + continue; + } + entries.push({ value: normalized, lineIndex: index }); + } + return entries; +} + +function pushIgnoredLineIndexes(target: number[], ignoredEntries: EffectiveRuleEntry[]): void { + for (const entry of ignoredEntries) { + target.push(entry.lineIndex); + } +} + +export function buildEffectiveRules(includeLines: string[], excludeLines: string[]): EffectiveRulesResult { + const includeEntries = toEffectiveEntries(includeLines); + const excludeEntries = toEffectiveEntries(excludeLines); + + const ignoredIncludeLineIndexes: number[] = []; + const ignoredExcludeLineIndexes: number[] = []; + + let keptInclude = includeEntries.slice(0, RULE_LIMIT_INCLUDE_MAX); + pushIgnoredLineIndexes(ignoredIncludeLineIndexes, includeEntries.slice(RULE_LIMIT_INCLUDE_MAX)); + + let keptExclude = excludeEntries.slice(0, RULE_LIMIT_EXCLUDE_MAX); + pushIgnoredLineIndexes(ignoredExcludeLineIndexes, excludeEntries.slice(RULE_LIMIT_EXCLUDE_MAX)); + + /* + * Total cap policy is intentionally explicit and predictable: + * 1) keep include up to includeMax + * 2) keep exclude up to excludeMax + * 3) if total still exceeds totalMax, trim the tail of exclude first + * (include retains priority), and only trim include if include alone exceeds totalMax. + */ + const totalAfterListCaps = keptInclude.length + keptExclude.length; + if (totalAfterListCaps > RULE_LIMIT_TOTAL_MAX) { + if (keptInclude.length > RULE_LIMIT_TOTAL_MAX) { + pushIgnoredLineIndexes(ignoredIncludeLineIndexes, keptInclude.slice(RULE_LIMIT_TOTAL_MAX)); + keptInclude = keptInclude.slice(0, RULE_LIMIT_TOTAL_MAX); + pushIgnoredLineIndexes(ignoredExcludeLineIndexes, keptExclude); + keptExclude = []; + } else { + const allowedExclude = RULE_LIMIT_TOTAL_MAX - keptInclude.length; + pushIgnoredLineIndexes(ignoredExcludeLineIndexes, keptExclude.slice(allowedExclude)); + keptExclude = keptExclude.slice(0, allowedExclude); + } + } + + const includeIgnored = ignoredIncludeLineIndexes.length; + const excludeIgnored = ignoredExcludeLineIndexes.length; + const totalIgnored = includeIgnored + excludeIgnored; + const includeUsed = keptInclude.length; + const excludeUsed = keptExclude.length; + const totalUsed = includeUsed + excludeUsed; + const includeEffective = includeEntries.length; + const excludeEffective = excludeEntries.length; + const totalEffective = includeEffective + excludeEffective; + const maxPerListCount = Math.max(includeEffective, excludeEffective); + const warningLevel: RuleVolumeWarningLevel = + maxPerListCount > RULE_WARNING_STRONG_THRESHOLD + ? 'strong' + : maxPerListCount > RULE_WARNING_SOFT_THRESHOLD + ? 'soft' + : 'none'; + + return { + effectiveIncludeRules: keptInclude.map((entry) => entry.value), + effectiveExcludeRules: keptExclude.map((entry) => entry.value), + ignoredIncludeLineIndexes, + ignoredExcludeLineIndexes, + counts: { + includeUsed, + excludeUsed, + totalUsed, + includeIgnored, + excludeIgnored, + totalIgnored, + }, + rawCounts: { + includeEffective, + excludeEffective, + totalEffective, + }, + hardCapExceeded: totalIgnored > 0, + warningLevel, + }; +} diff --git a/src/settings-tab.ts b/src/settings-tab.ts index fab614d..e32a01a 100644 --- a/src/settings-tab.ts +++ b/src/settings-tab.ts @@ -2,11 +2,13 @@ import { App, PluginSettingTab, Setting } from 'obsidian'; import { normalizeVaultPath } from './matcher'; import { buildPathTesterResult, - buildRuleDiagnostics, + buildRuleDiagnosticsWithIgnoredLines, + getRuleVolumeWarningMessage, splitRulesFromText, stringifyRules, type RuleDiagnosticsEntry, } from './rule-diagnostics'; +import { buildEffectiveRules } from './rule-limits'; type RuleSaveState = 'saving' | 'saved' | 'error'; const RULES_SAVE_DEBOUNCE_MS = 400; @@ -25,6 +27,38 @@ export interface SettingsTabPlugin { applyAllOpenMarkdownLeaves: (reason: string) => Promise; } +export type RuleLimitsUiState = { + summaryText: string; + volumeWarningMessage: string | null; + hardCapWarningMessage: string | null; + ignoredIncludeLineIndexes: number[]; + ignoredExcludeLineIndexes: number[]; +}; + +export function computeRuleLimitsUiState(includeRulesText: string, excludeRulesText: string): RuleLimitsUiState { + const effectiveRules = buildEffectiveRules( + includeRulesText.split('\n'), + excludeRulesText.split('\n'), + ); + const ignoredSuffix = effectiveRules.counts.totalIgnored > 0 + ? ` (+${effectiveRules.counts.totalIgnored} ignored)` + : ''; + const summaryText = + `Include: ${effectiveRules.counts.includeUsed} rules · Exclude: ${effectiveRules.counts.excludeUsed} rules · Total: ${effectiveRules.counts.totalUsed}${ignoredSuffix}`; + const volumeWarningMessage = getRuleVolumeWarningMessage(effectiveRules.warningLevel); + const hardCapWarningMessage = effectiveRules.hardCapExceeded + ? 'Too many rules. Extra lines are ignored.' + : null; + + return { + summaryText, + volumeWarningMessage, + hardCapWarningMessage, + ignoredIncludeLineIndexes: effectiveRules.ignoredIncludeLineIndexes, + ignoredExcludeLineIndexes: effectiveRules.ignoredExcludeLineIndexes, + }; +} + export class DebouncedRuleChangeSaver { private timer: ReturnType | null = null; private lastValue = ''; @@ -93,12 +127,21 @@ function renderDiagnosticsList( const bullet = entry.isOk ? '✅' : '⚠️'; const summary = `${bullet} [${entry.lineNumber}] ${entry.normalized || '(empty line)'}`; const itemEl = listEl.createEl('li', { - cls: entry.isOk ? 'read-only-view-diagnostics-item-ok' : 'read-only-view-diagnostics-item-warning', + cls: [ + entry.isOk ? 'read-only-view-diagnostics-item-ok' : 'read-only-view-diagnostics-item-warning', + entry.ignoredByRuleLimit ? 'read-only-view-diagnostics-item-ignored' : '', + ].filter(Boolean).join(' '), }); - itemEl.createEl('div', { + const summaryEl = itemEl.createEl('div', { text: summary, cls: 'read-only-view-diagnostics-summary', }); + if (entry.ignoredByRuleLimit) { + summaryEl.createEl('span', { + text: ' Ignored', + cls: 'read-only-view-diagnostics-ignored-pill', + }); + } if (entry.warnings.length > 0) { const warningsListEl = itemEl.createEl('ul', { cls: 'read-only-view-diagnostics-warnings' }); for (const warning of entry.warnings) { @@ -195,17 +238,67 @@ export class ForceReadModeSettingTab extends PluginSettingTab { }); }); - this.renderRulesEditor('Include rules', 'One rule per line. These files become read-only if not excluded.', this.plugin.settings.includeRules, async (value) => { + const rulesSummaryEl = containerEl.createDiv({ cls: 'read-only-view-rules-summary' }); + const ruleWarningEl = containerEl.createDiv({ cls: 'read-only-view-rule-warning-banner' }); + const hardCapWarningEl = containerEl.createDiv({ cls: 'read-only-view-rule-warning-banner' }); + + let includeRulesText = stringifyRules(this.plugin.settings.includeRules); + let excludeRulesText = stringifyRules(this.plugin.settings.excludeRules); + + const includeEditor = this.renderRulesEditor( + 'Include rules', + 'One rule per line. These files become read-only if not excluded.', + this.plugin.settings.includeRules, + async (value) => { this.plugin.settings.includeRules = splitRulesFromText(value); await this.plugin.saveSettings(); await this.plugin.applyAllOpenMarkdownLeaves('settings-include-rules'); - }); + }, + (value) => { + includeRulesText = value; + renderRuleLimitsState(); + }, + ); - this.renderRulesEditor('Exclude rules', 'One rule per line. Exclude wins when include and exclude both match.', this.plugin.settings.excludeRules, async (value) => { + const excludeEditor = this.renderRulesEditor( + 'Exclude rules', + 'One rule per line. Exclude wins when include and exclude both match.', + this.plugin.settings.excludeRules, + async (value) => { this.plugin.settings.excludeRules = splitRulesFromText(value); await this.plugin.saveSettings(); await this.plugin.applyAllOpenMarkdownLeaves('settings-exclude-rules'); - }); + }, + (value) => { + excludeRulesText = value; + renderRuleLimitsState(); + }, + ); + + const renderRuleLimitsState = () => { + const uiState = computeRuleLimitsUiState(includeRulesText, excludeRulesText); + rulesSummaryEl.setText(uiState.summaryText); + ruleWarningEl.empty(); + if (uiState.volumeWarningMessage) { + ruleWarningEl.setText(uiState.volumeWarningMessage); + ruleWarningEl.addClass('is-visible'); + } else { + ruleWarningEl.removeClass('is-visible'); + } + + hardCapWarningEl.empty(); + if (uiState.hardCapWarningMessage) { + hardCapWarningEl.setText(uiState.hardCapWarningMessage); + hardCapWarningEl.addClass('is-visible'); + } else { + hardCapWarningEl.removeClass('is-visible'); + } + + includeEditor.setIgnoredLineIndexes(uiState.ignoredIncludeLineIndexes); + excludeEditor.setIgnoredLineIndexes(uiState.ignoredExcludeLineIndexes); + }; + + renderRuleLimitsState(); this.renderPathTester(); } @@ -215,9 +308,11 @@ export class ForceReadModeSettingTab extends PluginSettingTab { description: string, rules: string[], onChange: (value: string) => Promise, - ): void { + onTextInput?: (value: string) => void, + ): { setIgnoredLineIndexes: (lineIndexes: number[]) => void } { const initialText = stringifyRules(rules); let currentText = initialText; + let ignoredLineIndexes = new Set(); const sectionEl = this.containerEl.createDiv({ cls: 'read-only-view-rule-section' }); new Setting(sectionEl).setName(title).setHeading(); @@ -259,7 +354,11 @@ export class ForceReadModeSettingTab extends PluginSettingTab { diagnosticsEl.setAttr('aria-live', 'polite'); const renderDiagnostics = () => { - const entries = buildRuleDiagnostics(currentText, this.plugin.settings.useGlobPatterns); + const entries = buildRuleDiagnosticsWithIgnoredLines( + currentText, + this.plugin.settings.useGlobPatterns, + ignoredLineIndexes, + ); renderDiagnosticsList(diagnosticsEl, entries); }; @@ -267,19 +366,29 @@ export class ForceReadModeSettingTab extends PluginSettingTab { textAreaEl.addEventListener('input', () => { currentText = textAreaEl.value; + onTextInput?.(currentText); saver.schedule(currentText); renderDiagnostics(); }); textAreaEl.addEventListener('change', () => { currentText = textAreaEl.value; + onTextInput?.(currentText); void saver.flush(currentText); renderDiagnostics(); }); textAreaEl.addEventListener('blur', () => { currentText = textAreaEl.value; + onTextInput?.(currentText); void saver.flush(currentText); renderDiagnostics(); }); + + return { + setIgnoredLineIndexes: (lineIndexes: number[]) => { + ignoredLineIndexes = new Set(lineIndexes); + renderDiagnostics(); + }, + }; } private renderPathTester(): void { diff --git a/styles.css b/styles.css index 88502da..6bd5d25 100644 --- a/styles.css +++ b/styles.css @@ -15,6 +15,31 @@ If your plugin does not need CSS, delete this file. margin-bottom: 1rem; } +.read-only-view-rules-summary { + margin: 0.25rem 0 0.5rem; + color: var(--text-muted); + font-size: 0.9rem; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.read-only-view-rule-warning-banner { + display: none; + margin: 0.25rem 0 0.5rem; + padding: 0.4rem 0.5rem; + border-radius: 6px; + border: 1px solid var(--background-modifier-border); + background: var(--background-secondary); + color: var(--text-warning); + font-size: 0.88rem; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.read-only-view-rule-warning-banner.is-visible { + display: block; +} + .read-only-view-rule-section textarea { box-sizing: border-box; min-height: 8rem; @@ -40,8 +65,27 @@ If your plugin does not need CSS, delete this file. color: var(--text-warning); } +.read-only-view-diagnostics-item-ignored { + background: var(--background-secondary); + border-radius: 6px; + padding: 0.2rem 0.35rem; +} + .read-only-view-diagnostics-summary { line-height: 1.4; + overflow-wrap: anywhere; +} + +.read-only-view-diagnostics-ignored-pill { + display: inline-block; + margin-left: 0.3rem; + padding: 0.05rem 0.35rem; + border: 1px solid var(--background-modifier-border); + border-radius: 999px; + font-size: 0.75rem; + line-height: 1.2; + color: var(--text-muted); + background: var(--background-primary-alt); } .read-only-view-diagnostics-warnings { @@ -75,6 +119,11 @@ If your plugin does not need CSS, delete this file. min-height: 9rem; } + .read-only-view-rules-summary, + .read-only-view-rule-warning-banner { + font-size: 0.86rem; + } + .read-only-view-rule-diagnostics { max-height: 12rem; } @@ -101,7 +150,9 @@ If your plugin does not need CSS, delete this file. .read-only-view-diagnostics-summary, .read-only-view-diagnostics-warning, - .read-only-view-path-tester-result { + .read-only-view-path-tester-result, + .read-only-view-rules-summary, + .read-only-view-rule-warning-banner { font-size: 0.95rem; } } diff --git a/tests/enforcement.test.ts b/tests/enforcement.test.ts index c38a11c..18edcd1 100644 --- a/tests/enforcement.test.ts +++ b/tests/enforcement.test.ts @@ -115,3 +115,33 @@ test('service contract: fallback logging keeps redacted path format', async () = assert.equal(typeof fallbackLog.payload?.errorType, 'string'); assert.equal(typeof fallbackLog.payload?.errorMessage, 'string'); }); + +test('service contract: preview check uses view state mode without forcing getMode call', async () => { + const leaf = createMockWorkspaceLeaf({ filePath: 'docs/file.md', mode: 'preview' }); + leaf.view.getMode = () => { + throw new Error('getMode should not be called for preview state check'); + }; + + const setup = createService({ leaves: [leaf] }); + await setup.service.applyAllOpenMarkdownLeaves('mode-check'); + + assert.equal(leaf.setViewStateCalls.length, 0); +}); + +test('service contract: layout-change reason uses extended per-leaf throttle', async () => { + const leaf = createMockWorkspaceLeaf({ filePath: 'docs/file.md', mode: 'source' }); + const nowValues = [1000, 1300, 1701]; + let nowIndex = 0; + const setup = createService({ + leaves: [leaf], + now: () => nowValues[Math.min(nowIndex++, nowValues.length - 1)] ?? 0, + }); + + await setup.service.applyAllOpenMarkdownLeaves('workspace-events:layout-change'); + leaf.setMode('source'); + await setup.service.applyAllOpenMarkdownLeaves('workspace-events:layout-change'); + leaf.setMode('source'); + await setup.service.applyAllOpenMarkdownLeaves('workspace-events:layout-change'); + + assert.equal(leaf.setViewStateCalls.length, 2); +}); diff --git a/tests/helpers/prepare-obsidian-runtime.mjs b/tests/helpers/prepare-obsidian-runtime.mjs index 1a08581..1f0bef8 100644 --- a/tests/helpers/prepare-obsidian-runtime.mjs +++ b/tests/helpers/prepare-obsidian-runtime.mjs @@ -63,6 +63,8 @@ const mainPath = path.join(scriptDir, '..', '..', 'build-tests', 'src', 'main.js const mainSource = await readFile(mainPath, 'utf8'); const patchedMainSource = mainSource .replace("'./matcher'", "'./matcher.js'") + .replace("'./path-utils'", "'./path-utils.js'") + .replace("'./rule-limits'", "'./rule-limits.js'") .replace("'./command-controls'", "'./command-controls.js'") .replace("'./enforcement'", "'./enforcement.js'") .replace("'./popover-observer'", "'./popover-observer.js'") @@ -72,18 +74,35 @@ await writeFile(mainPath, patchedMainSource, 'utf8'); const enforcementPath = path.join(scriptDir, '..', '..', 'build-tests', 'src', 'enforcement.js'); const enforcementSource = await readFile(enforcementPath, 'utf8'); const patchedEnforcementSource = enforcementSource - .replace("'./matcher'", "'./matcher.js'"); + .replace("'./matcher'", "'./matcher.js'") + .replace("'./rule-limits'", "'./rule-limits.js'"); await writeFile(enforcementPath, patchedEnforcementSource, 'utf8'); const settingsTabPath = path.join(scriptDir, '..', '..', 'build-tests', 'src', 'settings-tab.js'); const settingsTabSource = await readFile(settingsTabPath, 'utf8'); const patchedSettingsTabSource = settingsTabSource .replace("'./matcher'", "'./matcher.js'") - .replace("'./rule-diagnostics'", "'./rule-diagnostics.js'"); + .replace("'./rule-diagnostics'", "'./rule-diagnostics.js'") + .replace("'./rule-limits'", "'./rule-limits.js'"); await writeFile(settingsTabPath, patchedSettingsTabSource, 'utf8'); const ruleDiagnosticsPath = path.join(scriptDir, '..', '..', 'build-tests', 'src', 'rule-diagnostics.js'); const ruleDiagnosticsSource = await readFile(ruleDiagnosticsPath, 'utf8'); const patchedRuleDiagnosticsSource = ruleDiagnosticsSource - .replace("'./matcher'", "'./matcher.js'"); + .replace("'./matcher'", "'./matcher.js'") + .replace("'./rule-limits'", "'./rule-limits.js'"); await writeFile(ruleDiagnosticsPath, patchedRuleDiagnosticsSource, 'utf8'); + +const matcherPath = path.join(scriptDir, '..', '..', 'build-tests', 'src', 'matcher.js'); +const matcherSource = await readFile(matcherPath, 'utf8'); +const patchedMatcherSource = matcherSource + .replace("'./path-utils'", "'./path-utils.js'") + .replace("'./rule-limits'", "'./rule-limits.js'"); +await writeFile(matcherPath, patchedMatcherSource, 'utf8'); + +const ruleLimitsPath = path.join(scriptDir, '..', '..', 'build-tests', 'src', 'rule-limits.js'); +const ruleLimitsSource = await readFile(ruleLimitsPath, 'utf8'); +const patchedRuleLimitsSource = ruleLimitsSource + .replace("'./constants'", "'./constants.js'") + .replace("'./path-utils'", "'./path-utils.js'"); +await writeFile(ruleLimitsPath, patchedRuleLimitsSource, 'utf8'); diff --git a/tests/main.observer.test.ts b/tests/main.observer.test.ts index 5c570e8..e2d3231 100644 --- a/tests/main.observer.test.ts +++ b/tests/main.observer.test.ts @@ -298,6 +298,92 @@ test('workspace event burst is coalesced into one reapply pass', async () => { } }); +test('active-leaf-change uses targeted leaf reapply and skips full-scan reapply', async () => { + const { harness, leaf, plugin } = createObserverPlugin(); + const reapplyReasons: string[] = []; + + plugin.loadSettings = async () => undefined; + plugin.applyAllOpenMarkdownLeaves = async (reason: string) => { + reapplyReasons.push(reason); + }; + plugin.registerEvent = () => undefined; + (plugin as unknown as { addCommand: (command: unknown) => unknown }).addCommand = () => ({}); + + try { + await withFakeTimeouts(async ({ flushAll }) => { + await plugin.onload(); + assert.deepEqual(reapplyReasons, ['onload']); + + harness.workspace.trigger('active-leaf-change', leaf); + await Promise.resolve(); + await flushAll(); + + assert.deepEqual(reapplyReasons, ['onload']); + }); + } finally { + harness.restore(); + } +}); + +test('active-leaf-change + file-open uses targeted leaf reapply and skips full-scan reapply', async () => { + const { harness, leaf, plugin } = createObserverPlugin(); + const reapplyReasons: string[] = []; + + plugin.loadSettings = async () => undefined; + plugin.applyAllOpenMarkdownLeaves = async (reason: string) => { + reapplyReasons.push(reason); + }; + plugin.registerEvent = () => undefined; + (plugin as unknown as { addCommand: (command: unknown) => unknown }).addCommand = () => ({}); + + try { + await withFakeTimeouts(async ({ flushAll }) => { + await plugin.onload(); + assert.deepEqual(reapplyReasons, ['onload']); + + harness.workspace.trigger('active-leaf-change', leaf); + harness.workspace.trigger('file-open'); + await Promise.resolve(); + await flushAll(); + + assert.deepEqual(reapplyReasons, ['onload']); + }); + } finally { + harness.restore(); + } +}); + +test('layout-change in burst keeps full-scan reapply', async () => { + const { harness, leaf, plugin } = createObserverPlugin(); + const reapplyReasons: string[] = []; + + plugin.loadSettings = async () => undefined; + plugin.applyAllOpenMarkdownLeaves = async (reason: string) => { + reapplyReasons.push(reason); + }; + plugin.registerEvent = () => undefined; + (plugin as unknown as { addCommand: (command: unknown) => unknown }).addCommand = () => ({}); + + try { + await withFakeTimeouts(async ({ flushAll }) => { + await plugin.onload(); + assert.deepEqual(reapplyReasons, ['onload']); + + harness.workspace.trigger('active-leaf-change', leaf); + harness.workspace.trigger('file-open'); + harness.workspace.trigger('layout-change'); + await Promise.resolve(); + await flushAll(); + + assert.equal(reapplyReasons.length, 2); + assert.ok(reapplyReasons[1]?.startsWith('workspace-events:')); + assert.ok(reapplyReasons[1]?.includes('layout-change')); + }); + } finally { + harness.restore(); + } +}); + test('re-apply command remains immediate and bypasses workspace event scheduler', async () => { const { harness, plugin } = createObserverPlugin(); const reapplyReasons: string[] = []; diff --git a/tests/rule-diagnostics.test.ts b/tests/rule-diagnostics.test.ts index 045fe85..84cedd4 100644 --- a/tests/rule-diagnostics.test.ts +++ b/tests/rule-diagnostics.test.ts @@ -70,3 +70,20 @@ test('path tester helper returns include/exclude matches and final read-only sta assert.deepEqual(excluded.excludeMatches, ['docs/private/**']); assert.equal(excluded.finalReadOnly, false); }); + +test('path tester uses effective rules and does not match ignored tail rules', () => { + const includeRules = Array.from({ length: 200 }, (_, index) => `notes/${index}.md`); + includeRules.push('notes/ignored.md'); + const settings = { + ...DEFAULT_SETTINGS, + enabled: true, + useGlobPatterns: true, + caseSensitive: true, + includeRules, + excludeRules: [], + }; + + const result = buildPathTesterResult('notes/ignored.md', settings); + assert.deepEqual(result.includeMatches, []); + assert.equal(result.finalReadOnly, false); +}); diff --git a/tests/rule-limits.test.ts b/tests/rule-limits.test.ts new file mode 100644 index 0000000..ede6319 --- /dev/null +++ b/tests/rule-limits.test.ts @@ -0,0 +1,78 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { DEFAULT_SETTINGS, shouldForceReadOnly, type ForceReadModeSettings } from '../src/matcher.js'; +import { buildEffectiveRules } from '../src/rule-limits.js'; + +function makeRules(count: number, prefix: string): string[] { + return Array.from({ length: count }, (_, index) => `${prefix}/${index}.md`); +} + +function createSettings(overrides: Partial): ForceReadModeSettings { + return { + ...DEFAULT_SETTINGS, + enabled: true, + useGlobPatterns: true, + caseSensitive: true, + includeRules: [], + excludeRules: [], + ...overrides, + }; +} + +test('rule limits: small lists keep all rules and warning level stays none', () => { + const zero = buildEffectiveRules([], []); + assert.equal(zero.counts.totalUsed, 0); + assert.equal(zero.counts.totalIgnored, 0); + assert.equal(zero.warningLevel, 'none'); + + const ten = buildEffectiveRules(makeRules(10, 'include'), []); + assert.equal(ten.counts.includeUsed, 10); + assert.equal(ten.counts.totalIgnored, 0); + assert.equal(ten.warningLevel, 'none'); +}); + +test('rule limits: include over 50 triggers soft warning', () => { + const result = buildEffectiveRules(makeRules(51, 'include'), []); + assert.equal(result.warningLevel, 'soft'); +}); + +test('rule limits: exclude over 150 triggers strong warning', () => { + const result = buildEffectiveRules([], makeRules(151, 'exclude')); + assert.equal(result.warningLevel, 'strong'); +}); + +test('rule limits: include hard cap keeps 200 and ignores rest', () => { + const result = buildEffectiveRules(makeRules(201, 'include'), []); + assert.equal(result.counts.includeUsed, 200); + assert.equal(result.counts.includeIgnored, 1); + assert.equal(result.counts.totalIgnored, 1); +}); + +test('rule limits: exclude hard cap keeps 300 and ignores rest', () => { + const result = buildEffectiveRules([], makeRules(301, 'exclude')); + assert.equal(result.counts.excludeUsed, 300); + assert.equal(result.counts.excludeIgnored, 1); + assert.equal(result.counts.totalIgnored, 1); +}); + +test('rule limits: total cap preserves include then trims exclude tail', () => { + const result = buildEffectiveRules(makeRules(200, 'include'), makeRules(300, 'exclude')); + assert.equal(result.counts.includeUsed, 200); + assert.equal(result.counts.excludeUsed, 200); + assert.equal(result.counts.totalUsed, 400); + assert.equal(result.counts.excludeIgnored, 100); + assert.equal(result.counts.totalIgnored, 100); +}); + +test('matching uses only effective rules and ignores truncated tail', () => { + const includeRules = makeRules(200, 'notes'); + includeRules.push('secret/blocked.md'); + const settings = createSettings({ + includeRules, + excludeRules: [], + }); + + assert.equal(shouldForceReadOnly('secret/blocked.md', settings), false); + assert.equal(shouldForceReadOnly('notes/10.md', settings), true); +}); diff --git a/tests/settings-tab-ui-state.test.ts b/tests/settings-tab-ui-state.test.ts new file mode 100644 index 0000000..742e388 --- /dev/null +++ b/tests/settings-tab-ui-state.test.ts @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { computeRuleLimitsUiState } from '../src/settings-tab.js'; + +function rulesText(prefix: string, count: number): string { + return Array.from({ length: count }, (_, index) => `${prefix}/${index}.md`).join('\n'); +} + +test('settings ui state: small rule sets have plain summary and no warnings', () => { + const state = computeRuleLimitsUiState('docs/a.md\ndocs/b.md', 'docs/private/a.md'); + assert.equal(state.summaryText, 'Include: 2 rules · Exclude: 1 rules · Total: 3'); + assert.equal(state.volumeWarningMessage, null); + assert.equal(state.hardCapWarningMessage, null); + assert.deepEqual(state.ignoredIncludeLineIndexes, []); + assert.deepEqual(state.ignoredExcludeLineIndexes, []); +}); + +test('settings ui state: include over 50 shows soft volume warning', () => { + const state = computeRuleLimitsUiState(rulesText('include', 51), ''); + assert.equal( + state.volumeWarningMessage, + 'Many rules. Consider merging rules and using ** to simplify.', + ); +}); + +test('settings ui state: exclude over 150 shows strong volume warning', () => { + const state = computeRuleLimitsUiState('', rulesText('exclude', 151)); + assert.equal( + state.volumeWarningMessage, + 'Very many rules. This may slow down Obsidian, especially on mobile. Consider merging rules and using **.', + ); +}); + +test('settings ui state: hard caps expose ignored suffix and ignored indexes', () => { + const state = computeRuleLimitsUiState(rulesText('include', 201), ''); + assert.equal(state.summaryText, 'Include: 200 rules · Exclude: 0 rules · Total: 200 (+1 ignored)'); + assert.equal(state.hardCapWarningMessage, 'Too many rules. Extra lines are ignored.'); + assert.deepEqual(state.ignoredIncludeLineIndexes, [200]); + assert.deepEqual(state.ignoredExcludeLineIndexes, []); +}); + +test('settings ui state: total cap keeps include and trims exclude tail indexes', () => { + const state = computeRuleLimitsUiState(rulesText('include', 200), rulesText('exclude', 300)); + assert.equal(state.summaryText, 'Include: 200 rules · Exclude: 200 rules · Total: 400 (+100 ignored)'); + assert.equal(state.hardCapWarningMessage, 'Too many rules. Extra lines are ignored.'); + assert.equal(state.ignoredIncludeLineIndexes.length, 0); + assert.equal(state.ignoredExcludeLineIndexes.length, 100); + assert.equal(state.ignoredExcludeLineIndexes[0], 200); + assert.equal(state.ignoredExcludeLineIndexes[99], 299); +});