mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 02:42:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4eb33856b | ||
|
|
b14b65679f |
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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 };
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
|
||||
Reference in new issue
Block a user