Compare commits

...
Author SHA1 Message Date
陈奇 e4eb33856b feat(plugins): share borderless installed settings rows
Apply the exact six-file patch from implement_plugin_row_layout, based on 0c05a96fa1. Patch SHA-256: 5c58150fc3a7f72169b1a23ad1410cf37c51051b29bd6600bf30e848a9f366d6. Native visual acceptance remains pending.
2026-10-27 12:19:09 +00:00
陈奇 b14b65679f fix(plugins): invalidate mounted author identity on catalog changes 2026-10-27 12:18:27 +00:00
12 changed files with 286 additions and 32 deletions

No files matched your search

+20 -2
View File
@@ -1,6 +1,22 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState, useSyncExternalStore } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
// All visible rows share one IPC subscription. No catalog fetch or polling here.
const generations = new Map<string, number>();
const listeners = new Set<() => void>();
let unsubscribe: (() => void) | undefined;
function subscribe(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1) unsubscribe = netcattyBridge.get()?.onPluginCatalogChanged?.(({ sourceId }) => {
generations.set(sourceId, (generations.get(sourceId) ?? 0) + 1);
for (const notify of listeners) notify();
});
return () => {
listeners.delete(listener);
if (!listeners.size) { unsubscribe?.(); unsubscribe = undefined; generations.clear(); }
};
}
export function usePluginAuthorIdentity({ entry, plugin, sourceId = 'official' }: {
entry?: NetcattyPluginCatalogEntry; plugin?: NetcattyInstalledPlugin; sourceId?: string;
}) {
@@ -13,7 +29,9 @@ export function usePluginAuthorIdentity({ entry, plugin, sourceId = 'official' }
? { kind: 'installed', pluginId }
: entryId && selectionToken ? { kind: 'catalog', pluginId: entryId, selectionToken, sourceId } : null,
[pluginId, entryId, selectionToken, sourceId]);
const key = JSON.stringify([request, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source?.id, plugin?.runtime?.quarantinedAt]);
const identitySource = plugin?.source?.id ?? sourceId;
const generation = useSyncExternalStore(subscribe, () => generations.get(identitySource) ?? 0, () => 0);
const key = JSON.stringify([request, plugin?.activeVersion, plugin?.archiveSha256, plugin?.source?.id, plugin?.runtime?.quarantinedAt, generation]);
const installed = Boolean(plugin);
const version = plugin?.activeVersion;
const archiveSha256 = plugin?.archiveSha256 ?? null;
+31
View File
@@ -0,0 +1,31 @@
import React from 'react';
import { ChevronRight } from 'lucide-react';
import { PluginBrandIcon } from './PluginBrandIcon';
/** Shared installed-plugin composition; navigation never wraps the quick actions. */
export function PluginListRow({ plugin, name, description, metadata, actions, openLabel, onOpen }: {
plugin: NetcattyInstalledPlugin;
name: string;
description: string;
metadata?: React.ReactNode;
actions: React.ReactNode;
openLabel?: string;
onOpen?: () => void;
}) {
const heading = <span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
<span className="min-w-0 max-w-full text-sm font-medium leading-5 [overflow-wrap:anywhere]">{name}</span>
{plugin.activeVersion && <span className="inline-flex max-w-full items-center rounded-full border-0 bg-muted/60 px-2 py-0 text-[11px] font-normal leading-5 text-muted-foreground hover:bg-muted/60 [overflow-wrap:anywhere]" data-plugin-row-tag>{plugin.activeVersion}</span>}
</span>;
return <article aria-label={name} data-plugin-row={plugin.id} className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)_auto] items-start gap-x-3 rounded-lg py-4 sm:gap-x-4">
<div data-plugin-icon-slot className="flex h-12 w-12 shrink-0 items-center justify-center text-muted-foreground"><PluginBrandIcon plugin={plugin} size={32} /></div>
<div className="min-w-0 space-y-1.5">
{onOpen ? <button type="button" data-plugin-card={plugin.id} aria-label={openLabel} aria-expanded={false} onClick={onOpen}
className="group block w-full min-w-0 rounded-md text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<span className="flex min-w-0 items-center gap-2"><span className="min-w-0 flex-1">{heading}</span><ChevronRight size={14} className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" aria-hidden="true" /></span>
<span className="mt-1 block line-clamp-2 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]" title={description}>{description}</span>
</button> : <div>{heading}<p className="mt-1 line-clamp-2 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]" title={description}>{description}</p></div>}
{metadata && <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs leading-5 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [overflow-wrap:anywhere]">{metadata}</div>}
</div>
<div className="flex min-h-12 shrink-0 items-center justify-end gap-2 self-center" data-plugin-row-actions>{actions}</div>
</article>;
}
@@ -0,0 +1,122 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { JSDOM } from 'jsdom';
import { EventEmitter } from 'node:events';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRequire } from 'node:module';
import { usePluginAuthorIdentity } from '../../../application/state/usePluginAuthorIdentity';
import { checkPluginUpdates } from '../../../infrastructure/services/pluginUpdateCheck';
const require = createRequire(import.meta.url);
const { PluginDatabase } = require('../../../electron/plugins/database.cjs');
const { MarketSourcesService } = require('../../../electron/plugins/marketSourcesService.cjs');
const { OFFICIAL_SOURCE } = require('../../../electron/plugins/sourceIdentity.cjs');
const { registerPluginBridge, CHANNELS } = require('../../../electron/plugins/pluginBridge.cjs');
const { createPreloadApi } = require('../../../electron/preload/api.cjs');
const { catalog, delivery, makeFiles, read, digest } = require('../../../electron/plugins/fixtures/staticDistributionFixture.cjs');
test('mounted installed authors follow actual catalog/update/source entrypoints without changing package identity', async () => {
const rootPath = mkdtempSync(join(tmpdir(), 'netcatty-author-refresh-'));
const database = new PluginDatabase(join(rootPath, 'plugins.sqlite'));
const dom = new JSDOM('<!doctype html><div id="root"></div>', { pretendToBeVisual: true });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document,
navigator: dom.window.navigator, HTMLElement: dom.window.HTMLElement, Element: dom.window.Element,
Node: dom.window.Node, IntersectionObserver: undefined, IS_REACT_ACT_ENVIRONMENT: true })) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
}
const entry = structuredClone(catalog[0]);
entry.artifact = { sha256: 'a'.repeat(64), url: `${OFFICIAL_SOURCE.origin}/assets/${'a'.repeat(64)}.ncpkg` };
const bytes = read(delivery.plugins[0].iconLight.path.slice(1));
const avatar = { path: `/assets/${digest(bytes)}.png`, sha256: digest(bytes), size: bytes.length };
const filesFor = (entries = [entry], image = true) => {
const files = makeFiles(entries, (index: { plugins: Array<{ id: string; versions: Array<{ distribution: string }> }> }, sidecar: { schemaVersion: number; plugins: Array<{ id: string; avatar: typeof avatar | null; artifact: { path: string; sha256: string; size: number } }> }) => {
sidecar.schemaVersion = 2;
for (const item of sidecar.plugins) {
item.avatar = image ? avatar : null;
item.artifact = { path: `/assets/${entry.artifact.sha256}.ncpkg`, sha256: entry.artifact.sha256, size: 1 };
index.plugins.find((plugin) => plugin.id === item.id).versions[0].distribution = 'release';
}
});
files.set(avatar.path, bytes); return files;
};
let files = filesFor();
database.installVersion({ pluginId: entry.id, version: entry.version, runtimeKind: "browser", apiVersion: "0.1.0-internal.1", manifest: entry.manifest, archiveSha256: entry.artifact.sha256,
packageRelativePath: 'packages/synthetic', source: OFFICIAL_SOURCE }, { enable: true });
const manager = { initialize: async () => {}, list: async () => database.listPlugins(),
changeMarketSource: async (change: () => unknown) => change() };
let avatarReads = 0;
const service = new MarketSourcesService({ database, manager,
fetch: async (url: string) => { assert.equal(new URL(url).origin, OFFICIAL_SOURCE.origin); return new Response(files.get(new URL(url).pathname)); },
createSourceFetch: (origin: string) => async (url: string) => { assert.equal(new URL(url).origin, origin); return new Response(files.get(new URL(url).pathname)); },
resolveAvatar: async () => { avatarReads++; return 'data:image/png;base64,eA=='; } });
const ipcMain = { handlers: new Map(), handle(channel: string, handler: unknown) { this.handlers.set(channel, handler); }, on() {} };
const events = new EventEmitter();
registerPluginBridge(ipcMain, { manager, catalogService: service, isTrustedSender: () => true,
broadcast: (channel: string, payload: unknown) => events.emit(channel, {}, payload) });
const api = createPreloadApi({ ipcRenderer: { on: events.on.bind(events), removeListener: events.removeListener.bind(events),
invoke: (channel: string, payload: unknown) => ipcMain.handlers.get(channel)({}, payload) } });
let authorCalls = 0;
const original = api.getPluginAuthorPresentation;
api.getPluginAuthorPresentation = (request: NetcattyPluginAuthorRequest) => { authorCalls++; return original(request); };
Object.assign(window, { netcatty: api });
let plugin = database.getActivePlugin(entry.id) as NetcattyInstalledPlugin;
function Row() {
const { ref, identity } = usePluginAuthorIdentity({ plugin });
return <div ref={ref} data-author={identity?.author} data-avatar={identity?.avatar} data-official={identity?.officialMaintained ?? false} />;
}
const { act } = await import('react');
const { createRoot } = await import('react-dom/client');
const root = createRoot(document.getElementById('root')!);
const row = () => document.querySelector<HTMLElement>('[data-official]')!;
try {
await act(async () => root.render(<><Row /><Row /></>));
assert.equal(row().dataset.author, entry.manifest.publisher); assert.equal(row().dataset.official, 'false');
assert.equal(avatarReads, 0, 'opening installed rows never fetches a catalog/avatar');
await act(async () => { await checkPluginUpdates(api); });
assert.equal(row().dataset.author, entry.author); assert.equal(row().dataset.official, 'true');
assert.match(row().dataset.avatar!, /^data:image\/png/);
assert.equal(events.listenerCount(CHANNELS.catalogChanged), 1, 'rows share one preload subscription');
const count = authorCalls;
await act(async () => { await api.listPluginCatalog('official'); root.render(<><Row /><Row /></>); });
assert.equal(authorCalls, count, 'equivalent catalog and installation retain request dedupe');
files = filesFor([{ ...entry, author: 'Updated project name' }], false);
await act(async () => { await api.listPluginCatalog('official'); });
assert.equal(row().dataset.author, 'Updated project name'); assert.equal(row().dataset.avatar, undefined);
files = filesFor([]);
await act(async () => { await api.listPluginCatalog('official'); });
assert.equal(row().dataset.author, entry.manifest.publisher); assert.equal(row().dataset.official, 'false');
assert.equal(row().dataset.avatar, undefined, 'withdrawal removes enriched identity without a remount');
const source = await api.addPluginSource({ name: 'Community', address: 'https://community.example.com' });
const community = structuredClone(entry);
community.id = community.manifest.id = 'com.community.identity';
community.artifact.url = `${source.origin}/assets/${community.artifact.sha256}.ncpkg`;
files = filesFor([community]);
database.installVersion({ pluginId: community.id, version: community.version, manifest: community.manifest,
archiveSha256: community.artifact.sha256, packageRelativePath: 'packages/community', source }, { enable: true });
await act(async () => { await api.listPluginCatalog(source.id); });
assert.equal(authorCalls, count + 4, 'unrelated source changes do not reload official rows');
plugin = database.getActivePlugin(community.id);
await act(async () => root.render(<><Row /><Row /></>));
assert.equal(row().dataset.author, community.author); assert.match(row().dataset.avatar!, /^data:image\/png/);
assert.equal(row().dataset.official, 'false', 'copied catalog never gains official maintenance authority');
await act(async () => { await api.updatePluginSource({ id: source.id, name: 'Renamed', enabled: false }); });
assert.equal(row().dataset.author, community.manifest.publisher); assert.equal(row().dataset.avatar, undefined);
await act(async () => { await api.updatePluginSource({ id: source.id, name: 'Renamed', enabled: true }); });
assert.equal(row().dataset.author, community.author);
await act(async () => { await api.removePluginSource(source.id, false); });
assert.equal(row().dataset.author, community.manifest.publisher); assert.equal(row().dataset.avatar, undefined);
await act(async () => root.unmount());
assert.equal(events.listenerCount(CHANNELS.catalogChanged), 0, 'last row releases the shared IPC listener');
} finally {
database.close(); dom.window.close(); rmSync(rootPath, { recursive: true, force: true });
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -1,12 +1,16 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { JSDOM } from 'jsdom';
import { PluginListRow } from '../../plugins/PluginListRow';
import { Toggle } from '../settings-ui';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
test('installed rows filter, open one detail and restore search, scroll and row focus without toggle navigation', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const raf = globalThis.requestAnimationFrame;
@@ -35,11 +39,14 @@ test('installed grid filters, opens one detail and restores search, scroll and c
const authorRow = alphaCard.findByProps({ 'data-plugin-author-row': true });
assert.ok(authorRow.findAllByType('span').some((node) => node.children.includes('Example')), 'author has its own compact row');
assert.ok(authorRow.findByProps({ 'data-plugin-author-placeholder': true }), 'an unverified installed publisher has a default avatar');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.ok(alphaCard.findAllByType('span').some((node) => node.props.className?.includes('line-clamp-2')), 'row description is bounded');
assert.ok(alphaCard.props['data-plugin-row'], 'installed entries use the shared row composition');
assert.match(alphaCard.findByProps({ 'data-plugin-row-tag': true }).props.className, /border-0/);
assert.doesNotMatch(alphaCard.props.className, /bg-card|border|divide/);
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
assert.equal(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')), false, 'installed rows no longer inherit the marketplace grid');
const pill = renderer.root.findAllByType('button').find((node) => node.props.role === 'switch')!;
await act(async () => pill.props.onClick());
assert.deepEqual(toggles, [[plugins[0].id, true]]); assert.deepEqual(selections, []);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'Alpha' } }));
@@ -57,9 +64,39 @@ test('installed grid filters, opens one detail and restores search, scroll and c
assert.equal(cards().length, 0);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: '' } }));
await act(async () => renderer.update(<I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, statusKnown: false, error: 'synthetic state unavailable' }} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>));
const unknown = renderer.root.findAllByType('button').filter((node) => node.children.includes('Status unknown'));
const unknown = renderer.root.findAllByType('button').filter((node) => node.props.role === 'switch');
assert.equal(unknown.length, 2);
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-label'].startsWith('Status unknown')));
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
});
test('long installed-row text keeps baseline tags and independent native-button actions', () => {
const name = `Long plugin ${'unbroken'.repeat(30)}`;
const description = `Description ${'long description '.repeat(30)}`;
const plugin = { id: 'com.test.long', activeVersion: '1.2.3-preview.4', enabled: false, runtime: {} } as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<PluginListRow plugin={plugin} name={name} description={description}
metadata={<span>{'https://source.example/'.repeat(20)}</span>} openLabel={`Details ${name}`} onOpen={() => {}}
actions={<Toggle checked={false} ariaLabel={`Enable ${name}`} onChange={() => {}} />} />);
const dom = new JSDOM(html);
try {
const row = dom.window.document.querySelector('[data-plugin-row]')!;
const navigation = row.querySelector<HTMLButtonElement>('[data-plugin-card]')!;
const toggle = row.querySelector<HTMLButtonElement>('[role=switch]')!;
const tag = row.querySelector('[data-plugin-row-tag]')!;
assert.equal(navigation.type, 'button');
assert.equal(toggle.type, 'button');
assert.equal(navigation.contains(toggle), false, 'Enter/Space on the switch cannot invoke row navigation');
assert.match(navigation.className, /focus-visible:ring-2/);
assert.match(toggle.className, /focus-visible:ring-2/);
assert.match(tag.parentElement!.className, /items-baseline/);
assert.equal(tag.previousElementSibling!.textContent, name, 'long names stay available without title truncation');
assert.match(tag.previousElementSibling!.className, /overflow-wrap:anywhere/);
assert.match(tag.className, /border-0/);
assert.equal(navigation.querySelector('[title]')!.getAttribute('title'), description, 'clamped descriptions retain the full text');
assert.match(row.className, /minmax\(0,1fr\)/);
assert.doesNotMatch(row.querySelector('[data-plugin-icon-slot]')!.className, /border|ring|bg-/);
assert.equal(row.querySelector('button button'), null);
} finally { dom.window.close(); }
});
@@ -4,6 +4,8 @@ import React, { useRef, useState } from 'react';
import { PluginPermissionPanel } from './PluginPermissionPanel';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { PluginListRow } from '../../plugins/PluginListRow';
import { Toggle } from '../settings-ui';
import { ArrowLeft } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
@@ -80,13 +82,25 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 max-w-full rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
<div className={selectedPlugin ? 'space-y-4' : 'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3'}>
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
<div className={selectedPlugin ? 'space-y-4' : 'space-y-1'} data-plugin-row-list={!selectedPlugin || undefined}>
{visible.map((plugin) => !selectedPlugin ? <PluginListRow key={plugin.id} plugin={plugin} name={nameOf(plugin)} description={descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}
openLabel={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`} onOpen={() => choose(plugin.id)}
metadata={<>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<span>{plugin.source ? `${t('settings.plugins.sources.source')}: ${t(plugin.source.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}${plugin.source.origin ? ` · ${plugin.source.origin}` : ''}` : t('settings.plugins.landing.sourceUnknown')}</span>
{plugin.manifest?.license && <span>{plugin.manifest.license}</span>}
{manager.statusKnown === false && <span>{t('settings.plugins.permissions.statusUnknown')}</span>}
{plugin.runtime.lastError && <span role="status" className="w-full text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</span>}
</>}
actions={<Toggle checked={plugin.enabled} disabled={manager.busy || manager.statusKnown === false}
ariaLabel={`${t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${nameOf(plugin)}`}
onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />}
/> : <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className="min-w-0 space-y-4 rounded-lg focus:outline-none">
<div className="flex flex-wrap items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
@@ -106,10 +120,7 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
<p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
@@ -41,6 +41,13 @@ test('landing rows expose real sources/status/settings and quick toggles preserv
await render();
assert.equal(document.querySelector('[role=dialog]'), null);
assert.equal(document.querySelector('input[type=checkbox]'), null);
assert.equal(document.querySelectorAll('[data-plugin-row]').length, 2);
assert.equal(document.querySelectorAll('[data-plugin-icon-slot]').length, 2);
assert.equal(document.querySelector('[data-plugin-row-list]')!.className.includes('border'), false);
const alphaRow = document.querySelector('[data-plugin-row="com.example.alpha"]')!;
assert.ok(alphaRow.querySelector('[data-plugin-row-tag]'));
assert.equal(alphaRow.querySelector('[data-plugin-row-actions] [role=switch]')?.getAttribute('aria-label'), 'Disable Alpha');
assert.equal(alphaRow.querySelector('button button'), null, 'quick actions are never nested inside navigation');
assert.equal(document.querySelectorAll('[role=switch]').length, 2, 'only real plugin toggles appear on landing');
assert.match(document.body.textContent!, /Running/);
assert.match(document.body.textContent!, /Source: Official/);
@@ -5,7 +5,7 @@ import type { usePluginUpdateCheck } from '../../../application/state/usePluginU
import type { usePluginManager } from '../../../application/state/usePluginManager';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { PluginListRow } from '../../plugins/PluginListRow';
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
import { SettingCard, SettingRow, SettingsSection, Toggle } from '../settings-ui';
import { PluginLogSettings } from './PluginLogSettings';
@@ -63,28 +63,24 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
{manager.statusKnown && manager.available && manager.plugins.length === 0 && <p className="text-sm text-muted-foreground">{t('settings.plugins.landing.empty')}</p>}
{manager.plugins.length > 0 && <>
<Input aria-label={t('settings.plugins.landing.search')} placeholder={t('settings.plugins.landing.search')} value={search} onChange={(event) => setSearch(event.target.value)} />
{visible.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <SettingCard divided>
{visible.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <div className="space-y-1" data-plugin-row-list>
{visible.map((plugin) => {
const name = nameOf(plugin);
const description = localized(plugin.manifest?.description);
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} label={<span className="flex min-w-0 items-baseline gap-2">
<span className="truncate" title={name}>{name}</span>
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
</span>} description={<>
return <PluginListRow key={plugin.id} plugin={plugin} name={name} description={description || t('settings.plugins.manager.noDescription')} metadata={<>
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
<span className="block break-words">{[sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
{description && <span className="line-clamp-2 break-words">{description}</span>}
</>}>
<span>{[sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
{availableUpdate && <span className="text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
</>} actions={
<div className="flex items-center gap-3">
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
</div>
</SettingRow>;
} />;
})}
</SettingCard>}
</div>}
</>}
</SettingsSection>
</>;
@@ -6,14 +6,14 @@ import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
test('detail state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const calls: unknown[] = [];
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill' }, runtime: {} } as NetcattyInstalledPlugin;
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill', main: { browser: 'index.js' }, engines: { api: '0.1.0-internal', netcatty: '*' } }, runtime: {} } as NetcattyInstalledPlugin;
const manager = { available: true, plugins: [plugin], busy: false, setEnabled: async (...args: unknown[]) => { calls.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
let renderer: ReactTestRenderer;
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager initialSelectedId={plugin.id} manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
try {
await act(async () => { renderer = create(render(true)); });
const pill = () => renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
+26 -2
View File
@@ -12,6 +12,15 @@ class MarketSourcesService {
this.manager = options.manager;
this.registry = new PluginSourceRegistry(options.database);
this.catalogs = new Map();
this.catalogIdentities = new Map();
this.changeListeners = new Set();
}
onDidChange(listener) {
this.changeListeners.add(listener);
return { dispose: () => this.changeListeners.delete(listener) };
}
#changed(sourceId) {
for (const listener of this.changeListeners) listener({ sourceId });
}
#catalog(sourceId = "official") {
const source = this.registry.get(sourceId);
@@ -25,7 +34,16 @@ class MarketSourcesService {
}
return catalog;
}
list(sourceId) { return this.#catalog(sourceId).list(); }
async list(sourceId = "official") {
const snapshot = await this.#catalog(sourceId).list();
// Selection tokens bind the validated entry and its delivery resources.
const identity = JSON.stringify(snapshot.entries.map((entry) => [entry.id, entry.selectionToken]));
if (this.catalogIdentities.get(sourceId) !== identity) {
this.catalogIdentities.set(sourceId, identity);
this.#changed(sourceId);
}
return snapshot;
}
readme(id, token, sourceId) { return this.#catalog(sourceId).readme(id, token); }
async author(request) {
if (!request || typeof request !== "object" || Array.isArray(request)
@@ -60,13 +78,19 @@ class MarketSourcesService {
}
listSources() { return this.registry.list(); }
addSource(payload) { return this.manager.changeMarketSource(() => this.registry.add(payload?.name, payload?.address)); }
updateSource(payload) { return this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled)); }
async updateSource(payload) {
const source = await this.manager.changeMarketSource(() => this.registry.update(payload?.id, payload?.name, payload?.enabled));
this.#changed(source.id);
return source;
}
async removeSource(payload) {
if (typeof payload?.disablePlugins !== "boolean") throw new Error("Choose whether to disable installed plugins");
await this.manager.changeMarketSource(() => {
this.registry.remove(payload.id);
this.catalogs.delete(payload.id);
this.catalogIdentities.delete(payload.id);
}, payload.disablePlugins ? payload.id : undefined);
this.#changed(payload.id);
}
}
module.exports = { MarketSourcesService };
+2
View File
@@ -41,6 +41,7 @@ const CHANNELS = Object.freeze({
updateSource: "netcatty:plugins:update-source",
removeSource: "netcatty:plugins:remove-source",
catalog: "netcatty:plugins:catalog",
catalogChanged: "netcatty:plugins:catalog-changed",
catalogReadme: "netcatty:plugins:catalog-readme",
author: "netcatty:plugins:author",
catalogInstall: "netcatty:plugins:catalog-install",
@@ -1708,6 +1709,7 @@ function registerPluginBridge(ipcMain, options) {
publishScopeCatalog(event);
return null;
});
options.catalogService?.onDidChange?.((event) => options.broadcast?.(CHANNELS.catalogChanged, event));
contributionService?.onDidChange?.((event) => options.broadcast?.(CHANNELS.contributionsChanged, event));
contributionService?.onDidPostViewMessage?.((event) => options.broadcast?.(CHANNELS.viewMessagePosted, event));
viewHost?.onDidClose?.((event) => options.broadcast?.(CHANNELS.viewClosed, event));
+5
View File
@@ -107,6 +107,11 @@ function createPreloadApi(ctx) {
removePluginSource: (id, disablePlugins) => ipcRenderer.invoke("netcatty:plugins:remove-source", { id, disablePlugins }),
listPluginCatalog: (sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog", { sourceId }),
getPluginAuthorPresentation: (request) => ipcRenderer.invoke("netcatty:plugins:author", request),
onPluginCatalogChanged: (callback) => {
const listener = (_event, payload) => callback(payload);
ipcRenderer.on("netcatty:plugins:catalog-changed", listener);
return () => ipcRenderer.removeListener("netcatty:plugins:catalog-changed", listener);
},
getPluginCatalogReadme: (id, selectionToken, sourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-readme", { id, selectionToken, sourceId }),
installCatalogPlugin: (id, selectionToken, sourceId, acceptedSourceId) => ipcRenderer.invoke("netcatty:plugins:catalog-install", { id, selectionToken, sourceId, acceptedSourceId }),
setPluginAIHostReady: (ready) => ipcRenderer.invoke("netcatty:plugins:ai-host-ready", ready),
Vendored
+1
View File
@@ -490,6 +490,7 @@ declare global {
removePluginSource?(id: string, disablePlugins: boolean): Promise<void>;
listPluginCatalog?(sourceId?: string): Promise<NetcattyPluginCatalogSnapshot>;
getPluginAuthorPresentation?(request: NetcattyPluginAuthorRequest): Promise<NetcattyPluginAuthorPresentation | null>;
onPluginCatalogChanged?(callback: (event: { sourceId: string }) => void): () => void;
getPluginCatalogReadme?(id: string, selectionToken?: string, sourceId?: string): Promise<string>;
installCatalogPlugin?(id: string, selectionToken?: string, sourceId?: string, acceptedSourceId?: string): Promise<NetcattyInstalledPlugin>;
setPluginAIHostReady?(ready: boolean): Promise<boolean>;