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.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 cb23d71..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 }>; @@ -322,6 +325,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 đủ. @@ -804,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. */} +
+ +
+ )} @@ -896,8 +914,6 @@ export function Settings() { )} - {activeSection === 'plugins' && } - {activeSection === 'extensions' && } {activeSection === 'mcp' && ( @@ -929,7 +945,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 ( @@ -1114,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/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/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..503bd00 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) ────────────────────────── @@ -277,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' }, @@ -291,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' }, @@ -374,8 +394,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).' }, 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