Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/components/McpSetupDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
</p>
</div>
{MCP_TOOL_IDS.map((id) => {
{availableMcpToolIds.map((id) => {
const label = TOOL_DEFS.find((td) => td.id === id)?.label ?? id;
const enabled = isToolEnabled(id);
return (
Expand Down
76 changes: 76 additions & 0 deletions src/components/Settings.mcpToolList.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={[{ pathname: '/settings', state: { section: 'mcp' } }]}>
<AppConfigProvider>
<LocaleProvider>
<FeatureProvider>
<OnboardingProvider>
<UpdateProvider>
<ExtensionUpdateProvider>
<Settings />
</ExtensionUpdateProvider>
</UpdateProvider>
</OnboardingProvider>
</FeatureProvider>
</LocaleProvider>
</AppConfigProvider>
</MemoryRouter>,
);
}

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();
});
});
39 changes: 39 additions & 0 deletions src/components/Settings.mergedSections.test.tsx
Original file line number Diff line number Diff line change
@@ -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();
});
});
33 changes: 24 additions & 9 deletions src/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>;

Expand All @@ -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 đủ.
Expand Down Expand Up @@ -804,6 +814,14 @@ export function Settings() {
})()}
</div>

{/* 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. */}
<div className="pt-2 border-t border-line">
<SettingsPlugins />
</div>

</section>
)}

Expand Down Expand Up @@ -896,8 +914,6 @@ export function Settings() {
</section>
)}

{activeSection === 'plugins' && <SettingsPlugins />}

{activeSection === 'extensions' && <SettingsExtensions />}

{activeSection === 'mcp' && (
Expand Down Expand Up @@ -929,7 +945,7 @@ export function Settings() {
{t('settings.mcp.perToolDescription')}
</p>
</div>
{MCP_TOOL_IDS.map((id) => {
{availableMcpToolIds.map((id) => {
const label = TOOL_DEFS.find((td) => td.id === id)?.label ?? id;
const enabled = isMcpToolEnabled(id);
return (
Expand Down Expand Up @@ -1114,11 +1130,10 @@ export function Settings() {
</p>
</div>
</div>
</section>
)}

{activeSection === 'storage' && (
<section className="space-y-3">
{/* 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. */}
<div className="rounded-lg border divide-y">
<div className="flex items-center justify-between gap-3 px-4 py-3">
<div className="min-w-0">
Expand Down
41 changes: 41 additions & 0 deletions src/components/SettingsExtensionInstaller.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
22 changes: 22 additions & 0 deletions src/components/SettingsExtensionInstaller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
fetchArtifactManifestPreview,
getPlugin,
installArtifact,
listInstalledArtifacts,
type RemoteArtifactManifest,
} from '@/platform';

Expand Down Expand Up @@ -47,13 +48,22 @@ export function SettingsExtensionInstaller() {
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(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<string | null>(null);

const runPreview = useCallback(async (targetUrl: string, marketId?: string) => {
setPreview(null);
setPreviewTriple(null);
setPreviewError(null);
setInstallError(null);
setPreviewMarketId(marketId);
setMissingServiceBin(null);
if (!targetUrl.trim()) return;
setPreviewing(true);
try {
Expand All @@ -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));
Expand Down Expand Up @@ -168,6 +185,11 @@ export function SettingsExtensionInstaller() {
))}
</div>
)}
{missingServiceBin && (
<Callout tone="warning" size="sm">
{t('settings.extensions.install.serviceDependencyMissing', { bin: missingServiceBin })}
</Callout>
)}
</>
) : (
<div className="space-y-1">
Expand Down
Loading
Loading