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() {
))}
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() {
+ >
+ )}
);
}
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