fix: keep the composer's proofreading off the plugin store, and safe with a stand-in editor #1397

This commit is contained in:
Linus Rath
2026-11-02 21:08:35 +01:00
parent dfbeeed142
commit f0477bc4fd
5 changed files with 57 additions and 9 deletions
@@ -194,21 +194,21 @@ export function createTextCheckExtension(options: () => TextCheckOptions) {
/** Put the checkers' issues on the editor, replacing what was there. */
export function setTextIssues(editor: Editor, issues: TextIssueRange[]): void {
if (editor.isDestroyed) return;
if (editor.isDestroyed || !editor.state) return;
const { tr } = editor.state;
editor.view.dispatch(tr.setMeta(textCheckKey, { issues } satisfies Meta).setMeta('addToHistory', false));
}
/** Mark the issue whose popup is open (or none). */
export function setActiveIssue(editor: Editor, key: string | null): void {
if (editor.isDestroyed) return;
if (editor.isDestroyed || !editor.state) return;
const { tr } = editor.state;
editor.view.dispatch(tr.setMeta(textCheckKey, { active: key } satisfies Meta).setMeta('addToHistory', false));
}
/** The issues drawn now, in document order, with their current ranges. */
export function drawnIssues(state: EditorState): TextIssueRange[] {
const set = textCheckKey.getState(state)?.decos;
export function drawnIssues(state: EditorState | undefined): TextIssueRange[] {
const set = state ? textCheckKey.getState(state)?.decos : undefined;
if (!set) return [];
return set.find().map((d) => {
const spec = d.spec as IssueSpec;
@@ -18,7 +18,7 @@ import { actionsFor, subscribeActions } from "@/lib/plugin-sandbox/actions";
import { all as allActivePlugins, subscribe as subscribeActivePlugins } from "@/lib/plugin-sandbox/registry";
import { replaceTextareaRange, type PlainOwnOptions } from "@/lib/plugin-sandbox/composer-edit";
import { usePolicyStore } from "@/stores/policy-store";
import { usePluginStore } from "@/stores/plugin-store";
import { knownPlugins, subscribeKnownPlugins } from "@/lib/plugin-sandbox/known-plugins";
import type { InstalledPlugin } from "@/lib/plugin-types";
import { useSettingsStore } from "@/stores/settings-store";
import {
@@ -48,7 +48,7 @@ export function policySettled(s: Pick<ReturnType<typeof usePolicyStore.getState>
/** Plugins turned on that aren't running yet (still loading). */
function startingPlugins(running: InstalledPlugin[]): InstalledPlugin[] {
const up = new Set(running.map((p) => p.id));
return usePluginStore.getState().plugins.filter((p) => p.enabled && p.status !== 'error' && p.status !== 'disabled' && !up.has(p.id));
return knownPlugins().filter((p) => p.enabled && p.status !== 'error' && p.status !== 'disabled' && !up.has(p.id));
}
// The per-composer switch outlives a remount (pop-out, layout switch).
@@ -148,7 +148,7 @@ export function useProofreading({ composerId, sessionKey, editor, textareaRef, p
subscribeCheckers(() => p.sync()),
subscribeActions(() => p.gateChanged()),
subscribeActivePlugins(() => p.gateChanged()),
usePluginStore.subscribe(() => p.gateChanged()),
subscribeKnownPlugins(() => p.gateChanged()),
usePolicyStore.subscribe(() => p.gateChanged()),
useSettingsStore.subscribe((s, prev) => {
if (s.writingLanguage !== prev.writingLanguage) p.edited("now");
@@ -205,7 +205,7 @@ export function useProofreading({ composerId, sessionKey, editor, textareaRef, p
// Underlines in the rich editor.
const drawnRef = useRef("");
useEffect(() => {
if (!editor || editor.isDestroyed || plainTextMode) return;
if (!editor || editor.isDestroyed || !editor.state || plainTextMode) return;
const ranges = issues.map((i) => ({ key: i.key, from: i.from, to: i.to, kind: i.kind, text: i.annotation.text }));
const signature = ranges.map((r) => `${r.key}@${r.from}`).join(",");
if (signature === drawnRef.current) return;
@@ -217,7 +217,7 @@ export function useProofreading({ composerId, sessionKey, editor, textareaRef, p
// and the list and the count follow.
const [drawnKeys, setDrawnKeys] = useState<string[] | null>(null);
useEffect(() => {
if (!editor || editor.isDestroyed || plainTextMode) { setDrawnKeys(null); return; }
if (!editor || editor.isDestroyed || !editor.state || plainTextMode) { setDrawnKeys(null); return; }
const read = () => {
const keys = drawnIssues(editor.state).map((r) => r.key);
setDrawnKeys((prev) => (prev && prev.length === keys.length && prev.every((k, i) => k === keys[i]) ? prev : keys));
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { usePluginStore } from '@/stores/plugin-store';
import { knownPlugins } from '@/lib/plugin-sandbox/known-plugins';
import type { InstalledPlugin } from '@/lib/plugin-types';
// The composer's proofreading reads the installed plugins from here, so it
// knows which signing plugin is still starting, without importing the store.
describe('known plugins', () => {
it('follow the plugin store', () => {
const smime = { id: 'smime', name: 'S/MIME', permissions: ['crypto:full'], enabled: true, status: 'enabled' } as unknown as InstalledPlugin;
usePluginStore.setState({ plugins: [smime] });
expect(knownPlugins()).toEqual([smime]);
usePluginStore.setState({ plugins: [] });
expect(knownPlugins()).toEqual([]);
});
});
+26
View File
@@ -0,0 +1,26 @@
// The user's installed plugins as the plugin store has them, for code that
// needs the list without loading the store and everything it imports (the
// composer's proofreading checks which plugins are turned on but not running
// yet). The plugin store keeps it current.
import type { InstalledPlugin } from '../plugin-types';
let plugins: readonly InstalledPlugin[] = [];
const listeners = new Set<() => void>();
export function setKnownPlugins(next: readonly InstalledPlugin[]): void {
if (next === plugins) return;
plugins = next;
for (const l of listeners) {
try { l(); } catch { /* ignore */ }
}
}
export function knownPlugins(): readonly InstalledPlugin[] {
return plugins;
}
export function subscribeKnownPlugins(listener: () => void): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
+5
View File
@@ -1,6 +1,7 @@
// Plugin store - manages installed plugins and lifecycle. Slot registrations
// are owned by `lib/plugin-sandbox/registry` (per-iframe), not by the store.
import { setKnownPlugins } from '@/lib/plugin-sandbox/known-plugins';
import { cancelConnectFor, forgetCredentials } from '@/lib/plugin-sandbox/connect-dialog';
import { create } from 'zustand';
import { persist, type PersistStorage, type StorageValue } from 'zustand/middleware';
@@ -876,3 +877,7 @@ if (!IS_LITE) {
},
});
}
// The list for code that shouldn't import this store (lib/plugin-sandbox/known-plugins.ts).
setKnownPlugins(usePluginStore.getState().plugins);
usePluginStore.subscribe((state) => setKnownPlugins(state.plugins));