feat: support DeepSeek Harness Desktop and refine management UI

- Detect and launch DeepSeek Harness Desktop on Windows
- Improve agent client selection and responsive layouts
- Unify dashboard runtime and connection card styling
- Streamline plugin navigation, cards, and installation dialogs
- Update translations and regression tests
This commit is contained in:
lzt404 committed 2026-10-04 17:31:52 +08:00
1 parent 3b11361f1b
commit 2649df000c
24 files changed
+587 -180

No files matched your search

+4 -2
View File
@@ -1083,13 +1083,15 @@ export const jaOverrides = {
'agents.description.kimiCode': 'CPA の OpenAI Chat Completions API と動的モデルカタログを使用',
'agents.description.grokBuild': 'CPA の OpenAI Chat Completions API とカスタムモデルカタログを使用',
'agents.description.pi': 'CLIProxyAPI プロバイダーを使用して Pi CLI のモデルを動的に読み込みます',
'agents.clients.switch': 'クライアントを切り替え',
'agents.clients.switchHint': 'クライアントを検索して選択します。選択中のクライアントはショートカット一覧に表示されます。',
'agents.clients.manage': 'クライアントを管理',
'agents.clients.description': '一覧に表示するクライアントを選択します。表示のみを変更し、クライアントの設定は変更しません。',
'agents.clients.description': 'ショートカットに優先表示するクライアントを選択します。選択中のクライアントは常に表示され、その他は「クライアントを切り替え」から選べます。',
'agents.clients.search': 'クライアントを検索',
'agents.clients.shown': '{count} / {total} 件を表示',
'agents.clients.selected': '{count} 件のクライアントを選択中',
'agents.clients.automatic': '自動表示に戻す',
'agents.clients.automaticHint': '既定では、インストール済みまたは設定済みのクライアントを表示します。',
'agents.clients.automaticHint': '既定では、インストール済みまたは設定済みのクライアントを優先表示します。',
'agents.clients.minimum': '少なくとも 1 件のクライアントを表示してください。',
'agents.clients.noResults': '一致するクライアントが見つかりません',
'agents.list.detecting': '検出中',
+4 -2
View File
@@ -1083,13 +1083,15 @@ export const en: Record<MessageKey, string> = {
'agents.description.kimiCode': 'Uses the CPA OpenAI Chat Completions API and dynamic model catalog',
'agents.description.grokBuild': 'Uses the CPA OpenAI Chat Completions API and custom model catalog',
'agents.description.pi': 'Loads models dynamically in Pi through the CLIProxyAPI provider',
'agents.clients.switch': 'Switch Client',
'agents.clients.switchHint': 'Search and select a client. Your current selection stays in the shortcut list.',
'agents.clients.manage': 'Manage Clients',
'agents.clients.description': 'Choose which clients appear in the list. This only changes visibility, not client settings.',
'agents.clients.description': 'Choose which clients take priority in the shortcuts. Your current selection stays visible; use Switch Client to access any other client.',
'agents.clients.search': 'Search clients',
'agents.clients.shown': 'Showing {count} / {total}',
'agents.clients.selected': '{count} clients selected',
'agents.clients.automatic': 'Restore automatic display',
'agents.clients.automaticHint': 'Installed or configured clients are shown by default.',
'agents.clients.automaticHint': 'Installed or configured clients take priority by default.',
'agents.clients.minimum': 'Keep at least one client visible.',
'agents.clients.noResults': 'No matching clients found',
'agents.list.detecting': 'Detecting',
+4 -2
View File
@@ -1082,13 +1082,15 @@ export const zhCN = {
'agents.description.kimiCode': '使用 CPA 的 OpenAI Chat Completions 接口和动态模型目录',
'agents.description.grokBuild': '使用 CPA 的 OpenAI Chat Completions 接口和自定义模型目录',
'agents.description.pi': '使用 Pi CLI 和 CLIProxyAPI 提供方动态加载模型',
'agents.clients.switch': '切换客户端',
'agents.clients.switchHint': '搜索并选择客户端,当前选择会显示在快捷列表中。',
'agents.clients.manage': '管理客户端',
'agents.clients.description': '选择要显示在列表中的客户端,仅调整显示,不修改客户端配置。',
'agents.clients.description': '选择快捷列表优先展示的客户端。当前选择始终保留,其他客户端可通过“切换客户端”访问。',
'agents.clients.search': '搜索客户端',
'agents.clients.shown': '显示 {count} / {total}',
'agents.clients.selected': '已选择 {count} 个客户端',
'agents.clients.automatic': '恢复自动显示',
'agents.clients.automaticHint': '默认显示已安装或已有配置的客户端。',
'agents.clients.automaticHint': '默认优先展示已安装或已有配置的客户端。',
'agents.clients.minimum': '至少保留一个客户端。',
'agents.clients.noResults': '未找到匹配的客户端',
'agents.list.detecting': '正在检测',
+64 -5
View File
@@ -3,12 +3,23 @@
contain: size;
}
.agents-page .agent-list-items {
.agents-page .agent-client-list .agent-list-items {
gap: 4px;
flex: 1 1 auto;
grid-auto-flow: row;
grid-auto-columns: auto;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: none;
grid-auto-rows: 56px;
min-height: 56px;
overflow: hidden;
scrollbar-gutter: auto;
padding: 0;
}
.agents-page .agent-list-items button {
.agents-page .agent-client-list .agent-list-items button {
min-height: 56px;
height: 56px;
padding: 8px;
gap: 8px;
}
@@ -29,16 +40,46 @@
}
.agent-client-list-footer {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
flex: none;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
border-top: 1px solid var(--border-primary);
padding-top: 10px;
}
.agent-client-switch {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
grid-column: 1 / -1;
}
.agent-client-list-footer > .compact-button {
width: auto;
}
.agent-client-manager button.agent-client-option {
background: transparent;
color: inherit;
text-align: left;
}
.agent-client-manager button.agent-client-option:hover,
.agent-client-manager button.agent-client-option[aria-pressed="true"] {
border-color: var(--ui-accent);
background: var(--ui-accent-soft);
}
.agent-client-manager button.agent-client-option > svg {
flex: none;
margin-left: auto;
}
.agent-client-list-footer small,
.agent-client-manager small,
.agent-client-manager p,
@@ -221,7 +262,25 @@
}
@container agent-workspace (max-width: 760px) {
.agents-page:not(.agents-page-embedded) .agent-client-list {
.agents-page:not(.agents-page-embedded) .agent-client-list,
.app-shell:has(.agents-page:not(.agents-page-embedded)) .agent-client-list {
contain: none;
height: 270px;
max-height: 270px;
}
}
/* These three compact controls fit side by side at ordinary desktop widths. */
@container agent-panel (min-width: 620px) and (max-width: 760px) {
.agent-claude-desktop-mapping-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.agent-claude-code-runtime-settings {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.2fr);
}
.agent-claude-code-disable-compact {
grid-column: auto;
}
}
+54 -17
View File
@@ -1,5 +1,5 @@
import { useEffect, useId, useRef, useState, type ReactNode } from 'react';
import { RefreshCw, Search, SlidersHorizontal, X } from 'lucide-react';
import { useEffect, useLayoutEffect, useId, useRef, useState, type ReactNode } from 'react';
import { Check, RefreshCw, Search, SlidersHorizontal, X } from 'lucide-react';
import { MessageNotice } from '../appNotice';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import { useI18n } from '../i18n';
@@ -41,22 +41,39 @@ export function AgentClientList<Id extends string>({
const { t } = useI18n();
const [visibleIds, setVisibleIds] = useState(() => readVisibleClients(clients));
const [managing, setManaging] = useState(false);
const [picking, setPicking] = useState(false);
const [capacity, setCapacity] = useState(1);
const listRef = useRef<HTMLDivElement>(null);
const close = () => { setManaging(false); setPicking(false); };
const [draftIds, setDraftIds] = useState<Id[] | null>(null);
const [query, setQuery] = useState('');
// Keep the current client reachable even when detection fails or it was uninstalled.
const automaticIds = clients.filter((client) => client.detected || client.id === selected)
.map((client) => client.id);
const visibleClients = clients.filter((client) => (visibleIds ?? automaticIds).includes(client.id));
const shortcuts = visibleClients.slice(0, capacity);
const current = visibleClients.find((client) => client.id === selected);
if (current && !shortcuts.includes(current)) shortcuts[shortcuts.length - 1] = current;
const draftSelection = draftIds ?? automaticIds;
const searchRef = useRef<HTMLInputElement>(null);
const titleId = useId();
const descriptionId = useId();
const dialogRef = useDialogFocusTrap<HTMLDialogElement>({
active: managing,
onEscape: () => setManaging(false),
active: managing || picking,
onEscape: close,
initialFocusRef: searchRef,
});
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
const resize = () => setCapacity(Math.max(1, Math.min(6, Math.floor((list.clientHeight + 4) / 60))));
resize();
const observer = new ResizeObserver(resize);
observer.observe(list);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!busy && visibleIds && !visibleIds.includes(selected) && visibleIds.length) {
onSelect(visibleIds[0]);
@@ -64,8 +81,19 @@ export function AgentClientList<Id extends string>({
}, [busy, onSelect, selected, visibleIds]);
useEffect(() => {
if (managing) dialogRef.current?.showModal();
}, [dialogRef, managing]);
if (managing || picking) dialogRef.current?.showModal();
}, [dialogRef, managing, picking]);
const pick = (id: Id) => {
// A manually hidden client can be brought back directly from the switcher.
if (visibleIds && !visibleIds.includes(id)) {
const next = [...visibleIds, id];
setVisibleIds(next);
try { window.localStorage.setItem(VISIBLE_CLIENTS_KEY, JSON.stringify(next)); } catch { /* Session only. */ }
}
onSelect(id);
close();
};
const save = () => {
if (!draftSelection.length) return;
@@ -95,8 +123,8 @@ export function AgentClientList<Id extends string>({
</button>
</div>
{error ? <MessageNotice message={error} onDismiss={onDismissError} /> : null}
<div className="agent-list-items">
{visibleClients.map((client) => <button type="button" key={client.id}
<div className="agent-list-items" ref={listRef}>
{shortcuts.map((client) => <button type="button" key={client.id}
className={selected === client.id ? 'active' : ''} aria-pressed={selected === client.id}
onClick={() => onSelect(client.id)} disabled={busy}>
<span className="agent-client-icon">{client.icon}</span>
@@ -105,29 +133,38 @@ export function AgentClientList<Id extends string>({
</button>)}
</div>
<div className="agent-client-list-footer">
<small>{t('agents.clients.shown', { count: visibleClients.length, total: clients.length })}</small>
<button type="button" className="secondary-button agent-client-switch" disabled={busy || loading}
onClick={() => { setQuery(''); setPicking(true); }}>
<Search size={15} aria-hidden="true" />{t('agents.clients.switch')}
</button>
<small>{t('agents.clients.shown', { count: shortcuts.length, total: clients.length })}</small>
<button type="button" className="secondary-button compact-button" disabled={busy || loading}
onClick={() => { setDraftIds(visibleIds); setQuery(''); setManaging(true); }}>
<SlidersHorizontal size={15} aria-hidden="true" />{t('agents.clients.manage')}
</button>
</div>
</aside>
{managing ? <dialog className="agent-client-manager" ref={dialogRef} aria-labelledby={titleId}
aria-describedby={descriptionId} onCancel={(event) => { event.preventDefault(); setManaging(false); }}>
{managing || picking ? <dialog className="agent-client-manager" ref={dialogRef} aria-labelledby={titleId}
aria-describedby={descriptionId} onCancel={(event) => { event.preventDefault(); close(); }}>
<header>
<h2 id={titleId}>{t('agents.clients.manage')}</h2>
<button type="button" className="icon-button quiet" aria-label={t('common.close')} onClick={() => setManaging(false)}>
<h2 id={titleId}>{t(picking ? 'agents.clients.switch' : 'agents.clients.manage')}</h2>
<button type="button" className="icon-button quiet" aria-label={t('common.close')} onClick={close}>
<X size={18} aria-hidden="true" />
</button>
</header>
<p id={descriptionId}>{t('agents.clients.description')}</p>
<p id={descriptionId}>{t(picking ? 'agents.clients.switchHint' : 'agents.clients.description')}</p>
<div className="agent-client-search">
<Search size={16} aria-hidden="true" />
<input ref={searchRef} type="search" value={query} onChange={(event) => setQuery(event.currentTarget.value)}
placeholder={t('agents.clients.search')} aria-label={t('agents.clients.search')} />
</div>
<div className="agent-client-catalog">
{matches.map((client) => <label key={client.id} className="agent-client-option">
{matches.map((client) => picking ? <button type="button" key={client.id} className="agent-client-option"
aria-label={client.name} aria-pressed={selected === client.id} onClick={() => pick(client.id)}>
<span className="agent-client-icon">{client.icon}</span>
<span className="agent-client-option-copy"><strong>{client.name}</strong><small title={client.summary}>{client.summary}</small></span>
{selected === client.id ? <Check size={16} aria-hidden="true" /> : null}
</button> : <label key={client.id} className="agent-client-option">
<input type="checkbox" aria-label={client.name} checked={draftSelection.includes(client.id)}
onChange={(event) => setDraftIds(event.currentTarget.checked
? [...draftSelection, client.id] : draftSelection.filter((id) => id !== client.id))} />
@@ -136,7 +173,7 @@ export function AgentClientList<Id extends string>({
</label>)}
{!matches.length ? <p role="status">{t('agents.clients.noResults')}</p> : null}
</div>
<div className="agent-client-manager-defaults">
{managing ? <><div className="agent-client-manager-defaults">
<button type="button" className="secondary-button compact-button" onClick={() => setDraftIds(null)}>
{t('agents.clients.automatic')}
</button>
@@ -148,7 +185,7 @@ export function AgentClientList<Id extends string>({
<button type="button" className="secondary-button" onClick={() => setManaging(false)}>{t('common.cancel')}</button>
<button type="button" className="primary-button" onClick={save} disabled={!draftSelection.length}>{t('common.save')}</button>
</div>
</footer>
</footer></> : null}
</dialog> : null}
</>;
}
+3 -3
View File
@@ -41,12 +41,12 @@ export function AgentRunControls({
return <section className="agent-run-controls" aria-label={t('agents.run.title')}>
<div className="agent-section-heading"><div><strong>{t('agents.run.title')}</strong></div></div>
<div className="agent-launch-actions">
{dualTargets ? <>{launchButton(cli, 'cli')}{launchButton(app, 'app')}</>
: harness?.running ? <button type="button" className="danger-button agent-launch-button"
{harness?.running ? <button type="button" className="danger-button agent-launch-button"
disabled={busy} onClick={onStop} title={t('agents.deepseekLaunch.runningDetail', { pid: harness.pid ?? '—', mode: harness.mode ?? '—' })}>
{busyAction === 'stop-deepseek' ? <LoaderCircle size={16} className="spin" /> : <Square size={16} />}
{t(busyAction === 'stop-deepseek' ? 'agents.deepseekLaunch.stopping' : 'agents.deepseekLaunch.stop')}
</button> : launchButton(targets[0] ?? null)}
</button> : dualTargets ? launchButton(cli, 'cli') : launchButton(targets[0] ?? null)}
{dualTargets ? launchButton(app, 'app') : null}
{desktop ? <button type="button" className="secondary-button agent-launch-button"
onClick={onRestart} disabled={busy || !enabled || !app} title={app?.detail ?? t('agents.launch.unavailable')}>
{busyAction === 'restart-app' ? <LoaderCircle size={16} className="spin" /> : <RefreshCw size={16} />}
+2 -2
View File
@@ -1079,7 +1079,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
const selectedModel = selectedModelOption?.name ?? '';
const isPiClient = selected === 'pi';
const isDeepSeekHarnessClient = selected === 'deepseek-harness';
const hasIndependentCliAndApp = selected === 'codex' || selected === 'opencode';
const hasIndependentCliAndApp = selected === 'codex' || selected === 'opencode' || isDeepSeekHarnessClient;
const isClaudeModelMappingClient = selected === 'claude-code' || selected === 'claude-desktop';
const claudeModelMappingsDraft = isClaudeModelMappingClient
? claudeModelMappingsDraftByClient[selected]
@@ -2461,7 +2461,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
<MessageNotice tone="success" message={!configurationErrorMessage ? configurationNotice || clearNotice : null}
onDismiss={() => { setConfigurationNotice(''); setClearNotice(''); }} />
{activeSubpage === 'core' ? <AgentRunControls name={activeDefinition.name} dualTargets={hasIndependentCliAndApp}
desktop={hasIndependentCliAndApp || selected === 'claude-desktop' || selected === 'zcode' || selected === 'workbuddy'}
desktop={(hasIndependentCliAndApp && !isDeepSeekHarnessClient) || selected === 'claude-desktop' || selected === 'zcode' || selected === 'workbuddy'}
targets={activeLaunchTargets} enabled={launchEnabled} busyAction={busyAction}
harness={isDeepSeekHarnessClient ? deepSeekHarnessProcessStatus : null}
onLaunch={(target) => void launchAgent(target)} onRestart={() => void restartDesktopApp()}
+43 -41
View File
@@ -19,7 +19,7 @@
gap: 12px;
min-width: 0;
margin: 0;
padding: 16px 20px;
padding: 20px;
border: 1px solid var(--border-color);
border-radius: 12px;
background: var(--bg-card);
@@ -28,8 +28,8 @@
.home-dashboard .home-top-grid .panel-heading {
flex-direction: row;
align-items: center;
min-height: 0;
align-items: flex-start;
min-height: 48px;
margin: 0;
padding: 0;
}
@@ -43,14 +43,14 @@
line-height: 1.4;
}
.home-dashboard .home-top-grid h2 { font-size: 18px; }
.home-dashboard .home-top-grid h2 { font-size: 17px; }
.home-dashboard .control-panel .panel-detail-grid {
display: flex;
flex-direction: column;
flex: none;
align-content: start;
gap: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
flex: 1;
align-content: stretch;
gap: 8px;
margin: 0;
padding: 0;
background: transparent;
@@ -58,29 +58,28 @@
.home-dashboard .control-panel .panel-detail-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 3px;
min-width: 0;
min-height: 32px;
padding: 4px 0;
padding: 6px 12px;
border: 0;
border-bottom: 1px solid var(--border-secondary);
border-radius: 8px;
background: var(--bg-secondary);
}
.home-dashboard .control-panel .panel-detail-row:last-child { border-bottom: 0; }
.home-dashboard .control-panel .panel-detail-row dt {
color: var(--text-tertiary);
font-size: 16px;
font-size: 12px;
line-height: 1.5;
}
.home-dashboard .control-panel .panel-detail-row dd {
margin: 0;
color: var(--text-primary);
font-size: 16px;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
white-space: normal;
@@ -113,7 +112,7 @@
min-width: 0;
min-height: 38px;
padding: 6px 10px;
font-size: 16px;
font-size: 14px;
line-height: 1.5;
white-space: normal;
overflow-wrap: anywhere;
@@ -140,21 +139,26 @@
}
.home-panel-heading > div { min-width: 0; }
.home-panel-heading { flex-wrap: wrap; }
.home-panel-heading p {
.home-panel-heading { flex-wrap: wrap; align-items: flex-start; min-height: 48px; }
.home-dashboard .home-panel-heading p {
margin: 5px 0 0;
color: var(--text-tertiary);
font-size: 14px;
font-size: 12px;
line-height: 1.5;
}
.home-dashboard .home-access-panel { justify-content: space-between; }
.home-connection-state {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
color: var(--text-tertiary);
font-size: 14px;
padding: 4px 8px;
border-radius: 6px;
background: var(--bg-secondary);
font-size: 12px;
line-height: 1.5;
}
.home-connection-state > span { width: 8px; height: 8px; flex: none; border-radius: 50%; background: currentColor; }
@@ -162,9 +166,10 @@
.home-protocol-tabs {
display: flex;
gap: 16px;
padding: 0;
border-bottom: 1px solid var(--border-color);
gap: 4px;
padding: 4px;
border-radius: 9px;
background: var(--bg-secondary);
}
.home-protocol-tabs button {
display: inline-flex;
@@ -174,19 +179,18 @@
flex: 1;
min-width: 0;
margin: 0;
padding: 6px 4px 8px;
border: 0;
border-bottom: 2px solid transparent;
border-radius: 0;
padding: 6px 4px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-tertiary);
font-size: 16px;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
}
.home-protocol-tabs button:hover { color: var(--text-primary); background: transparent; }
.home-protocol-tabs button.active { color: var(--ui-accent); border-bottom-color: var(--ui-accent); font-weight: 600; }
.home-protocol-tabs img { width: 20px; height: 20px; flex: none; object-fit: contain; }
.home-protocol-tabs button.active { color: var(--ui-accent); border-color: var(--border-color); background: var(--bg-card); font-weight: 600; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); }
.home-protocol-tabs img { width: 18px; height: 18px; flex: none; object-fit: contain; }
:root[data-theme='dark'] #home-protocol-openai img { filter: invert(1); }
.home-dashboard .home-access-panel .home-connection-details.client-api-card,
@@ -211,10 +215,10 @@
gap: 4px 12px;
margin-bottom: 8px;
color: var(--text-secondary);
font-size: 16px;
font-size: 13px;
line-height: 1.5;
}
.home-field-label > span + span { color: var(--text-tertiary); font-size: 14px; }
.home-field-label > span + span { color: var(--text-tertiary); font-size: 12px; }
.home-copy-field {
display: flex;
align-items: center;
@@ -224,7 +228,7 @@
padding: 4px 6px 4px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
background: var(--bg-secondary);
}
.home-copy-field code {
flex: 1;
@@ -232,7 +236,7 @@
overflow-wrap: anywhere;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 16px;
font-size: 14px;
line-height: 1.5;
}
.home-dashboard .home-copy-field > button { flex: none; width: 32px; height: 32px; min-width: 32px; border-color: transparent; background: transparent; }
@@ -248,7 +252,7 @@
border: 0;
background: transparent;
color: var(--ui-accent);
font-size: 14px;
font-size: 12px;
line-height: 1.5;
}
@@ -294,8 +298,6 @@
.content > .home-page.home-dashboard { flex: 1; }
.home-dashboard .home-top-grid { flex: 0 0 auto; }
.home-dashboard .control-panel .panel-detail-grid { flex: 1; }
.home-dashboard .control-panel .panel-detail-row { flex: 1; }
.home-dashboard .home-access-panel { justify-content: space-between; }
.home-dashboard .home-overview { display: flex; flex-direction: column; flex: 0 0 auto; }
.home-dashboard .home-overview-heading { flex: none; }
.home-dashboard .home-stat-grid { flex: 1; }
+2 -1
View File
@@ -213,9 +213,10 @@ export function KernelPage({ view = 'home' }: { view?: KernelView }) {
<h1 className="sr-only">{t('app.nav.home')}</h1>
<div className="home-top-grid">
<div className="panel control-panel">
<div className="panel-heading">
<div className="panel-heading home-panel-heading">
<div>
<h2>{t('kernel.control.title')}</h2>
<p>{t(coreReady ? 'home.runtime.ready' : coreInstalled ? 'home.runtime.stopped' : 'home.runtime.notInstalled')}</p>
</div>
<button type="button" className="icon-button quiet home-runtime-refresh" disabled={coreProcessBusy} onClick={() => void refreshStatus()} title={t('kernel.control.refresh')} aria-label={t('kernel.control.refresh')}><RefreshCw size={15} aria-hidden="true" /></button>
</div>
+6 -12
View File
@@ -1,12 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { invoke } from '@tauri-apps/api/core';
import { AlertTriangle, ExternalLink, X } from 'lucide-react';
import { ExternalLink, X } from 'lucide-react';
import { useDialogFocusTrap } from '../components/useDialogFocusTrap';
import { useI18n } from '../i18n';
import { pluginText } from '../i18n/plugins';
import { pluginStoreApi, type PluginStoreEntry, type PluginStoreInstallResult } from '../services/plugins';
import { buildRepositoryURL, getPluginConfirmToken, isOfficialPlugin } from '../services/pluginResources';
import { buildRepositoryURL } from '../services/pluginResources';
import { isValidManualReleaseTag, supportsPluginVersionSelection } from '../services/pluginReleaseVersions';
export function PluginInstallDialog({ entry, onClose, onInstalled }: {
@@ -17,8 +17,6 @@ export function PluginInstallDialog({ entry, onClose, onInstalled }: {
const { t, locale } = useI18n();
const pt = (key: Parameters<typeof pluginText>[0]) => pluginText(key, locale);
const [version, setVersion] = useState('');
const [step, setStep] = useState(0);
const [typed, setTyped] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const pending = useRef(false);
@@ -26,14 +24,12 @@ export function PluginInstallDialog({ entry, onClose, onInstalled }: {
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
// The dialog remains open throughout installation, including on errors.
const ref = useDialogFocusTrap<HTMLElement>({ onEscape: busy ? undefined : onClose, preventEscape: busy });
const thirdParty = !isOfficialPlugin(entry);
const token = getPluginConfirmToken(entry);
const canSelectVersion = supportsPluginVersionSelection(entry.installType);
const validVersion = !version.trim() || isValidManualReleaseTag(version);
const repository = buildRepositoryURL(entry.repository);
const action = entry.installed ? pt('update') : pt('install');
const install = async () => {
if (pending.current || !validVersion || (thirdParty && (step < 2 || typed.trim() !== token))) return;
if (pending.current || !validVersion) return;
pending.current = true; setBusy(true); setError('');
try {
const result = await pluginStoreApi.install(entry.id, { sourceId: entry.sourceId, version: canSelectVersion ? version.trim() : undefined });
@@ -46,13 +42,11 @@ export function PluginInstallDialog({ entry, onClose, onInstalled }: {
<section className="config-dialog plugin-install-dialog" role="dialog" aria-modal="true" aria-labelledby="plugin-install-title" ref={ref} tabIndex={-1}>
<div className="config-dialog-heading"><h2 id="plugin-install-title">{action} · {entry.name || entry.id}</h2><button className="icon-button quiet" aria-label={t('common.close')} disabled={busy} onClick={onClose}><X size={18} /></button></div>
<div className="plugin-install-body">
<dl className="plugin-install-origin"><dt>{pt('source')}</dt><dd>{entry.sourceName || entry.sourceId}<small>{entry.sourceUrl}</small></dd><dt>{pt('repository')}</dt><dd>{entry.repository || '—'}{repository && <button className="icon-button quiet" aria-label={pt('repository')} onClick={() => void invoke('open_external_url', { url: repository }).catch(e => setError(String(e)))}><ExternalLink size={15} /></button>}</dd></dl>
{step === 0 && <><p>{pt('installHint')}</p>{canSelectVersion && <label className="plugin-field">{pt('version')}<input value={version} onChange={e => setVersion(e.target.value)} disabled={busy} placeholder={entry.version || 'v1.0.0'} aria-invalid={!validVersion} />{!validVersion && <small role="alert">{pt('invalidVersion')}</small>}</label>}</>}
{thirdParty && step >= 1 && <p className="plugin-warning"><AlertTriangle size={18} aria-hidden="true" />{pt('trustWarning')}</p>}
{thirdParty && step === 2 && <label className="plugin-field">{pt('trustType')}<code>{token}</code><input value={typed} onChange={e => setTyped(e.target.value)} disabled={busy} autoComplete="off" spellCheck={false} /></label>}
<dl className="plugin-install-origin"><dt>{pt('source')}</dt><dd>{entry.sourceName || entry.sourceId}<small>{entry.sourceUrl}</small></dd><dt>{pt('repository')}</dt><dd className="plugin-install-repository"><span>{entry.repository || '—'}</span>{repository && <button className="icon-button quiet" aria-label={pt('repository')} onClick={() => void invoke('open_external_url', { url: repository }).catch(e => setError(String(e)))}><ExternalLink size={15} /></button>}</dd></dl>
{canSelectVersion && <label className="plugin-field">{pt('version')}<input value={version} onChange={e => setVersion(e.target.value)} disabled={busy} placeholder={entry.version || 'v1.0.0'} aria-invalid={!validVersion} />{!validVersion && <small role="alert">{pt('invalidVersion')}</small>}</label>}
{error && <p className="plugin-error" role="alert">{error}</p>}
</div>
<div className="plugin-dialog-actions"><button className="secondary-button" disabled={busy} onClick={onClose}>{t('common.cancel')}</button>{thirdParty && step < 2 ? <button className="primary-button" disabled={!validVersion} onClick={() => setStep(step + 1)}>{step === 0 ? action : pt('trustNext')}</button> : <button className={thirdParty ? 'danger-button' : 'primary-button'} disabled={busy || !validVersion || (thirdParty && typed.trim() !== token)} onClick={() => void install()}>{busy ? t('common.loading') : action}</button>}</div>
<div className="plugin-dialog-actions"><button className="secondary-button" disabled={busy} onClick={onClose}>{t('common.cancel')}</button><button className="primary-button" disabled={busy || !validVersion} onClick={() => void install()}>{busy ? t('common.loading') : action}</button></div>
</section>
</div>, document.body);
}
+31 -17
View File
@@ -1,17 +1,19 @@
.plugins-page { display: flex; flex-direction: column; gap: 20px; min-width: 0; max-width: 1500px; margin: 0 auto; padding: 8px 0 32px; }
.plugins-heading { display: flex; justify-content: space-between; align-items: center; gap: 20px; }
.plugins-heading h1 { margin: 8px 0; font-size: 28px; letter-spacing: -.6px; }
.plugins-page .plugins-heading > button { width: auto; flex: none; }
.plugins-heading h1 { margin: 0 0 6px; font-size: 24px; letter-spacing: -.4px; }
.plugins-heading p { margin: 0; color: var(--text-tertiary); line-height: 1.7; }
.plugins-eyebrow { display: flex; gap: 8px; align-items: center; color: var(--primary-color); font-size: 12px; font-weight: 650; }
.plugin-summary { display: flex; flex-wrap: wrap; gap: 16px 24px; align-items: center; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 10px; padding: 18px 20px; }
.plugin-summary { display: flex; flex-wrap: wrap; gap: 12px 24px; align-items: center; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 14px 18px; }
.plugin-summary > span { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-tertiary); overflow-wrap: anywhere; min-width: 0; }
.plugin-summary > span:last-child { margin-left: auto; }
.plugin-global-toggle { display: flex; align-items: center; gap: 10px; font-size: 14px; font-weight: 600; }
.plugin-global-toggle input { width: 18px; height: 18px; accent-color: var(--primary-color); }
.plugin-banner { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 14px 18px; margin: 0; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 13px; line-height: 1.65; overflow-wrap: anywhere; }
.plugin-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border-color); }
.plugin-tabs button { border: 0; border-bottom: 2px solid transparent; padding: 12px 2px; border-radius: 0; background: transparent; color: var(--text-tertiary); font-size: 14px; }
.plugin-tabs button.active { border-color: var(--primary-color); color: var(--primary-color); font-weight: 650; }
.plugin-navigation { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 20px; }
.plugin-navigation > .plugin-toolbar { flex: 1 1 260px; justify-content: flex-end; }
.plugin-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-tertiary); }
.plugin-tabs button { border: 1px solid transparent; padding: 7px 14px; border-radius: 7px; background: transparent; color: var(--text-tertiary); font-size: 13px; }
.plugin-tabs button.active { border-color: var(--border-color); background: var(--bg-card); color: var(--primary-color); font-weight: 650; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); }
.plugin-toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.plugin-search { display: flex; align-items: center; gap: 9px; max-width: 440px; min-width: 180px; flex: 1; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-card); color: var(--text-tertiary); }
.plugin-search input { border: 0; background: transparent; color: var(--text-primary); width: 100%; min-width: 0; padding: 11px 0; font: inherit; font-size: 13px; outline: none; }
@@ -19,19 +21,24 @@
.plugin-toolbar select { border: 1px solid var(--border-color); border-radius: 8px; color: var(--text-primary); background: var(--bg-card); padding: 10px 12px; }
.plugin-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 330px), 1fr)); gap: 16px; }
.plugin-card { min-width: 0; display: flex; flex-direction: column; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 12px; padding: 20px; gap: 12px; }
.plugin-card-title { display: flex; align-items: center; gap: 12px; min-width: 0; flex-wrap: wrap; }
.plugin-card-title > div { flex: 1; min-width: 100px; }
.plugin-card-title { display: grid; grid-template-columns: 40px minmax(0, 1fr) fit-content(38%); align-items: center; gap: 8px 12px; min-width: 0; }
.plugin-card-title > div { min-width: 0; }
.plugin-card-title > .plugin-badge { justify-self: end; white-space: normal; overflow-wrap: anywhere; line-height: 1.5; }
.plugin-card-title h2 { font-size: 15px; margin: 0 0 5px; overflow-wrap: anywhere; }
.plugin-card-title code { color: var(--text-tertiary); font-size: 11px; overflow-wrap: anywhere; }
.plugin-icon { display: grid; place-items: center; width: 44px; height: 44px; flex-shrink: 0; border-radius: 10px; color: var(--primary-color); background: var(--ui-accent-soft); }
.plugin-icon { display: grid; place-items: center; width: 40px; height: 40px; flex-shrink: 0; border-radius: 10px; color: var(--primary-color); background: var(--ui-accent-soft); }
.plugin-badge { display: inline-flex; padding: 4px 8px; border-radius: 5px; background: var(--bg-tertiary); color: var(--text-tertiary); font-size: 11px; white-space: nowrap; }
.plugin-badge.active { color: var(--success-badge-text); background: var(--success-badge-bg); }
.plugin-badge.warning { color: var(--amber-text); background: var(--theme-fff7e9); }
.plugin-meta, .plugin-path { color: var(--text-tertiary); font-size: 12px; margin: 0; line-height: 1.7; overflow-wrap: anywhere; }
.plugin-path { font-family: ui-monospace, monospace; font-size: 11px; }
.plugin-local-card .plugin-path { padding: 8px 10px; border-radius: 6px; background: var(--bg-secondary); }
.plugin-local-card .plugin-card-description { padding: 10px 12px; border-radius: 8px; background: var(--bg-secondary); color: var(--text-tertiary); font-size: 12px; }
.plugin-local-card .plugin-card-title h2:last-child { margin-bottom: 0; }
.plugin-card-description { font-size: 13px; line-height: 1.7; color: var(--text-secondary); margin: 0; overflow-wrap: anywhere; }
.plugin-card-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-top: 12px; margin-top: auto; border-top: 1px solid var(--border-color); }
.plugin-card-actions button { font-size: 12px; min-height: 34px; }
.plugins-page .plugin-card-actions > button { width: auto; }
.plugin-card-actions .primary-button { margin-left: auto; }
.plugin-delete { margin-left: auto; color: var(--danger-color); }
.plugin-menus { display: flex; flex-wrap: wrap; gap: 8px; }
@@ -49,18 +56,25 @@
.plugin-field textarea { resize: vertical; font-family: ui-monospace, monospace; font-weight: normal; line-height: 1.7; }
.plugin-field small { font-size: 12px; color: var(--text-tertiary); font-weight: normal; line-height: 1.7; }
.plugin-field code { overflow-wrap: anywhere; padding: 10px; border-radius: 5px; background: var(--bg-tertiary); }
.config-dialog.plugin-install-dialog { width: min(620px, 100%); padding: 0; overflow: auto; max-height: calc(100dvh - 40px); color: var(--text-primary); }
.plugin-install-dialog .config-dialog-heading { padding: 20px; border-bottom: 1px solid var(--border-color); }
.plugin-install-dialog h2 { font-size: 17px; overflow-wrap: anywhere; }
.plugin-install-body { display: grid; gap: 20px; padding: 20px; font-size: 13px; line-height: 1.7; }
.config-dialog.plugin-install-dialog { display: flex; flex-direction: column; gap: 0; width: min(620px, 100%); height: auto; min-width: 0; padding: 0; overflow: hidden; max-height: calc(100dvh - 40px); color: var(--text-primary); }
.plugin-install-dialog .config-dialog-heading { flex-shrink: 0; padding: 20px; border-bottom: 1px solid var(--border-color); }
.plugin-install-dialog .config-dialog-heading h2 { min-width: 0; font-size: 17px; white-space: normal; overflow-wrap: anywhere; }
.plugin-install-dialog .config-dialog-heading button { flex-shrink: 0; }
.plugin-install-body { display: grid; gap: 20px; min-height: 0; overflow-y: auto; padding: 20px; font-size: 13px; line-height: 1.7; }
.plugin-install-body p { margin: 0; }
.plugin-install-origin { display: grid; grid-template-columns: auto 1fr; gap: 10px 16px; margin: 0; font-size: 12px; }
.plugin-install-origin { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px 16px; margin: 0; font-size: 12px; }
.plugin-install-origin dt { color: var(--text-tertiary); }
.plugin-install-origin dd { margin: 0; overflow-wrap: anywhere; }
.plugin-install-origin dd { min-width: 0; margin: 0; overflow-wrap: anywhere; }
.plugin-install-repository { display: flex; align-items: flex-start; gap: 8px; }
.plugin-install-repository span { min-width: 0; }
.plugin-install-repository button { flex-shrink: 0; }
.plugin-install-origin small { display: block; color: var(--text-tertiary); }
.plugin-warning { display: flex; gap: 10px; padding: 16px; background: var(--bg-error-light); border-radius: 8px; color: var(--error-color); }
.plugin-warning svg { flex-shrink: 0; }
.plugin-dialog-actions { display: flex; justify-content: flex-end; gap: 10px; padding: 18px 20px; border-top: 1px solid var(--border-color); }
.plugin-dialog-actions { display: flex; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; gap: 10px; padding: 18px 20px; border-top: 1px solid var(--border-color); }
.plugins-page .primary-button, .plugins-page .secondary-button, .plugin-install-dialog .primary-button, .plugin-install-dialog .secondary-button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.plugins-page button svg { flex-shrink: 0; }
.plugin-resource { display: flex; flex-direction: column; gap: 16px; }
.plugin-resource iframe { width: 100%; min-height: 65vh; border: 1px solid var(--border-color); border-radius: 8px; background: var(--bg-card); }
@media (max-width: 700px) { .plugins-heading { align-items: flex-start; gap: 12px; } .plugins-heading h1 { font-size: 24px; } .plugin-summary { padding: 14px; gap: 14px; } .plugin-summary > span { width: 100%; } .plugin-summary > span:last-child { margin-left: 0; } .plugin-card, .plugin-settings { padding: 16px; } .plugin-banner { flex-wrap: wrap; } .plugin-tabs { gap: 20px; } }
@media (max-width: 700px) { .plugins-heading { align-items: flex-start; gap: 12px; } .plugins-heading h1 { font-size: 24px; } .plugin-summary { padding: 14px; gap: 14px; } .plugin-summary > span { width: 100%; } .plugin-summary > span:last-child { margin-left: 0; } .plugin-card, .plugin-settings { padding: 16px; } .plugin-banner { flex-wrap: wrap; } .plugin-navigation > .plugin-toolbar { flex-basis: 100%; } .plugin-search { max-width: none; } }
@media (max-width: 420px) { .plugin-card-title { grid-template-columns: 40px minmax(0, 1fr); } .plugin-card-title > .plugin-badge { grid-column: 2; justify-self: start; } .plugin-tabs button { padding-inline: 10px; } }
+8 -5
View File
@@ -128,30 +128,33 @@ export function PluginsPage() {
const locked = busy || loading || Boolean(configPlugin || installEntry || oauthPlugin);
return <div className="plugins-page">
<header className="plugins-heading"><div><div className="plugins-eyebrow"><Puzzle size={18} aria-hidden="true" />CLI Proxy API</div><h1>{pt('title')}</h1><p>{pt('description')}</p></div><button className="secondary-button" disabled={locked} onClick={() => void load()}><RefreshCw size={16} aria-hidden="true" />{t('common.refresh')}</button></header>
<header className="plugins-heading"><div><h1>{pt('title')}</h1><p>{pt('description')}</p></div><button className="secondary-button" disabled={locked} onClick={() => void load()}><RefreshCw size={16} aria-hidden="true" />{t('common.refresh')}</button></header>
{error && <div className="plugin-error plugin-banner" role="alert"><span>{error}</span><button className="secondary-button" disabled={busy || loading} onClick={() => void load()}>{pt('retry')}</button></div>}
{supported === false ? <section className="plugin-empty"><Puzzle size={36} /><p>{pt('unsupported')}</p></section> : supported === null ? <section className="plugin-empty" role="status">{loading ? t('common.loading') : pt('retry')}</section> : <>
<div className="plugin-summary"><label className="plugin-global-toggle"><input type="checkbox" checked={data?.pluginsEnabled ?? false} disabled={locked || !data} onChange={e => { const enabled = e.target.checked; void mutate(() => pluginsApi.updateSettings({ enabled }), pt('saved')); }} />{pt('global')}</label><span><Folder size={15} aria-hidden="true" />{data?.pluginsDir || 'plugins'}</span><span>{pt('installed')} <strong>{installedCount}</strong>{configOnlyCount > 0 && <> · {pt('configOnly')} <strong>{configOnlyCount}</strong></>}</span></div>
{data && !data.pluginsEnabled && <p className="plugin-banner">{pt('disabledHint')}</p>}
{restartRequired && <div className="plugin-banner"><span>{pt('restart')}</span><button className="secondary-button" disabled={locked} onClick={async () => { if (await askConfirmation({ title: pt('restartButton'), message: pt('restartConfirm') })) void mutate(async () => { await invoke('restart_core_process'); if (mounted.current) setRestartRequired(false); }, pt('saved')); }}>{pt('restartButton')}</button></div>}
{resource ? <section className="plugin-resource"><div className="plugin-toolbar"><button className="secondary-button" onClick={() => setResource(null)}><ArrowLeft size={16} />{pt('back')}</button><strong>{resource.entry.label}</strong><button className="secondary-button" onClick={() => void invoke('open_external_url', { url: resource.url }).catch(e => setError(message(e)))}><ExternalLink size={16} />{pt('openExternal')}</button></div><iframe key={resource.url} src={resource.url} title={resource.entry.label} referrerPolicy="no-referrer" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" allow="clipboard-read; clipboard-write" /></section> : <>
<div className="plugin-navigation">
<nav className="plugin-tabs" aria-label={pt('title')}>{(['installed', 'store', 'settings'] as const).map(value => <button key={value} className={tab === value ? 'active' : ''} aria-current={tab === value ? 'page' : undefined} disabled={busy} onClick={() => { setTab(value); setSearch(''); setFilter('all'); }}>{pt(value === 'installed' ? 'local' : value)}</button>)}</nav>
{tab !== 'settings' &&
<div className="plugin-toolbar"><label className="plugin-search"><Search size={17} aria-hidden="true" /><input type="search" value={search} onChange={e => setSearch(e.target.value)} placeholder={pt('search')} aria-label={pt('search')} /></label>{tab === 'store' && <select aria-label={pt('store')} value={filter} onChange={e => setFilter(e.target.value)}>{['all', 'installed', 'updates'].map(value => <option key={value} value={value}>{pt(value as 'all' | 'installed' | 'updates')}</option>)}</select>}</div>}
</div>
{tab === 'settings' ? <PluginSettingsPanel onSaved={() => { notice.showNotice(pt('saved')); void load(); }} /> : <>
<div className="plugin-toolbar"><label className="plugin-search"><Search size={17} aria-hidden="true" /><input type="search" value={search} onChange={e => setSearch(e.target.value)} placeholder={pt('search')} aria-label={pt('search')} /></label>{tab === 'store' && <select aria-label={pt('store')} value={filter} onChange={e => setFilter(e.target.value)}>{['all', 'installed', 'updates'].map(value => <option key={value} value={value}>{pt(value as 'all' | 'installed' | 'updates')}</option>)}</select>}</div>
{loading && <p className="plugin-loading" role="status">{t('common.loading')}…</p>}
{tab === 'installed' ? <div className="plugin-grid">{plugins.map(plugin => {
const status = getPluginStatus(plugin, data?.pluginsEnabled ?? false);
const installed = isPluginInstalled(plugin);
const meta = [plugin.metadata?.version, plugin.metadata?.author].filter(Boolean).join(' · ');
const removeLabel = `${pt(installed ? 'remove' : 'removeConfig')} ${getPluginTitle(plugin)}`;
return <article className="plugin-card" key={plugin.id}>
<div className="plugin-card-title"><span className="plugin-icon"><Puzzle size={23} /></span><div><h2>{getPluginTitle(plugin)}</h2><code>{plugin.id}</code></div><span className={`plugin-badge ${status === 'active' ? 'active' : status === 'missingFile' || status === 'notLoaded' ? 'warning' : ''}`}>{pt(status)}</span></div>
return <article className="plugin-card plugin-local-card" key={plugin.id}>
<div className="plugin-card-title"><span className="plugin-icon"><Puzzle size={23} /></span><div><h2>{getPluginTitle(plugin)}</h2>{getPluginTitle(plugin) !== plugin.id && <code>{plugin.id}</code>}</div><span className={`plugin-badge ${status === 'active' ? 'active' : status === 'missingFile' || status === 'notLoaded' ? 'warning' : ''}`}>{pt(status)}</span></div>
{meta && <p className="plugin-meta">{meta}</p>}{plugin.path && <p className="plugin-path" title={plugin.path}>{plugin.path}</p>}
{status === 'missingFile' && <p className="plugin-card-description">{pt('missingFileHint')}</p>}
{status === 'notLoaded' && <p className="plugin-card-description">{pt('notLoadedHint')}</p>}
{collectPluginResourceEntries([plugin]).length > 0 && <div className="plugin-menus">{collectPluginResourceEntries([plugin]).map(entry => <button key={entry.menuIndex} title={entry.description} className="secondary-button" disabled={locked} onClick={() => void openResource(entry)}><ExternalLink size={14} />{entry.label}</button>)}</div>}
{plugin.effectiveEnabled && plugin.supportsOAuth && plugin.oauthProvider && <button className="secondary-button" disabled={locked} onClick={() => setOAuthPlugin(plugin)}><LogIn size={15} />{pt('oauth')}</button>}
<div className="plugin-card-actions">
{plugin.effectiveEnabled && plugin.supportsOAuth && plugin.oauthProvider && <button className="secondary-button" disabled={locked} onClick={() => setOAuthPlugin(plugin)}><LogIn size={15} />{pt('oauth')}</button>}
{installed ? <button className="secondary-button" disabled={locked} onClick={() => void mutate(() => pluginsApi.updateEnabled(plugin.id, !plugin.enabled), pt('saved'))}>{pt(plugin.enabled ? 'disabled' : 'enabled')}</button> : <button className="secondary-button" disabled={locked} onClick={() => { setSearch(plugin.id); setFilter('all'); setTab('store'); }}><Download size={15} />{pt('findInStore')}</button>}
<button className="secondary-button" disabled={locked} onClick={() => setConfigPlugin(plugin)}><Settings2 size={15} />{pt('configure')}</button>
{status === 'notLoaded' && <button className="secondary-button" disabled={locked} onClick={() => void invoke('open_core_logs_directory').catch(reason => { if (mounted.current) setError(message(reason)); })}><Folder size={15} />{pt('openLogs')}</button>}