Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.

<details>
<summary>Advanced troubleshooting</summary>
Expand Down Expand Up @@ -293,6 +311,7 @@ See repository [Releases](../../releases).
<summary>How enforcement works</summary>

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.
Expand Down
24 changes: 22 additions & 2 deletions docs/PROJECT_STATE.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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:<joined reasons>`.
- 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:
Expand All @@ -113,6 +121,7 @@ Loop protection:

- Global lock (`enforcing`) + pending reason queue (`pendingReapply`)
- Per-leaf throttle (`WeakMap<WorkspaceLeaf, number>`) 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:

Expand All @@ -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

Expand All @@ -140,13 +153,20 @@ 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`
- status text: `Saving...`, `Saved.`, `Save failed.`
- 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
Expand Down
7 changes: 7 additions & 0 deletions just/test.just
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
76 changes: 69 additions & 7 deletions src/enforcement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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) {
Expand Down Expand Up @@ -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)) {
Expand All @@ -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<void> {
Expand All @@ -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;
}

Expand All @@ -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,
Expand All @@ -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,
});
Expand Down
55 changes: 51 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
Plugin,
MarkdownView,
WorkspaceLeaf,
} from 'obsidian';
import {
Expand Down Expand Up @@ -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<typeof setTimeout> | null = null;
private workspaceEventReasons = new Set<string>();
private workspaceEventLeaves = new Set<WorkspaceLeaf>();

async onload(): Promise<void> {
await this.loadSettings();
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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<void> {
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();
}
Expand Down
Loading