From 9182cf26adbbe1562d9851f36e5010a6c196c711 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 09:48:41 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Settings=20=E2=86=92=20MCP:=20only=20show?= =?UTF-8?q?=20per-tool=20toggle=20for=20tools=20actually=20registered?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP_TOOL_IDS (useMcpToolEnabled.ts) is a static list that still names redis-client/kafka-explorer/rabbit-client/container-manager, even though those four moved from compiled-in tools to installable plugins. Both Settings → MCP and the "MCP for Claude Code" dialog (McpSetupDialog.tsx) mapped over that static list directly, so a toggle row for e.g. Kafka Explorer showed up (falling back to the bare id as its label, since it isn't in TOOL_DEFS) even when the plugin was never installed — flipping that toggle does nothing, since there's no plugin for it to gate. Fix: filter MCP_TOOL_IDS down to ids present in TOOL_DEFS (sourced from the registry, so it only lists plugins actually registered — core or currently installed) before rendering, in both places. Added Settings.mcpToolList.test.tsx, verified by temporarily reverting the filter in Settings.tsx and confirming the test fails for the right reason (the row renders using the bare id as a fallback label) before restoring it. --- src/components/McpSetupDialog.tsx | 9 ++- src/components/Settings.mcpToolList.test.tsx | 76 ++++++++++++++++++++ src/components/Settings.tsx | 9 ++- 3 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 src/components/Settings.mcpToolList.test.tsx diff --git a/src/components/McpSetupDialog.tsx b/src/components/McpSetupDialog.tsx index 58b3f7c..253c692 100644 --- a/src/components/McpSetupDialog.tsx +++ b/src/components/McpSetupDialog.tsx @@ -32,6 +32,13 @@ export function McpSetupDialog({ open, onClose }: { open: boolean; onClose: () = // Per-tool kill switch — independent of the background toggle above, and // (same reasoning) one shared value with Settings → MCP's own list. const { isEnabled: isToolEnabled, setToolEnabled } = useMcpToolEnabledMap(); + // Redis Client/Kafka Explorer/RabbitMQ Client/Containers are installable + // plugins now, not compiled in — MCP_TOOL_IDS is a static list that + // doesn't know which of them are actually present. Filter by TOOL_DEFS + // (sourced from the registry, so it only lists plugins actually + // registered) so this dialog never shows a toggle for a tool that isn't + // even installed. + const availableMcpToolIds = MCP_TOOL_IDS.filter((id) => TOOL_DEFS.some((td) => td.id === id)); useEffect(() => { if (!open) return; @@ -94,7 +101,7 @@ export function McpSetupDialog({ open, onClose }: { open: boolean; onClose: () = Off means that tool never answers an MCP call — on screen or in the background.

- {MCP_TOOL_IDS.map((id) => { + {availableMcpToolIds.map((id) => { const label = TOOL_DEFS.find((td) => td.id === id)?.label ?? id; const enabled = isToolEnabled(id); return ( diff --git a/src/components/Settings.mcpToolList.test.tsx b/src/components/Settings.mcpToolList.test.tsx new file mode 100644 index 0000000..b9af752 --- /dev/null +++ b/src/components/Settings.mcpToolList.test.tsx @@ -0,0 +1,76 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; + +/** + * `MCP_TOOL_IDS` (useMcpToolEnabled.ts) liệt kê Redis Client/Kafka Explorer/ + * RabbitMQ Client/Containers dù bốn tool này giờ là plugin CÀI THÊM, không + * còn biên dịch sẵn — danh sách đó không tự biết cái nào ĐANG thật sự có mặt. + * Không lọc theo registry (`TOOL_DEFS`) thì Settings → MCP hiện công tắc + * bật/tắt cho một tool CHƯA cài, đổi công tắc đó không có tác dụng gì (không + * có plugin nào để bật/tắt MCP access). Trong test này (không cài plugin nào + * runtime) `TOOL_DEFS` chỉ chứa 26 tool biên dịch sẵn — đúng kịch bản người + * dùng "chưa cài tool kafka" báo cáo. + * + * `isTauri` được đọc MỘT LẦN lúc `@/lib/platform` được nạp (mục MCP chỉ hiện + * danh sách khi `isTauri` true, không phải bản web) — cùng cái bẫy đã ghi ở + * `SettingsExtensionInstaller.test.tsx`. `vi.resetModules()` + gán + * `__TAURI_INTERNALS__` TRƯỚC khi `import('@/components/Settings')` (qua + * dynamic import, không phải import tĩnh ở đầu file — import tĩnh chạy + * TRƯỚC cả thân test) là cách duy nhất để test này đọc đúng giá trị cần. + */ + +async function renderMcpSection() { + vi.resetModules(); + (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = {}; + const { MemoryRouter } = await import('react-router-dom'); + const { AppConfigProvider } = await import('@/contexts/AppConfigContext'); + const { LocaleProvider } = await import('@/contexts/LocaleContext'); + const { FeatureProvider } = await import('@/contexts/FeatureContext'); + const { OnboardingProvider } = await import('@/contexts/OnboardingContext'); + const { UpdateProvider } = await import('@/contexts/UpdateContext'); + const { ExtensionUpdateProvider } = await import('@/contexts/ExtensionUpdateContext'); + const { Settings } = await import('@/components/Settings'); + return render( + + + + + + + + + + + + + + + , + ); +} + +afterEach(() => { + cleanup(); + delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + vi.resetModules(); +}); + +describe('Settings — MCP — danh sách bật/tắt theo tool CHỈ hiện tool đã đăng ký', () => { + it('không hiện công tắc cho kafka-explorer/redis-client/rabbit-client/container-manager khi chưa cài', async () => { + await renderMcpSection(); + + // Chưa cài → không có trong TOOL_DEFS → nhãn hiển thị đáng ra sẽ rơi về + // bare id (`?? id`) NẾU dòng đó vẫn hiện — khẳng định bare id đó không + // xuất hiện ở đâu cả, tức dòng tương ứng không được render. + expect(screen.queryByText('kafka-explorer')).toBeNull(); + expect(screen.queryByText('redis-client')).toBeNull(); + expect(screen.queryByText('rabbit-client')).toBeNull(); + expect(screen.queryByText('container-manager')).toBeNull(); + }); + + it('vẫn hiện công tắc cho tool biên dịch sẵn (vd JSON Formatter, có mặt trong MCP_TOOL_IDS)', async () => { + await renderMcpSection(); + + expect(screen.getByText('JSON Formatter')).toBeTruthy(); + }); +}); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index cb23d71..89eed75 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -322,6 +322,13 @@ export function Settings() { const { locale, setLocale, t } = useLocale(); const { enabled: mcpBackgroundEnabled, setEnabled: setMcpBackgroundEnabled } = useMcpBackgroundBridge(); const { isEnabled: isMcpToolEnabled, setToolEnabled: setMcpToolEnabled } = useMcpToolEnabledMap(); + // Redis/Kafka/RabbitMQ/Containers là plugin cài thêm (không còn biên dịch + // sẵn) — `MCP_TOOL_IDS` là danh sách TĨNH, không tự biết plugin nào ĐANG + // thật sự có mặt. Không lọc thì dòng bật/tắt MCP của một tool CHƯA cài vẫn + // hiện ra (đổi công tắc không có tác dụng gì, vì không có gì để bật/tắt) — + // lọc theo `TOOL_DEFS` (nguồn từ registry, chỉ chứa plugin ĐÃ đăng ký) để + // danh sách này luôn khớp đúng những gì người dùng thật sự có trong app. + const availableMcpToolIds = MCP_TOOL_IDS.filter((id) => TOOL_DEFS.some((td) => td.id === id)); // Một mục đang mở, không phải bốn cờ accordion rời — điều hướng ở nav trái // TỰ LÀ sự "mở/đóng" rồi, nên bỏ hẳn kiểu accordion lồng bên trong từng mục. // Xem SETTINGS_SECTIONS + nav trái ở cuối file cho danh sách đầy đủ. @@ -929,7 +936,7 @@ export function Settings() { {t('settings.mcp.perToolDescription')}

- {MCP_TOOL_IDS.map((id) => { + {availableMcpToolIds.map((id) => { const label = TOOL_DEFS.find((td) => td.id === id)?.label ?? id; const enabled = isMcpToolEnabled(id); return ( From bfebbb595bd6199a317d0fd7207545972169349f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 09:57:19 +0000 Subject: [PATCH 2/3] Settings: merge Plugin into Tools, Data & Storage into About MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reduces the left nav from 8 sections to 6, folding two single-purpose sections into the section they're most related to: - "Plugin" (SettingsPlugins.tsx: permissions read from each compiled-in tool's manifest, plus the activity/audit log) now renders under Tools, right below the enable/disable list — both are views onto the same set of tools, just answering different questions (what's on vs. what's it allowed to do vs. what has it actually done). - "Data & Storage" was a single row (data dir location + reveal-in-folder button) — folded into About as an extra card, not worth its own nav entry. Activity log changes (SettingsPlugins.tsx): - Collapsed by default behind a disclosure button showing the entry count — it can run up to 80 rows and isn't something most visits to this section need to see immediately. - Added a search box, filtered primarily by tool id but also matching channel/action/detail/missing-permission, so a keyword like a native command name or "denied" narrows it down too. Removed the now-unreferenced settings.plugins.title/settings.storage.title i18n keys (nav labels for the sections that no longer exist) and added auditShow/auditHide/auditSearchPlaceholder/auditNoMatch. Moved SettingsPlugins.test.tsx's audit-log assertions behind an openAuditLog() helper (log is closed by default now) and added tests for collapse-by-default and keyword filtering. Added Settings.mergedSections.test.tsx confirming both merges actually render in their new location and the old nav buttons are gone. Verified each new/changed assertion by temporarily reverting the corresponding code and confirming the test fails for the right reason, then restoring it — the collapse-by-default default and the Tools/Plugin merge were both checked this way. --- .../Settings.mergedSections.test.tsx | 39 +++++++ src/components/Settings.tsx | 24 +++-- src/components/SettingsPlugins.test.tsx | 57 +++++++++- src/components/SettingsPlugins.tsx | 100 ++++++++++++++---- src/lib/i18n.ts | 17 ++- 5 files changed, 202 insertions(+), 35 deletions(-) create mode 100644 src/components/Settings.mergedSections.test.tsx diff --git a/src/components/Settings.mergedSections.test.tsx b/src/components/Settings.mergedSections.test.tsx new file mode 100644 index 0000000..9071c0b --- /dev/null +++ b/src/components/Settings.mergedSections.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it, afterEach } from 'vitest'; +import { screen, cleanup } from '@testing-library/react'; +import { renderSettings } from '@/testSupport/renderSettings'; + +/** + * "Plugin" (quyền + nhật ký của tool biên dịch sẵn) và "Data & Storage" (vị + * trí thư mục dữ liệu) không còn là mục riêng trong nav trái — Plugin gộp + * vào Tools, Storage gộp vào About. Test này khẳng định nội dung thật sự có + * mặt ở mục mới, và hai mục cũ không còn xuất hiện trong nav. + */ + +afterEach(() => { + cleanup(); +}); + +describe('Settings — Plugin/Storage đã gộp, không còn mục riêng', () => { + it('nav trái không còn nút "Plugins"/"Data & Storage"', () => { + renderSettings(); + expect(screen.queryByRole('button', { name: 'Plugins' })).toBeNull(); + expect(screen.queryByRole('button', { name: 'Data & Storage' })).toBeNull(); + }); + + it('mục Tools hiện cả danh sách bật/tắt LẪN bảng quyền plugin (trước đây tách ở mục Plugin)', () => { + renderSettings([{ pathname: '/settings', state: { section: 'tools' } }]); + expect(screen.getByRole('heading', { name: 'Tools', level: 2 })).toBeTruthy(); + // Bảng quyền của SettingsPlugins.tsx — mô tả này chỉ có ở đó. + expect( + screen.getByText(/Every tool already COMPILED INTO the app|Mỗi tool BIÊN DỊCH SẴN trong app/), + ).toBeTruthy(); + // Nhật ký hoạt động vẫn theo đúng quy tắc "đóng theo mặc định". + expect(screen.getByRole('button', { name: /Show activity log/ })).toBeTruthy(); + }); + + it('mục About hiện vị trí thư mục dữ liệu (trước đây tách ở mục Data & Storage)', () => { + renderSettings([{ pathname: '/settings', state: { section: 'about' } }]); + expect(screen.getByRole('heading', { name: 'About', level: 2 })).toBeTruthy(); + expect(screen.getByText('Where your data is stored')).toBeTruthy(); + }); +}); diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 89eed75..1d27885 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -294,16 +294,19 @@ const SHORTCUT_GROUPS = getShortcutGroups(); // Nhóm theo mức độ dùng: tuỳ biến hằng ngày trước (Appearance/Tools/ // Shortcuts) → quản lý plugin/tiện ích/tích hợp (Plugins/Extensions/MCP) → // thông tin/thiết lập ít đụng tới nhất (Permissions/About/Storage/Config). +// "Plugin" (SettingsPlugins — quyền + nhật ký của tool biên dịch sẵn) và +// "Storage" (vị trí thư mục dữ liệu) không còn là mục riêng: gộp Plugin vào +// Tools (cùng nói về TOOL, chỉ khác góc nhìn — bật/tắt so với quyền/nhật ký) +// và Storage vào About (một dòng thông tin đơn lẻ không đáng một mục riêng +// trong nav trái). const SETTINGS_SECTIONS = [ { id: 'appearance', label: 'settings.section.appearance' }, { id: 'tools', label: 'settings.section.tools' }, { id: 'shortcuts', label: 'settings.section.shortcuts' }, - { id: 'plugins', label: 'settings.plugins.title' }, { id: 'extensions', label: 'settings.section.extensions' }, { id: 'mcp', label: 'settings.mcp.title' }, { id: 'permissions', label: 'settings.permissions.title' }, { id: 'about', label: 'settings.about.title' }, - { id: 'storage', label: 'settings.storage.title' }, { id: 'config', label: 'settings.config.title' }, ] as const satisfies ReadonlyArray<{ id: string; label: TranslationKey }>; @@ -811,6 +814,14 @@ export function Settings() { })()} + {/* Quyền + nhật ký hoạt động của mọi tool biên dịch sẵn — trước đây + là mục "Plugin" riêng, gộp về đây vì cùng nói về TOOL, chỉ khác + góc nhìn (bật/tắt ở trên, quyền được phép làm gì/đã làm gì ở + dưới) so với việc tách thành hai mục cạnh nhau trong nav. */} +
+ +
+ )} @@ -903,8 +914,6 @@ export function Settings() { )} - {activeSection === 'plugins' && } - {activeSection === 'extensions' && } {activeSection === 'mcp' && ( @@ -1121,11 +1130,10 @@ export function Settings() {

- - )} - {activeSection === 'storage' && ( -
+ {/* Vị trí thư mục dữ liệu — trước đây là mục "Data & Storage" riêng, + gộp về About vì chỉ có một dòng thông tin, không đáng một mục + riêng trong nav trái. */}
diff --git a/src/components/SettingsPlugins.test.tsx b/src/components/SettingsPlugins.test.tsx index cc51704..9388088 100644 --- a/src/components/SettingsPlugins.test.tsx +++ b/src/components/SettingsPlugins.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { render, screen, cleanup, within } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent, within } from '@testing-library/react'; import { SettingsPlugins } from '@/components/SettingsPlugins'; import { FeatureProvider } from '@/contexts/FeatureContext'; import { LocaleProvider } from '@/contexts/LocaleContext'; @@ -12,6 +12,9 @@ import { storageRemove } from '@/lib/persistentStore'; * đúng: quyền hiện ra là quyền THẬT trong manifest (không phải danh sách chép * tay song song — chính thứ mô hình plugin sinh ra để xoá bỏ), và lời gọi BỊ * TỪ CHỐI phải hiện, vì đó là loại lỗi duy nhất không tự lộ ra ở chỗ khác. + * + * Nhật ký hoạt động ĐÓNG theo mặc định (xem SettingsPlugins.tsx) — mọi test + * cần đọc nội dung nhật ký phải bấm mở trước qua `openAuditLog()`. */ function renderPanel() { @@ -26,6 +29,10 @@ function renderPanel() { ); } +function openAuditLog() { + fireEvent.click(screen.getByRole('button', { name: /Show activity log|Hiện nhật ký/ })); +} + beforeEach(() => { pluginAudit.clear(); }); @@ -58,11 +65,20 @@ describe('Settings — Plugins', () => { expect(within(row).getByText(/mcp_/)).toBeTruthy(); }); - it('lời gọi bị từ chối hiện trong nhật ký, kèm quyền còn thiếu', () => { + it('nhật ký hoạt động đóng theo mặc định', () => { + renderPanel(); + expect(screen.queryByPlaceholderText(/Search by tool id|Tìm theo id tool/)).toBeNull(); + expect(screen.getByRole('button', { name: /Show activity log|Hiện nhật ký/ })).toBeTruthy(); + }); + + it('bấm mở thì hiện ô tìm kiếm và danh sách; lời gọi bị từ chối hiện kèm quyền còn thiếu', () => { const sdk = createPluginSdk({ ...PLUGINS[0], permissions: [], commands: [] }); expect(() => sdk.storage.get('k')).toThrow(); renderPanel(); + openAuditLog(); + + expect(screen.getByPlaceholderText(/Search by tool id|Tìm theo id tool/)).toBeTruthy(); // Cả ô "denied" lẫn hàng chứa nó đều khớp regex, nên khớp NHIỀU phần tử là // bình thường — điều cần khẳng định là có một phần tử nêu đúng quyền thiếu. const denied = screen.getAllByText(/denied|bị từ chối/); @@ -71,6 +87,43 @@ describe('Settings — Plugins', () => { it('nhật ký rỗng thì nói rõ là rỗng thay vì để khoảng trắng', () => { renderPanel(); + openAuditLog(); expect(screen.getByText(/No calls yet|Chưa có lời gọi/)).toBeTruthy(); }); + + it('tìm theo id tool lọc đúng dòng, xoá ô tìm kiếm thì hiện lại đủ', () => { + const first = createPluginSdk({ ...PLUGINS[0], permissions: [], commands: [] }); + const second = createPluginSdk({ ...PLUGINS[1], permissions: [], commands: [] }); + expect(() => first.storage.get('k')).toThrow(); + expect(() => second.storage.get('k')).toThrow(); + + renderPanel(); + openAuditLog(); + // `p.id` cũng xuất hiện ở bảng quyền phía trên — dùng getAllByText, chỉ + // cần khẳng định CÓ mặt (permission table + audit row) trước khi lọc. + expect(screen.getAllByText(PLUGINS[0].id).length).toBeGreaterThan(1); + expect(screen.getAllByText(PLUGINS[1].id).length).toBeGreaterThan(1); + + fireEvent.change(screen.getByPlaceholderText(/Search by tool id|Tìm theo id tool/), { + target: { value: PLUGINS[0].id }, + }); + // Sau khi lọc: PLUGINS[0].id vẫn còn (bảng quyền + audit row còn lại), + // PLUGINS[1].id chỉ còn ở bảng quyền (audit row của nó bị lọc mất) — so + // sánh SỐ LẦN xuất hiện giảm đi đúng một, thay vì đòi vắng mặt hẳn. + expect(screen.getAllByText(PLUGINS[0].id).length).toBeGreaterThan(1); + expect(screen.getAllByText(PLUGINS[1].id).length).toBe(1); + }); + + it('từ khoá không khớp gì thì nói rõ, không để trắng danh sách', () => { + const sdk = createPluginSdk({ ...PLUGINS[0], permissions: [], commands: [] }); + expect(() => sdk.storage.get('k')).toThrow(); + + renderPanel(); + openAuditLog(); + fireEvent.change(screen.getByPlaceholderText(/Search by tool id|Tìm theo id tool/), { + target: { value: 'khong-ton-tai' }, + }); + + expect(screen.getByText(/No calls match|Không có lời gọi nào khớp/)).toBeTruthy(); + }); }); diff --git a/src/components/SettingsPlugins.tsx b/src/components/SettingsPlugins.tsx index 1e6fac0..9192554 100644 --- a/src/components/SettingsPlugins.tsx +++ b/src/components/SettingsPlugins.tsx @@ -1,8 +1,9 @@ import { useEffect, useState } from 'react'; -import { Globe, ShieldCheck, Terminal } from 'lucide-react'; +import { ChevronDown, ChevronRight, Globe, Search, ShieldCheck, Terminal, X } from 'lucide-react'; import { useFeatures } from '@/contexts/FeatureContext'; import { useLocale } from '@/contexts/LocaleContext'; import { cn } from '@/lib/utils'; +import { Input } from '@/components/ui/input'; import { PLUGINS, SDK_VERSION, pluginAudit, vaultStatus, type AuditEntry, type VaultStatus } from '@/platform'; /** @@ -28,6 +29,11 @@ export function SettingsPlugins() { const { isFeatureEnabled } = useFeatures(); const [entries, setEntries] = useState(() => pluginAudit.recent(MAX_ROWS)); const [vault, setVault] = useState(null); + // Đóng theo mặc định: nhật ký này dài (tới MAX_ROWS dòng) và ít khi cần + // tra ngay khi mở trang — một mục gấp lại, tự mở khi cần, đỡ chiếm chỗ hơn + // hẳn so với luôn hiện sẵn dưới bảng quyền. + const [auditOpen, setAuditOpen] = useState(false); + const [auditQuery, setAuditQuery] = useState(''); useEffect(() => { // Nhật ký được ghi cả khi Settings đang đóng, nên đọc lại một lần lúc mount @@ -51,6 +57,19 @@ export function SettingsPlugins() { const enabledCount = PLUGINS.filter((p) => isFeatureEnabled(p.id)).length; + // Khớp theo id tool TRƯỚC HẾT (đúng câu hỏi hay gặp nhất — "tool nào gọi + // gì"), nhưng cũng khớp channel/action/detail/quyền thiếu để một từ khoá + // bất kỳ (vd tên một lệnh native, hay "denied") cũng lọc được, không chỉ + // riêng id. + const auditQueryLower = auditQuery.trim().toLowerCase(); + const filteredEntries = auditQueryLower + ? entries.filter((e) => + [e.pluginId, e.channel, e.action, e.detail, e.missingPermission].some((field) => + field?.toLowerCase().includes(auditQueryLower), + ), + ) + : entries; + return (

{t('settings.plugins.description')}

@@ -135,27 +154,64 @@ export function SettingsPlugins() {

{t('settings.plugins.auditDescription')}

-
- {entries.length === 0 ? ( -

{t('settings.plugins.auditEmpty')}

- ) : ( - entries.map((e, i) => ( -
- {timeOf(e.ts)} - {e.pluginId} - {e.channel} - {e.action} - {e.detail && {e.detail}} - {!e.allowed && ( - - {t('settings.plugins.auditDenied')} - {e.missingPermission ? ` · ${e.missingPermission}` : ''} - - )} -
- )) - )} -
+ + + {auditOpen && ( + <> +
+ + setAuditQuery(e.target.value)} + placeholder={t('settings.plugins.auditSearchPlaceholder')} + className="pl-8 pr-8 h-ctl text-xs bg-bg-2/40 border-line" + /> + {auditQuery && ( + + )} +
+ +
+ {entries.length === 0 ? ( +

{t('settings.plugins.auditEmpty')}

+ ) : filteredEntries.length === 0 ? ( +

+ {t('settings.plugins.auditNoMatch', { query: auditQuery })} +

+ ) : ( + filteredEntries.map((e, i) => ( +
+ {timeOf(e.ts)} + {e.pluginId} + {e.channel} + {e.action} + {e.detail && {e.detail}} + {!e.allowed && ( + + {t('settings.plugins.auditDenied')} + {e.missingPermission ? ` · ${e.missingPermission}` : ''} + + )} +
+ )) + )} +
+ + )}
); } diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 6ce0757..7cd87f2 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -199,7 +199,6 @@ export const DICTIONARY = { }, // ── Settings — Plugins (biên dịch sẵn trong app, dùng SDK nội bộ) ──────── - 'settings.plugins.title': { vi: 'Plugin', en: 'Plugins' }, 'settings.plugins.description': { vi: 'Mỗi tool BIÊN DỊCH SẴN trong app là một plugin tự khai báo trong src/plugins//plugin.ts. Bảng dưới đọc thẳng từ các manifest đó: kênh Platform mà plugin được phép dùng, và lệnh native nằm trong allowlist của nó. Muốn cài thêm plugin/service KHÁC không có sẵn ở đây, xem mục Tiện ích.', en: 'Every tool already COMPILED INTO the app is a plugin that declares itself in src/plugins//plugin.ts. The table below is read straight from those manifests: which Platform channels the plugin may use, and which native commands are in its allowlist. To install extra plugins/services not already here, see the Extensions section.', @@ -234,6 +233,19 @@ export const DICTIONARY = { en: 'No calls yet this session.', }, 'settings.plugins.auditDenied': { vi: 'bị từ chối', en: 'denied' }, + 'settings.plugins.auditShow': { + vi: 'Hiện nhật ký ({{count}})', + en: 'Show activity log ({{count}})', + }, + 'settings.plugins.auditHide': { vi: 'Ẩn nhật ký', en: 'Hide activity log' }, + 'settings.plugins.auditSearchPlaceholder': { + vi: 'Tìm theo id tool, hành động, chi tiết…', + en: 'Search by tool id, action, detail…', + }, + 'settings.plugins.auditNoMatch': { + vi: 'Không có lời gọi nào khớp "{{query}}".', + en: 'No calls match "{{query}}".', + }, // ── Settings — Extensions (cài plugin/sidecar KHÁC ngoài bản biên dịch sẵn, // qua Marketplace hoặc dán URL manifest tay) ────────────────────────── @@ -374,8 +386,7 @@ export const DICTIONARY = { en: 'Everything runs on your device. Network access only happens when you ask for it, plus the daily check for app updates — no telemetry, analytics, or other data leaves your machine.', }, - // ── Settings — Data & Storage ───────────────────────────────────────────── - 'settings.storage.title': { vi: 'Dữ liệu & Lưu trữ', en: 'Data & Storage' }, + // ── Settings — Data & Storage (gộp vào About, không còn mục riêng) ──────── 'settings.storage.location': { vi: 'Nơi lưu dữ liệu của bạn', en: 'Where your data is stored' }, 'settings.storage.resolving': { vi: 'Đang xác định…', en: 'Resolving…' }, 'settings.storage.webOnly': { vi: 'Thư mục dữ liệu app (chỉ có ở bản desktop).', en: 'App data folder (available in the desktop app).' }, From e70c85404e1054f1f1eaab1008a3237d613c1678 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 10:09:24 +0000 Subject: [PATCH 3/3] Extensions: couple a Tier B plugin's install/uninstall with its sidecar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Tier B plugin (permissions: ['service']) and its native sidecar are installed as two separate artifact records — a plugin manifest and a service manifest, each with its own uninstall action in the Installed tab. Nothing tied them together: uninstalling the plugin left its sidecar running/installed with nothing left to call it, and uninstalling the sidecar left the plugin calling into a bin that no longer exists. Added findCoupledRecord(record, installed) (installer.ts): given one installed record, returns the other side of the pair — but only when the relationship is EXCLUSIVE (this plugin is the only one declaring that service.bin, and that service isn't relied on by any other installed plugin). A sidecar shared by multiple plugins, or a service installed on its own with nothing depending on it, comes back independent — gỡ one doesn't touch the other, matching how they were installed. SettingsInstalledExtensions.tsx now uses it for uninstall: - A plugin uninstall that has a coupled service goes through the same confirm-before-uninstall gate a service already required (it now indirectly stops a sidecar too), naming the exact counterpart that will also be removed. - Confirming cascades: both records are uninstalled, one busy/refresh cycle, one restart-needed banner. - The reverse direction works the same way — uninstalling the service side of a coupled pair also removes its dependent plugin. - A plugin with no service, a plugin whose service isn't installed, or a shared/standalone service — all uninstall independently exactly as before. Install-side coupling for the Marketplace/deep-link flow already existed (ExtensionInstallDialog installs a plugin's pluginManifestUrl + serviceManifestUrl together in one confirm). The one remaining gap was the plain "Install by URL" tab, which only ever sees one manifest URL at a time and has no way to fetch a second URL it was never given — SettingsExtensionInstaller.tsx now at least surfaces the gap: previewing a plugin manifest that declares a service dependency checks whether that service is already installed, and shows an inline warning naming the missing bin if not, instead of leaving it to a confusing runtime service-call error after install. Added findCoupledRecord tests (installer.test.ts) and cascading-uninstall tests (SettingsInstalledExtensions.test.tsx, both directions, plus shared/ standalone-stays-independent cases) and a preview-warning test (SettingsExtensionInstaller.test.tsx, both missing and already-installed cases). Verified each new behavior by temporarily reverting the corresponding code and confirming the test fails for the right reason, then restoring it. --- .../SettingsExtensionInstaller.test.tsx | 41 +++++++ src/components/SettingsExtensionInstaller.tsx | 22 ++++ .../SettingsInstalledExtensions.test.tsx | 104 ++++++++++++++++++ .../SettingsInstalledExtensions.tsx | 49 +++++++-- src/lib/i18n.ts | 8 ++ src/platform/index.ts | 1 + src/platform/installer.test.ts | 64 +++++++++++ src/platform/installer.ts | 43 ++++++++ 8 files changed, 324 insertions(+), 8 deletions(-) diff --git a/src/components/SettingsExtensionInstaller.test.tsx b/src/components/SettingsExtensionInstaller.test.tsx index 64c78b2..16c2e84 100644 --- a/src/components/SettingsExtensionInstaller.test.tsx +++ b/src/components/SettingsExtensionInstaller.test.tsx @@ -210,6 +210,47 @@ describe('SettingsExtensionInstaller — xem trước (preview)', () => { ).toBeTruthy(), ); }); + + it('kind=plugin có khai service: sidecar đó CHƯA cài thì cảnh báo ngay ở bước xem trước', async () => { + await renderInTauri(); + await settle(); + + invokeMock.mockResolvedValueOnce( + pluginManifestRaw({ service: { bin: 'devtool-svc-redis', methods: ['list'] } }), + ); + invokeMock.mockResolvedValueOnce([]); // listInstalledArtifacts — chưa cài gì + + const input = screen.getByPlaceholderText('https://example.com/extension.json'); + fireEvent.change(input, { target: { value: 'https://example.com/plugin.json' } }); + fireEvent.click(screen.getByRole('button', { name: /Preview|Xem trước/ })); + + await waitFor(() => expect(screen.getByText(/devtool-svc-redis/)).toBeTruthy()); + }); + + it('kind=plugin có khai service NHƯNG sidecar đó ĐÃ cài rồi thì không cảnh báo gì', async () => { + await renderInTauri(); + await settle(); + + invokeMock.mockResolvedValueOnce( + pluginManifestRaw({ service: { bin: 'devtool-svc-redis', methods: ['list'] } }), + ); + invokeMock.mockResolvedValueOnce([ + { + kind: 'service', + manifest: { bin: 'devtool-svc-redis', version: '1.0.0', protocol: 1, targets: {} }, + source_url: 'https://example.com/svc.json', + bin_path: '/tmp/devtool-svc-redis', + installed_at: 0, + }, + ]); + + const input = screen.getByPlaceholderText('https://example.com/extension.json'); + fireEvent.change(input, { target: { value: 'https://example.com/plugin.json' } }); + fireEvent.click(screen.getByRole('button', { name: /Preview|Xem trước/ })); + + await waitFor(() => expect(screen.getByText('Demo')).toBeTruthy()); + expect(screen.queryByText(/devtool-svc-redis/)).toBeNull(); + }); }); describe('SettingsExtensionInstaller — cài đặt (success)', () => { diff --git a/src/components/SettingsExtensionInstaller.tsx b/src/components/SettingsExtensionInstaller.tsx index 84a1662..6df6b7e 100644 --- a/src/components/SettingsExtensionInstaller.tsx +++ b/src/components/SettingsExtensionInstaller.tsx @@ -11,6 +11,7 @@ import { fetchArtifactManifestPreview, getPlugin, installArtifact, + listInstalledArtifacts, type RemoteArtifactManifest, } from '@/platform'; @@ -47,6 +48,14 @@ export function SettingsExtensionInstaller() { const [installing, setInstalling] = useState(false); const [installError, setInstallError] = useState(null); const [needsRestart, setNeedsRestart] = useState(false); + // `bin` của sidecar mà manifest đang xem trước khai cần (permissions có + // 'service') NHƯNG chưa thấy trong danh sách đã cài — `null` khi manifest + // không cần service nào, hoặc sidecar đó đã có sẵn. Chỉ mang tính THÔNG + // BÁO: URL của manifest plugin không mang theo URL của service đi kèm + // (hai tài nguyên tách biệt), nên không thể tự tải/cài hộ — khác lượt cài + // từ Chợ tiện ích/deep link, nơi cả hai URL đã có sẵn trong tay + // (ExtensionInstallDialog cài cả hai cùng lúc). + const [missingServiceBin, setMissingServiceBin] = useState(null); const runPreview = useCallback(async (targetUrl: string, marketId?: string) => { setPreview(null); @@ -54,6 +63,7 @@ export function SettingsExtensionInstaller() { setPreviewError(null); setInstallError(null); setPreviewMarketId(marketId); + setMissingServiceBin(null); if (!targetUrl.trim()) return; setPreviewing(true); try { @@ -63,6 +73,13 @@ export function SettingsExtensionInstaller() { // Hiển thị cho người dùng — TÍNH Ở RUST, không đoán ở phía webview // (cùng nguyên tắc "quyết định luôn do host" của `sidecar_path`). setPreviewTriple(await currentTargetTriple()); + } else if (manifest.service) { + // Plugin này KHAI cần một sidecar — kiểm xem sidecar đó đã cài chưa, + // để cảnh báo NGAY ở bước xem trước thay vì để người dùng tự phát + // hiện qua lỗi "service call" mơ hồ sau khi đã cài xong. + const installed = await listInstalledArtifacts(); + const hasService = installed.some((r) => r.kind === 'service' && r.manifest.bin === manifest.service!.bin); + if (!hasService) setMissingServiceBin(manifest.service.bin); } } catch (e) { setPreviewError(String(e instanceof Error ? e.message : e)); @@ -168,6 +185,11 @@ export function SettingsExtensionInstaller() { ))} )} + {missingServiceBin && ( + + {t('settings.extensions.install.serviceDependencyMissing', { bin: missingServiceBin })} + + )} ) : (
diff --git a/src/components/SettingsInstalledExtensions.test.tsx b/src/components/SettingsInstalledExtensions.test.tsx index 4f71f23..407b8ed 100644 --- a/src/components/SettingsInstalledExtensions.test.tsx +++ b/src/components/SettingsInstalledExtensions.test.tsx @@ -224,3 +224,107 @@ describe('SettingsInstalledExtensions — gỡ một plugin', () => { }); }); }); + +describe('SettingsInstalledExtensions — plugin + sidecar riêng LIÊN QUAN đến nhau (findCoupledRecord)', () => { + function coupledPluginRaw(overrides: Partial> = {}) { + return installedPluginRaw({ + manifest: pluginManifestRaw({ + id: 'redis-client', + label: 'Redis', + permissions: ['service'], + service: { bin: 'devtool-svc-demo', methods: ['list'] }, + }), + ...overrides, + }); + } + + it('gỡ plugin có sidecar riêng (không ai dùng chung) đòi xác nhận, nêu rõ sẽ gỡ luôn sidecar, rồi gỡ CẢ HAI', async () => { + invokeMock.mockResolvedValueOnce([coupledPluginRaw(), installedServiceRaw()]); // list lúc mount + await renderInTauri(); + await settle(); + + await waitFor(() => expect(screen.getByText('Redis')).toBeTruthy()); + const row = screen.getByText('Redis').closest('div')!.parentElement!.parentElement!; + const uninstallButton = within(row).getAllByRole('button').at(-1)!; + fireEvent.click(uninstallButton); + + // Xác nhận trước, CHƯA gọi uninstall nào — vì gỡ plugin này kéo theo + // dừng sidecar của nó. + await waitFor(() => expect(screen.getAllByText(/devtool-svc-demo/).length).toBeGreaterThan(0)); + expect(invokeMock).not.toHaveBeenCalledWith('artifact_installer_uninstall', expect.anything()); + + invokeMock.mockResolvedValueOnce(undefined); // uninstall plugin + invokeMock.mockResolvedValueOnce(undefined); // uninstall sidecar liên quan + invokeMock.mockResolvedValueOnce([]); // refresh sau khi gỡ cả hai + + fireEvent.click(screen.getByRole('button', { name: /^Confirm$|^Xác nhận$/ })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'redis-client', marketId: undefined }); + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'devtool-svc-demo', marketId: undefined }); + }); + }); + + it('gỡ sidecar (chiều ngược lại) cũng đòi xác nhận và nêu rõ plugin sẽ gỡ theo', async () => { + invokeMock.mockResolvedValueOnce([coupledPluginRaw(), installedServiceRaw()]); // list lúc mount + await renderInTauri(); + await settle(); + + await waitFor(() => expect(screen.getByText('devtool-svc-demo@1.0.0')).toBeTruthy()); + const row = screen.getByText('devtool-svc-demo@1.0.0').closest('div')!.parentElement!.parentElement!; + const uninstallButton = within(row).getAllByRole('button').at(-1)!; + fireEvent.click(uninstallButton); + + await waitFor(() => expect(screen.getAllByText(/Redis/).length).toBeGreaterThan(0)); + + invokeMock.mockResolvedValueOnce(undefined); // uninstall service + invokeMock.mockResolvedValueOnce(undefined); // uninstall plugin liên quan + invokeMock.mockResolvedValueOnce([]); // refresh + + fireEvent.click(screen.getByRole('button', { name: /^Confirm$|^Xác nhận$/ })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'devtool-svc-demo', marketId: undefined }); + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'redis-client', marketId: undefined }); + }); + }); + + it('sidecar dùng chung bởi 2 plugin → ĐỘC LẬP, gỡ một plugin không kéo theo sidecar, không cần xác nhận', async () => { + const pluginA = coupledPluginRaw({ manifest: pluginManifestRaw({ id: 'plugin-a', label: 'Plugin A', permissions: ['service'], service: { bin: 'devtool-svc-demo', methods: ['list'] } }) }); + const pluginB = coupledPluginRaw({ manifest: pluginManifestRaw({ id: 'plugin-b', label: 'Plugin B', permissions: ['service'], service: { bin: 'devtool-svc-demo', methods: ['list'] } }) }); + invokeMock.mockResolvedValueOnce([pluginA, pluginB, installedServiceRaw()]); // list lúc mount + await renderInTauri(); + await settle(); + + await waitFor(() => expect(screen.getByText('Plugin A')).toBeTruthy()); + const row = screen.getByText('Plugin A').closest('div')!.parentElement!.parentElement!; + const uninstallButton = within(row).getAllByRole('button').at(-1)!; + + invokeMock.mockResolvedValueOnce(undefined); // uninstall plugin-a — CHỈ một lần, không kéo theo sidecar + invokeMock.mockResolvedValueOnce([pluginB, installedServiceRaw()]); // refresh + fireEvent.click(uninstallButton); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'plugin-a', marketId: undefined }); + }); + expect(invokeMock).not.toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'devtool-svc-demo', marketId: undefined }); + }); + + it('plugin khai service.bin nhưng sidecar CHƯA cài → gỡ ngay, không đòi xác nhận (không có gì để ghép cặp)', async () => { + invokeMock.mockResolvedValueOnce([coupledPluginRaw()]); // list lúc mount — KHÔNG có sidecar nào cài kèm + await renderInTauri(); + await settle(); + + await waitFor(() => expect(screen.getByText('Redis')).toBeTruthy()); + const row = screen.getByText('Redis').closest('div')!.parentElement!.parentElement!; + const uninstallButton = within(row).getAllByRole('button').at(-1)!; + + invokeMock.mockResolvedValueOnce(undefined); // uninstall + invokeMock.mockResolvedValueOnce([]); // refresh + fireEvent.click(uninstallButton); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'redis-client', marketId: undefined }); + }); + }); +}); diff --git a/src/components/SettingsInstalledExtensions.tsx b/src/components/SettingsInstalledExtensions.tsx index dece1fc..2c78245 100644 --- a/src/components/SettingsInstalledExtensions.tsx +++ b/src/components/SettingsInstalledExtensions.tsx @@ -9,6 +9,7 @@ import { Spinner } from '@/components/ui/spinner'; import { checkForServiceUpdate, checkForUpdate, + findCoupledRecord, installPlugin, installService, listInstalledArtifacts, @@ -38,6 +39,13 @@ function rawArtifactId(record: InstalledArtifactRecord): string { return record.kind === 'plugin' ? record.manifest.id : record.manifest.bin; } +/** Tên hiển thị ngắn gọn cho bản ghi PHÍA BÊN KIA của một cặp liên quan + * (`findCoupledRecord`) — dùng trong lời cảnh báo trước khi gỡ, để người + * dùng biết CHÍNH XÁC cái gì sẽ bị gỡ theo, không chỉ "một thứ liên quan". */ +function coupledLabel(record: InstalledArtifactRecord): string { + return record.kind === 'plugin' ? record.manifest.label : record.manifest.bin; +} + /** Hành động đang chờ người dùng xác nhận cho một dòng `kind=service` — chỉ * service mới cần bước xác nhận thêm này. */ type PendingServiceAction = 'update' | 'uninstall'; @@ -77,19 +85,36 @@ export function SettingsInstalledExtensions() { return {t('settings.extensions.install.webWarning')}; } + /** + * Gỡ MỘT bản ghi — cùng lúc gỡ theo bản ghi LIÊN QUAN đến nó, nếu có + * (`findCoupledRecord`: một plugin Tier B và sidecar riêng của nó, khi + * không ai khác dùng chung sidecar đó). Bỏ lại phần kia sẽ để một sidecar + * mồ côi (không tool nào gọi tới) hoặc một plugin gọi vào một bin đã biến + * mất — hai phần ĐỘC LẬP (sidecar dùng chung, hoặc không ai phụ thuộc) thì + * không bị kéo theo, `coupled` khi đó là `undefined`. + */ const performUninstall = async (record: InstalledArtifactRecord) => { const key = recordKey(record); - setRowBusy((prev) => ({ ...prev, [key]: true })); + const coupled = findCoupledRecord(record, installed); + const coupledKey = coupled ? recordKey(coupled) : undefined; + setRowBusy((prev) => ({ ...prev, [key]: true, ...(coupledKey ? { [coupledKey]: true } : {}) })); setRowError((prev) => ({ ...prev, [key]: '' })); try { await uninstallArtifact(rawArtifactId(record), record.marketId); + if (coupled) { + await uninstallArtifact(rawArtifactId(coupled), coupled.marketId); + } setNeedsRestart(true); await refresh(); } catch (e) { setRowError((prev) => ({ ...prev, [key]: String(e instanceof Error ? e.message : e) })); } finally { - setRowBusy((prev) => ({ ...prev, [key]: false })); - setPendingServiceAction((prev) => ({ ...prev, [key]: undefined })); + setRowBusy((prev) => ({ ...prev, [key]: false, ...(coupledKey ? { [coupledKey]: false } : {}) })); + setPendingServiceAction((prev) => ({ + ...prev, + [key]: undefined, + ...(coupledKey ? { [coupledKey]: undefined } : {}), + })); } }; @@ -114,11 +139,13 @@ export function SettingsInstalledExtensions() { } }; - /** Plugin: thực hiện ngay (không có tiến trình nào bị dừng). Service: bước - * đầu chỉ HIỆN cảnh báo, hành động thật chỉ chạy sau khi người dùng bấm - * "Xác nhận" ở `handleConfirmServiceAction`. */ + /** Plugin KHÔNG có sidecar liên quan: thực hiện ngay (không có tiến trình + * nào bị dừng). Service, hoặc một plugin CÓ sidecar liên quan (gỡ nó kéo + * theo dừng sidecar đó): bước đầu chỉ HIỆN cảnh báo, hành động thật chỉ + * chạy sau khi người dùng bấm "Xác nhận" ở `handleConfirmServiceAction`. */ const handleUninstallClick = (record: InstalledArtifactRecord) => { - if (record.kind === 'plugin') { + const coupled = findCoupledRecord(record, installed); + if (record.kind === 'plugin' && !coupled) { void performUninstall(record); return; } @@ -190,6 +217,7 @@ export function SettingsInstalledExtensions() { const busy = rowBusy[key] ?? false; const updateVersion = rowUpdateVersion[key]; const pending = pendingServiceAction[key]; + const coupled = findCoupledRecord(record, installed); return (
@@ -261,7 +289,12 @@ export function SettingsInstalledExtensions() {
} > - {t('settings.extensions.installed.serviceWarning')} + {pending === 'uninstall' && coupled && ( +

+ {t('settings.extensions.installed.coupledUninstallNote', { label: coupledLabel(coupled) })} +

+ )} + {record.kind === 'service' && t('settings.extensions.installed.serviceWarning')} )}
diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 7cd87f2..503bd00 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -289,6 +289,10 @@ export const DICTIONARY = { vi: 'Manifest KHÔNG có bản cho nền tảng này — cài sẽ bị từ chối.', en: 'The manifest has NO build for this platform — install will be rejected.', }, + 'settings.extensions.install.serviceDependencyMissing': { + vi: 'Plugin này cần sidecar "{{bin}}" để hoạt động đầy đủ, và sidecar đó CHƯA được cài. Dán URL manifest service của nó (thường đi kèm cùng chỗ với plugin này) và cài riêng — hai URL tách biệt nhau, không tự tải hộ được.', + en: 'This plugin needs the "{{bin}}" sidecar to work fully, and it isn’t installed yet. Paste its service manifest URL (usually listed alongside this plugin) and install it separately — the two are separate URLs, this can’t fetch it for you.', + }, 'settings.extensions.installed.title': { vi: 'Tiện ích đã cài', en: 'Installed extensions' }, 'settings.extensions.installed.empty': { vi: 'Chưa cài tiện ích nào.', en: 'No extensions installed yet.' }, 'settings.extensions.installed.checkUpdate': { vi: 'Kiểm bản mới', en: 'Check for update' }, @@ -303,6 +307,10 @@ export const DICTIONARY = { vi: 'Cập nhật/gỡ sẽ DỪNG sidecar này nếu đang chạy — một kết nối hoặc stream đang mở sẽ bị ngắt ngay.', en: 'Updating/uninstalling will STOP this sidecar if it is running — any open connection or stream will be cut immediately.', }, + 'settings.extensions.installed.coupledUninstallNote': { + vi: 'Sẽ gỡ luôn "{{label}}" — hai phần này liên quan trực tiếp với nhau (một bên không dùng được nếu thiếu bên kia).', + en: 'Will also uninstall "{{label}}" — the two are directly linked (one doesn’t work without the other).', + }, 'settings.extensions.installed.confirm': { vi: 'Xác nhận', en: 'Confirm' }, 'settings.extensions.installed.cancel': { vi: 'Huỷ', en: 'Cancel' }, diff --git a/src/platform/index.ts b/src/platform/index.ts index 2a2c80f..38c2024 100644 --- a/src/platform/index.ts +++ b/src/platform/index.ts @@ -35,6 +35,7 @@ export { checkAllForUpdates, recordKey, assertNoConflictingInstall, + findCoupledRecord, } from './installer'; export type { RemotePluginManifest, diff --git a/src/platform/installer.test.ts b/src/platform/installer.test.ts index bea7b29..926ac82 100644 --- a/src/platform/installer.test.ts +++ b/src/platform/installer.test.ts @@ -586,3 +586,67 @@ describe('recordKey', () => { expect(key).toBe('devtool-svc-demo'); }); }); + +describe('findCoupledRecord', () => { + function pluginRecord(overrides: Partial> = {}) { + return { + kind: 'plugin' as const, + manifest: remoteManifest(overrides), + sourceUrl: 'x', + bundlePath: 'x', + installedAt: 0, + }; + } + + function serviceRecord(bin: string) { + return { + kind: 'service' as const, + manifest: { bin, version: '1.0.0', protocol: 1, targets: {} }, + sourceUrl: 'x', + binPath: 'x', + installedAt: 0, + }; + } + + it('plugin khai service.bin + đúng một sidecar cùng bin đã cài → trả về sidecar đó', async () => { + const installer = await loadInTauri(); + const plugin = pluginRecord({ id: 'redis-client', service: { bin: 'devtool-svc-redis', methods: ['list'] } }); + const service = serviceRecord('devtool-svc-redis'); + expect(installer.findCoupledRecord(plugin, [plugin, service])).toBe(service); + }); + + it('đối xứng: gọi với bản ghi service trả về plugin PHỤ THUỘC DUY NHẤT vào nó', async () => { + const installer = await loadInTauri(); + const plugin = pluginRecord({ id: 'redis-client', service: { bin: 'devtool-svc-redis', methods: ['list'] } }); + const service = serviceRecord('devtool-svc-redis'); + expect(installer.findCoupledRecord(service, [plugin, service])).toBe(plugin); + }); + + it('plugin không khai service nào → không có gì để ghép cặp', async () => { + const installer = await loadInTauri(); + const plugin = pluginRecord({ id: 'json' }); + expect(installer.findCoupledRecord(plugin, [plugin])).toBeUndefined(); + }); + + it('plugin khai service.bin nhưng sidecar đó CHƯA cài → không có gì để ghép cặp', async () => { + const installer = await loadInTauri(); + const plugin = pluginRecord({ id: 'redis-client', service: { bin: 'devtool-svc-redis', methods: ['list'] } }); + expect(installer.findCoupledRecord(plugin, [plugin])).toBeUndefined(); + }); + + it('service bị NHIỀU plugin dùng chung → độc lập, không ghép cặp với plugin nào', async () => { + const installer = await loadInTauri(); + const pluginA = pluginRecord({ id: 'plugin-a', service: { bin: 'shared-svc', methods: ['list'] } }); + const pluginB = pluginRecord({ id: 'plugin-b', service: { bin: 'shared-svc', methods: ['list'] } }); + const service = serviceRecord('shared-svc'); + expect(installer.findCoupledRecord(pluginA, [pluginA, pluginB, service])).toBeUndefined(); + expect(installer.findCoupledRecord(pluginB, [pluginA, pluginB, service])).toBeUndefined(); + expect(installer.findCoupledRecord(service, [pluginA, pluginB, service])).toBeUndefined(); + }); + + it('service cài riêng, không plugin nào phụ thuộc → độc lập', async () => { + const installer = await loadInTauri(); + const service = serviceRecord('standalone-svc'); + expect(installer.findCoupledRecord(service, [service])).toBeUndefined(); + }); +}); diff --git a/src/platform/installer.ts b/src/platform/installer.ts index 3c7569d..77b7571 100644 --- a/src/platform/installer.ts +++ b/src/platform/installer.ts @@ -471,6 +471,49 @@ export function recordKey(record: InstalledArtifactRecord): string { return record.marketId ? `${record.marketId}::${record.manifest.id}` : record.manifest.id; } +/** + * Một plugin Tier B khai `service: { bin, ... }` trong manifest của nó CÓ + * THỂ đi kèm một sidecar cài riêng (`kind: "service"`, cùng `bin`) — hai bản + * ghi cài đặt khác nhau cho cùng MỘT "tiện ích" theo mắt người dùng. Khi cả + * hai đang cùng cài và không ai khác phụ thuộc sidecar đó, chúng LIÊN QUAN + * đến nhau — gỡ một bên mà không gỡ bên kia để lại một sidecar mồ côi + * (không tool nào gọi tới) hoặc một plugin gọi `sdk.service.call` vào một + * bin đã biến mất. + * + * Trả về bản ghi PHÍA BÊN KIA nếu, và chỉ nếu, quan hệ này ĐỘC QUYỀN — nghĩa + * là không có bản ghi plugin ĐÃ CÀI nào khác cùng khai `service.bin` này. + * Một sidecar dùng chung bởi ≥2 plugin, hoặc một service cài riêng không + * plugin nào phụ thuộc, là hai phần ĐỘC LẬP — gỡ bên này không kéo theo bên + * kia, để người dùng tự quyết từng phần. + * + * Đối xứng hai chiều: gọi với một bản ghi plugin trả về sidecar của nó (nếu + * có, độc quyền); gọi với một bản ghi service trả về plugin PHỤ THUỘC DUY + * NHẤT vào nó (nếu có). + */ +export function findCoupledRecord( + record: InstalledArtifactRecord, + installed: InstalledArtifactRecord[], +): InstalledArtifactRecord | undefined { + if (record.kind === 'plugin') { + const bin = record.manifest.service?.bin; + if (!bin) return undefined; + const service = installed.find((r): r is Extract => + r.kind === 'service' && r.manifest.bin === bin, + ); + if (!service) return undefined; + const otherDependents = installed.filter( + (r) => r.kind === 'plugin' && r.manifest.id !== record.manifest.id && r.manifest.service?.bin === bin, + ); + return otherDependents.length === 0 ? service : undefined; + } + + const dependents = installed.filter( + (r): r is Extract => + r.kind === 'plugin' && r.manifest.service?.bin === record.manifest.bin, + ); + return dependents.length === 1 ? dependents[0] : undefined; +} + /** Kiểm từng artifact đã cài, TUẦN TỰ (không Promise.all) — best-effort, một * nguồn lỗi (mạng, URL đã đổi/chết) chỉ loại đúng mục đó khỏi kết quả, không * chặn việc kiểm các mục còn lại hay ném lỗi ra ngoài (lời gọi này chạy nền