diff --git a/src/components/SettingsExtensionInstaller.test.tsx b/src/components/SettingsExtensionInstaller.test.tsx index f0516aed..64c78b25 100644 --- a/src/components/SettingsExtensionInstaller.test.tsx +++ b/src/components/SettingsExtensionInstaller.test.tsx @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, cleanup, fireEvent, waitFor, within, act } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent, waitFor, act } from '@testing-library/react'; import { storageRemove } from '@/lib/persistentStore'; /** @@ -9,15 +9,10 @@ import { storageRemove } from '@/lib/persistentStore'; * nhất để mọi module transitively import `@/lib/platform` đọc đúng giá trị * test này cần. * - * `settle()` NGAY SAU `renderInTauri()`: lần `import('@tauri-apps/api/core')` - * ĐẦU TIÊN (bên trong `invokeCommand`, gọi lúc mount để `listInstalledArtifacts` - * chạy) là một dynamic import thật, còn đang "in-flight" khi `render()` trả - * về. Bắn thêm một sự kiện gọi `invoke` NGAY LẬP TỨC (không chờ gì) có thể đua - * với chính lần import đó và (quan sát thực nghiệm) đôi khi request một bản - * KHÔNG qua `vi.mock` — không phải lỗi ở component hay ở `installer.ts`, mà là - * một cuộc đua giữa `resetModules()` + dynamic import trong bài test. Chờ một - * nhịp (bọc trong `act` để React cũng flush kịp) trước khi tương tác tiếp là - * đủ để loại cuộc đua này. + * Danh sách "đã cài" (gỡ/cập nhật) không còn ở component này nữa — xem + * `SettingsInstalledExtensions.test.tsx`. File này chỉ còn phủ form "cài từ + * URL": xem trước + cài, không có gì gọi `listInstalledArtifacts` lúc mount + * nữa nên không cần mock "list lúc mount" như bản cũ. */ async function settle(ms = 20) { await act(async () => { @@ -60,17 +55,6 @@ function serviceManifestRaw(overrides: Partial> = {}) { }; } -function installedPluginRaw(overrides: Partial> = {}) { - return { - kind: 'plugin' as const, - manifest: pluginManifestRaw(), - source_url: 'https://example.com/plugin.json', - bundle_path: '/tmp/demo/1.0.0/bundle.mjs', - installed_at: 1_700_000_000_000, - ...overrides, - }; -} - function installedServiceRaw(overrides: Partial> = {}) { const { manifest: manifestOverrides, ...rest } = overrides; return { @@ -106,13 +90,10 @@ async function renderInstaller(opts?: { tauri?: boolean }) { // `useContext` bên trong component không thấy đúng Provider, ném "must be // used within a LocaleProvider" dù JSX nhìn đúng. const { LocaleProvider } = await import('@/contexts/LocaleContext'); - const { ExtensionUpdateProvider } = await import('@/contexts/ExtensionUpdateContext'); const { SettingsExtensionInstaller } = await import('@/components/SettingsExtensionInstaller'); return render( - - - + , ); } @@ -138,21 +119,8 @@ describe('SettingsExtensionInstaller — bản web (không phải Tauri)', () => }); }); -describe('SettingsExtensionInstaller — danh sách rỗng (empty state)', () => { - it('nói rõ chưa cài gì thay vì để trống', async () => { - invokeMock.mockResolvedValueOnce([]); // artifact_installer_list lúc mount - await renderInTauri(); - await settle(); - - await waitFor(() => { - expect(screen.getByText(/No extensions installed yet\.|Chưa cài tiện ích nào\./)).toBeTruthy(); - }); - }); -}); - describe('SettingsExtensionInstaller — xem trước (preview)', () => { it('trạng thái loading: nút Preview vô hiệu hoá trong lúc chờ, rồi hiện kết quả', async () => { - invokeMock.mockResolvedValueOnce([]); // list lúc mount await renderInTauri(); await settle(); @@ -177,7 +145,6 @@ describe('SettingsExtensionInstaller — xem trước (preview)', () => { }); it('trạng thái lỗi: hiện thông báo lỗi từ Rust, không rơi vào success', async () => { - invokeMock.mockResolvedValueOnce([]); // list lúc mount await renderInTauri(); await settle(); @@ -190,7 +157,6 @@ describe('SettingsExtensionInstaller — xem trước (preview)', () => { }); it('kind=service: hiển thị badge Service, target-triple máy hiện tại, và danh sách target manifest có', async () => { - invokeMock.mockResolvedValueOnce([]); // list lúc mount await renderInTauri(); await settle(); @@ -215,7 +181,6 @@ describe('SettingsExtensionInstaller — xem trước (preview)', () => { // plugin) — bấm Install ở đây phải đi thẳng tới artifact_installer_install, // không có lệnh list nào chen vào trước. invokeMock.mockResolvedValueOnce(installedServiceRaw()); - invokeMock.mockResolvedValueOnce([installedServiceRaw()]); // refresh sau khi cài fireEvent.click(screen.getByRole('button', { name: /^Install$|^Cài đặt$/ })); await waitFor(() => { @@ -227,7 +192,6 @@ describe('SettingsExtensionInstaller — xem trước (preview)', () => { }); it('kind=service: nền tảng không khớp thì cảnh báo rõ, không đoán/thử triple khác', async () => { - invokeMock.mockResolvedValueOnce([]); // list lúc mount await renderInTauri(); await settle(); @@ -248,9 +212,8 @@ describe('SettingsExtensionInstaller — xem trước (preview)', () => { }); }); -describe('SettingsExtensionInstaller — cài đặt (success) và danh sách hỗn hợp', () => { - it('cài xong thì bật cờ cần khởi động lại, và danh sách phân biệt badge theo kind', async () => { - invokeMock.mockResolvedValueOnce([]); // list lúc mount +describe('SettingsExtensionInstaller — cài đặt (success)', () => { + it('cài xong thì bật cờ cần khởi động lại', async () => { await renderInTauri(); await settle(); @@ -261,8 +224,7 @@ describe('SettingsExtensionInstaller — cài đặt (success) và danh sách h await waitFor(() => expect(screen.getByText('Demo')).toBeTruthy()); invokeMock.mockResolvedValueOnce([]); // list, assertNoConflictingInstall trước khi cài - invokeMock.mockResolvedValueOnce(installedPluginRaw()); // install - invokeMock.mockResolvedValueOnce([installedPluginRaw(), installedServiceRaw()]); // refresh sau khi cài + invokeMock.mockResolvedValueOnce(pluginManifestRaw()); // wrapped as InstalledPluginRecord by Rust normally; shape doesn't matter here fireEvent.click(screen.getByRole('button', { name: /^Install$|^Cài đặt$/ })); @@ -272,66 +234,6 @@ describe('SettingsExtensionInstaller — cài đặt (success) và danh sách h expect(invokeMock).toHaveBeenCalledWith('artifact_installer_install', { sourceUrl: 'https://example.com/plugin.json', }); - - const badges = screen.getAllByText(/^(Plugin|Service)$/); - expect(badges.map((b) => b.textContent).sort()).toEqual(['Plugin', 'Service']); - }); -}); - -describe('SettingsExtensionInstaller — service đòi xác nhận trước khi cập nhật/gỡ', () => { - it('bấm Gỡ trên một dòng service chỉ HIỆN cảnh báo, chưa gọi uninstall — phải bấm Xác nhận mới thật sự gỡ', async () => { - invokeMock.mockResolvedValueOnce([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); - - // Cảnh báo hiện ra, KHÔNG gọi uninstall ngay. - await waitFor(() => - expect( - screen.getByText(/will STOP this sidecar if it is running|sẽ DỪNG sidecar này nếu đang chạy/), - ).toBeTruthy(), - ); - expect(invokeMock).not.toHaveBeenCalledWith('artifact_installer_uninstall', expect.anything()); - - invokeMock.mockResolvedValueOnce(undefined); // uninstall - invokeMock.mockResolvedValueOnce([]); // refresh sau khi gỡ - - fireEvent.click(screen.getByRole('button', { name: /^Confirm$|^Xác nhận$/ })); - - await waitFor(() => { - expect(invokeMock).toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'devtool-svc-demo' }); - }); - }); - - it('bấm Huỷ ở cảnh báo thì không gọi uninstall, cảnh báo biến mất', async () => { - invokeMock.mockResolvedValueOnce([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.getByText(/will STOP this sidecar if it is running|sẽ DỪNG sidecar này nếu đang chạy/), - ).toBeTruthy(), - ); - - fireEvent.click(screen.getByRole('button', { name: /^Cancel$|^Huỷ$/ })); - - await waitFor(() => - expect( - screen.queryByText(/will STOP this sidecar if it is running|sẽ DỪNG sidecar này nếu đang chạy/), - ).toBeNull(), - ); - expect(invokeMock).not.toHaveBeenCalledWith('artifact_installer_uninstall', expect.anything()); }); }); diff --git a/src/components/SettingsExtensionInstaller.tsx b/src/components/SettingsExtensionInstaller.tsx index dad9fc33..84a1662e 100644 --- a/src/components/SettingsExtensionInstaller.tsx +++ b/src/components/SettingsExtensionInstaller.tsx @@ -1,7 +1,5 @@ -import { useCallback, useEffect, useState } from 'react'; -import { Download, RefreshCw, Trash2 } from 'lucide-react'; +import { useCallback, useState } from 'react'; import { useLocale } from '@/contexts/LocaleContext'; -import { useExtensionUpdates } from '@/contexts/ExtensionUpdateContext'; import { isTauri } from '@/lib/platform'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -9,57 +7,33 @@ import { Callout } from '@/components/ui/callout'; import { Spinner } from '@/components/ui/spinner'; import { assertNoConflictingInstall, - checkForServiceUpdate, - checkForUpdate, currentTargetTriple, fetchArtifactManifestPreview, getPlugin, installArtifact, - installPlugin, - installService, - listInstalledArtifacts, - recordKey, - uninstallArtifact, - type InstalledArtifactRecord, type RemoteArtifactManifest, } from '@/platform'; /** - * Cài / cập nhật / gỡ một "tiện ích" (plugin JS hoặc sidecar service native) - * từ một URL manifest bên ngoài — thay `SettingsPluginInstaller.tsx` (chỉ - * biết plugin JS). Xem docs/plans/native-sidecar-install.md cho bối cảnh đầy - * đủ và lý do gộp chung một UI cho cả hai `kind` thay vì hai component song - * song. + * Cài một "tiện ích" (plugin JS hoặc sidecar service native) từ một URL + * manifest bên ngoài — thay `SettingsPluginInstaller.tsx` (chỉ biết plugin + * JS). Xem docs/plans/native-sidecar-install.md cho bối cảnh đầy đủ và lý do + * gộp chung một UI cho cả hai `kind` thay vì hai component song song. + * + * Danh sách "đã cài" (cập nhật/gỡ) không còn ở đây nữa — xem + * `SettingsInstalledExtensions.tsx`, tab riêng của `SettingsExtensions.tsx`: + * trước đây nó bị chôn dưới tab này, nên một người chỉ dùng Chợ tiện ích để + * cài sẽ không bao giờ tìm ra chỗ để gỡ. * * KHÔNG áp dụng ngay lập tức: cả registry plugin (`initInstalledPlugins()`, * chạy đúng một lần lúc app khởi động) LẪN sidecar service (được * `service_host::get_or_spawn` tự spawn khi có lệnh gọi đầu tiên, không phải * tự khởi động lại phiên đang chạy) chỉ nhận thay đổi rõ ràng sau một lần - * khởi động lại/gọi lại — mọi thao tác ở đây vì vậy kết thúc bằng việc bật cờ - * "cần khởi động lại", không phải cập nhật UI tại chỗ ngay lập tức. Riêng - * service, cập nhật/gỡ còn tự dừng tiến trình cũ ngay lập tức ở phía Rust - * (self-healing — lần gọi service kế tiếp tự spawn lại bản mới), nên cảnh báo - * ngắt-kết-nối bên dưới KHÔNG phải chuyện lý thuyết. + * khởi động lại/gọi lại — cài xong vì vậy chỉ bật cờ "cần khởi động lại" + * (hiện ở tab Đã cài, nơi người dùng sẽ thấy ngay sau khi chuyển qua đó). */ - -/** id/bin THẬT gửi xuống Rust (`artifact_installer_uninstall`'s `key`) — - * khác `recordKey` (từ `@/platform`, dùng làm khoá React/UI) khi record có - * `marketId`: `recordKey` trả `::` để phân biệt hai market - * cùng id trên MÀN HÌNH, nhưng Rust chỉ biết `manifest.id`/`manifest.bin` - * trần cộng `market_id` truyền riêng — xem `stage_uninstall`. */ -function rawArtifactId(record: InstalledArtifactRecord): string { - return record.kind === 'plugin' ? record.manifest.id : 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 (xem lời giải thích ở đầu file và - * DoD của plan: "một cảnh báo chung là đủ", không cần phát hiện thật một - * kết nối/stream đang mở). */ -type PendingServiceAction = 'update' | 'uninstall'; - export function SettingsExtensionInstaller() { const { t } = useLocale(); - const { updates: autoUpdates } = useExtensionUpdates(); const [url, setUrl] = useState(''); const [preview, setPreview] = useState(null); const [previewTriple, setPreviewTriple] = useState(null); @@ -72,31 +46,8 @@ export function SettingsExtensionInstaller() { const [previewMarketId, setPreviewMarketId] = useState(undefined); const [installing, setInstalling] = useState(false); const [installError, setInstallError] = useState(null); - - const [installed, setInstalled] = useState([]); - // key (id hoặc bin) → thông báo/lỗi của thao tác đang chạy trên đúng dòng đó - // — tách theo key để bấm "Gỡ" ở một dòng không làm dòng khác nhấp nháy - // trạng thái loading. - const [rowBusy, setRowBusy] = useState>({}); - const [rowError, setRowError] = useState>({}); - const [rowUpdateVersion, setRowUpdateVersion] = useState>({}); - // Bước xác nhận riêng cho service — plugin không cần bước này (không có - // tiến trình nào bị dừng khi cài đè/gỡ một plugin JS). - const [pendingServiceAction, setPendingServiceAction] = useState>( - {}, - ); - const [needsRestart, setNeedsRestart] = useState(false); - const refresh = useCallback(async () => { - if (!isTauri) return; - setInstalled(await listInstalledArtifacts()); - }, []); - - useEffect(() => { - void refresh(); - }, [refresh]); - const runPreview = useCallback(async (targetUrl: string, marketId?: string) => { setPreview(null); setPreviewTriple(null); @@ -120,19 +71,6 @@ export function SettingsExtensionInstaller() { } }, []); - // ExtensionUpdateContext đã tự kiểm mọi artifact lúc app khởi động — dùng - // kết quả đó để hiện sẵn nút "Update" ngay khi mở trang này, thay vì bắt - // người dùng bấm "Check for update" từng dòng trước (nút đó vẫn còn, cho - // trường hợp một artifact đổi version SAU lần kiểm lúc khởi động). - useEffect(() => { - if (autoUpdates.length === 0) return; - setRowUpdateVersion((prev) => { - const next = { ...prev }; - for (const u of autoUpdates) next[u.key] = u.remoteVersion; - return next; - }); - }, [autoUpdates]); - if (!isTauri) { return {t('settings.extensions.install.webWarning')}; } @@ -156,7 +94,6 @@ export function SettingsExtensionInstaller() { setPreview(null); setPreviewTriple(null); setNeedsRestart(true); - await refresh(); } catch (e) { setInstallError(String(e instanceof Error ? e.message : e)); } finally { @@ -164,92 +101,6 @@ export function SettingsExtensionInstaller() { } }; - const performUninstall = async (record: InstalledArtifactRecord) => { - const key = recordKey(record); - setRowBusy((prev) => ({ ...prev, [key]: true })); - setRowError((prev) => ({ ...prev, [key]: '' })); - try { - await uninstallArtifact(rawArtifactId(record), record.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 })); - } - }; - - const performUpdate = async (record: InstalledArtifactRecord) => { - const key = recordKey(record); - setRowBusy((prev) => ({ ...prev, [key]: true })); - setRowError((prev) => ({ ...prev, [key]: '' })); - try { - // Cùng URL đã cài từ — `artifact_installer_install` phía Rust thay hẳn - // bản ghi cũ khi key trùng, nên "cập nhật" chỉ là "cài lại từ đúng - // nguồn". - if (record.kind === 'plugin') { - await installPlugin(record.sourceUrl, record.marketId); - } else { - await installService(record.sourceUrl, record.marketId); - } - setRowUpdateVersion((prev) => ({ ...prev, [key]: undefined })); - 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 })); - } - }; - - /** 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`. */ - const handleUninstallClick = (record: InstalledArtifactRecord) => { - if (record.kind === 'plugin') { - void performUninstall(record); - return; - } - setPendingServiceAction((prev) => ({ ...prev, [recordKey(record)]: 'uninstall' })); - }; - - const handleUpdateClick = (record: InstalledArtifactRecord) => { - if (record.kind === 'plugin') { - void performUpdate(record); - return; - } - setPendingServiceAction((prev) => ({ ...prev, [recordKey(record)]: 'update' })); - }; - - const handleConfirmServiceAction = (record: InstalledArtifactRecord, action: PendingServiceAction) => { - if (action === 'update') { - void performUpdate(record); - } else { - void performUninstall(record); - } - }; - - const handleCancelServiceAction = (key: string) => { - setPendingServiceAction((prev) => ({ ...prev, [key]: undefined })); - }; - - const handleCheckUpdate = async (record: InstalledArtifactRecord) => { - const key = recordKey(record); - setRowBusy((prev) => ({ ...prev, [key]: true })); - setRowError((prev) => ({ ...prev, [key]: '' })); - try { - const { available, remote } = - record.kind === 'plugin' ? await checkForUpdate(record) : await checkForServiceUpdate(record); - setRowUpdateVersion((prev) => ({ ...prev, [key]: available && remote ? remote.version : 'up-to-date' })); - } catch (e) { - setRowError((prev) => ({ ...prev, [key]: String(e instanceof Error ? e.message : e) })); - } finally { - setRowBusy((prev) => ({ ...prev, [key]: false })); - } - }; - const handleRestart = async () => { const { relaunch } = await import('@tauri-apps/plugin-process'); await relaunch(); @@ -362,99 +213,6 @@ export function SettingsExtensionInstaller() { {t('settings.extensions.restart.needed')} )} - -
-

{t('settings.extensions.installed.title')}

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

{t('settings.extensions.installed.empty')}

- ) : ( - installed.map((record) => { - const key = recordKey(record); - const busy = rowBusy[key] ?? false; - const updateVersion = rowUpdateVersion[key]; - const pending = pendingServiceAction[key]; - return ( -
-
-
-

- - {record.kind === 'plugin' - ? t('settings.extensions.install.kind.plugin') - : t('settings.extensions.install.kind.service')} - - {record.kind === 'plugin' ? ( - <> - {record.manifest.label}{' '} - {record.manifest.id}@{record.manifest.version} - - ) : ( - {record.manifest.bin}@{record.manifest.version} - )} - {record.marketId && ( - - {record.marketId} - - )} -

-

{record.sourceUrl}

- {rowError[key] &&

{rowError[key]}

} - {updateVersion === 'up-to-date' && ( -

{t('settings.extensions.installed.upToDate')}

- )} - {updateVersion && updateVersion !== 'up-to-date' && ( -

- {t('settings.extensions.installed.updateAvailable', { version: updateVersion })} -

- )} -
-
- {updateVersion && updateVersion !== 'up-to-date' ? ( - - ) : ( - - )} - -
-
- - {pending && ( - - - -
- } - > - {t('settings.extensions.installed.serviceWarning')} - - )} -
- ); - }) - )} - ); } diff --git a/src/components/SettingsExtensions.tsx b/src/components/SettingsExtensions.tsx index 5beac0d4..303ab870 100644 --- a/src/components/SettingsExtensions.tsx +++ b/src/components/SettingsExtensions.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useLocale } from '@/contexts/LocaleContext'; import { SettingsExtensionInstaller } from '@/components/SettingsExtensionInstaller'; +import { SettingsInstalledExtensions } from '@/components/SettingsInstalledExtensions'; import { SettingsMarketplace } from '@/components/SettingsMarketplace'; import { ExtensionInstallDialog } from '@/components/ExtensionInstallDialog'; import { Tabs } from '@/components/ui/tabs'; @@ -8,11 +9,16 @@ import { pendingInstall, type PendingInstallItem } from '@/lib/pendingInstall'; /** * Settings → Extensions: cài THÊM plugin/sidecar KHÔNG có sẵn trong bản - * biên dịch của app, qua Chợ tiện ích hoặc dán URL manifest tay. Tách khỏi + * biên dịch của app, qua Chợ tiện ích hoặc dán URL manifest tay — và quản lý + * (cập nhật/gỡ) những gì đã cài, bất kể cài từ đường nào. Tách khỏi * Settings → Plugins (danh sách tool biên dịch sẵn + nhật ký) — hai câu hỏi * khác nhau: "app này có những plugin nào sẵn, quyền gì" so với "tôi muốn * cài thêm cái gì đó KHÔNG có sẵn". * + * Tab "Đã cài" là tab RIÊNG, không lồng trong "Cài từ URL" hay "Chợ tiện + * ích" — trước đây danh sách gỡ/cập nhật bị chôn dưới tab "Cài từ URL", nên + * một người chỉ dùng Chợ tiện ích để cài sẽ không bao giờ tìm ra chỗ để gỡ. + * * Một lượt cài tới từ BÊN NGOÀI trang này (deep link * `desktop-devtool-app://install`, xử lý ở `deepLink.ts`) hiện `ExtensionInstallDialog` * NGAY TẠI ĐÂY, đè lên bất kỳ tab nào đang mở — không còn ép chuyển sang tab @@ -22,7 +28,7 @@ import { pendingInstall, type PendingInstallItem } from '@/lib/pendingInstall'; export function SettingsExtensions() { const { t } = useLocale(); - const [tab, setTab] = useState<'marketplace' | 'installUrl'>('marketplace'); + const [tab, setTab] = useState<'marketplace' | 'installUrl' | 'installed'>('marketplace'); const [pendingBatch, setPendingBatch] = useState(null); useEffect(() => { @@ -47,12 +53,19 @@ export function SettingsExtensions() { tabs={[ { id: 'marketplace', label: t('settings.extensions.tabs.marketplace') }, { id: 'installUrl', label: t('settings.extensions.tabs.installUrl') }, + { id: 'installed', label: t('settings.extensions.tabs.installed') }, ]} active={tab} - onSelect={(id) => setTab(id as 'marketplace' | 'installUrl')} + onSelect={(id) => setTab(id as 'marketplace' | 'installUrl' | 'installed')} />
- {tab === 'marketplace' ? : } + {tab === 'marketplace' ? ( + + ) : tab === 'installUrl' ? ( + + ) : ( + + )}
diff --git a/src/components/SettingsInstalledExtensions.test.tsx b/src/components/SettingsInstalledExtensions.test.tsx new file mode 100644 index 00000000..4f71f231 --- /dev/null +++ b/src/components/SettingsInstalledExtensions.test.tsx @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, cleanup, fireEvent, waitFor, within, act } from '@testing-library/react'; +import { storageRemove } from '@/lib/persistentStore'; + +/** + * `isTauri` được đọc MỘT LẦN lúc `@/lib/platform` được nạp — cùng cái bẫy đã + * ghi ở `installer.test.ts`/`SettingsExtensionInstaller.test.tsx`. + * `vi.resetModules()` + gán `__TAURI_INTERNALS__` TRƯỚC khi + * `import('@/components/SettingsInstalledExtensions')` là cách duy nhất để + * mọi module transitively import `@/lib/platform` đọc đúng giá trị test này + * cần. + * + * `settle()` NGAY SAU `renderInTauri()`: lần `import('@tauri-apps/api/core')` + * ĐẦU TIÊN (bên trong `invokeCommand`, gọi lúc mount để `listInstalledArtifacts` + * chạy) là một dynamic import thật, còn đang "in-flight" khi `render()` trả + * về — xem giải thích đầy đủ ở `SettingsExtensionInstaller.test.tsx`'s bản gốc + * (trước khi danh sách này tách ra thành component riêng). + */ +async function settle(ms = 20) { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }); +} + +const invokeMock = vi.fn(); +vi.mock('@tauri-apps/api/core', () => ({ invoke: (...args: unknown[]) => invokeMock(...args) })); +vi.mock('@tauri-apps/plugin-process', () => ({ relaunch: vi.fn() })); + +function pluginManifestRaw(overrides: Partial> = {}) { + return { + kind: 'plugin', + id: 'demo', + version: '1.0.0', + sdk: '^1.0.0', + entry: 'https://example.com/demo/bundle.mjs', + integrity: 'a'.repeat(64), + label: 'Demo', + description: 'Plugin ví dụ', + icon: 'regex', + keywords: [], + route: '/demo', + permissions: [], + commands: [], + hosts: [], + ...overrides, + }; +} + +function installedPluginRaw(overrides: Partial> = {}) { + return { + kind: 'plugin' as const, + manifest: pluginManifestRaw(), + source_url: 'https://example.com/plugin.json', + bundle_path: '/tmp/demo/1.0.0/bundle.mjs', + installed_at: 1_700_000_000_000, + ...overrides, + }; +} + +function installedServiceRaw(overrides: Partial> = {}) { + const { manifest: manifestOverrides, ...rest } = overrides; + return { + kind: 'service' as const, + manifest: { + bin: 'devtool-svc-demo', + version: '1.0.0', + protocol: 1, + targets: { 'aarch64-apple-darwin': { url: 'https://example.com/svc/arm64', sha256: 'a'.repeat(64) } }, + ...(manifestOverrides as Record | undefined), + }, + source_url: 'https://example.com/svc.json', + bin_path: '/tmp/services/devtool-svc-demo/1.0.0/devtool-svc-demo', + installed_at: 1_700_000_000_000, + ...rest, + }; +} + +/** + * `resetModules()` + import + render, dùng chung cho MỌI test cần một lần nạp + * module riêng (isTauri đọc một lần lúc `@/lib/platform` được nạp — xem ghi + * chú ở đầu file). + */ +async function renderInstalled(opts?: { tauri?: boolean }) { + vi.resetModules(); + if (opts?.tauri ?? true) { + (window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = {}; + } else { + delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + } + const { LocaleProvider } = await import('@/contexts/LocaleContext'); + const { ExtensionUpdateProvider } = await import('@/contexts/ExtensionUpdateContext'); + const { SettingsInstalledExtensions } = await import('@/components/SettingsInstalledExtensions'); + return render( + + + + + , + ); +} + +async function renderInTauri() { + return renderInstalled({ tauri: true }); +} + +afterEach(() => { + cleanup(); + invokeMock.mockReset(); + delete (window as unknown as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__; + storageRemove('devtool-locale'); + vi.resetModules(); +}); + +describe('SettingsInstalledExtensions — bản web (không phải Tauri)', () => { + it('hiện cảnh báo, không gọi invoke', async () => { + await renderInstalled({ tauri: false }); + + expect(screen.getByText(/only works in the desktop app|chỉ hoạt động trên bản desktop/)).toBeTruthy(); + expect(invokeMock).not.toHaveBeenCalled(); + }); +}); + +describe('SettingsInstalledExtensions — danh sách rỗng (empty state)', () => { + it('nói rõ chưa cài gì thay vì để trống', async () => { + invokeMock.mockResolvedValueOnce([]); // artifact_installer_list lúc mount + await renderInTauri(); + await settle(); + + await waitFor(() => { + expect(screen.getByText(/No extensions installed yet\.|Chưa cài tiện ích nào\./)).toBeTruthy(); + }); + }); +}); + +describe('SettingsInstalledExtensions — danh sách hỗn hợp', () => { + it('phân biệt badge theo kind (Plugin/Service)', async () => { + invokeMock.mockResolvedValueOnce([installedPluginRaw(), installedServiceRaw()]); // list lúc mount + await renderInTauri(); + await settle(); + + await waitFor(() => { + const badges = screen.getAllByText(/^(Plugin|Service)$/); + expect(badges.map((b) => b.textContent).sort()).toEqual(['Plugin', 'Service']); + }); + }); +}); + +describe('SettingsInstalledExtensions — service đòi xác nhận trước khi cập nhật/gỡ', () => { + it('bấm Gỡ trên một dòng service chỉ HIỆN cảnh báo, chưa gọi uninstall — phải bấm Xác nhận mới thật sự gỡ', async () => { + invokeMock.mockResolvedValueOnce([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); + + // Cảnh báo hiện ra, KHÔNG gọi uninstall ngay. + await waitFor(() => + expect( + screen.getByText(/will STOP this sidecar if it is running|sẽ DỪNG sidecar này nếu đang chạy/), + ).toBeTruthy(), + ); + expect(invokeMock).not.toHaveBeenCalledWith('artifact_installer_uninstall', expect.anything()); + + invokeMock.mockResolvedValueOnce(undefined); // uninstall + invokeMock.mockResolvedValueOnce([]); // refresh sau khi gỡ + + fireEvent.click(screen.getByRole('button', { name: /^Confirm$|^Xác nhận$/ })); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'devtool-svc-demo' }); + }); + }); + + it('bấm Huỷ ở cảnh báo thì không gọi uninstall, cảnh báo biến mất', async () => { + invokeMock.mockResolvedValueOnce([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.getByText(/will STOP this sidecar if it is running|sẽ DỪNG sidecar này nếu đang chạy/), + ).toBeTruthy(), + ); + + fireEvent.click(screen.getByRole('button', { name: /^Cancel$|^Huỷ$/ })); + + await waitFor(() => + expect( + screen.queryByText(/will STOP this sidecar if it is running|sẽ DỪNG sidecar này nếu đang chạy/), + ).toBeNull(), + ); + expect(invokeMock).not.toHaveBeenCalledWith('artifact_installer_uninstall', expect.anything()); + }); +}); + +describe('SettingsInstalledExtensions — gỡ một plugin', () => { + it('bấm Gỡ trên một dòng plugin gọi uninstall NGAY, không cần xác nhận thêm', async () => { + invokeMock.mockResolvedValueOnce([installedPluginRaw()]); // list lúc mount + await renderInTauri(); + await settle(); + + await waitFor(() => expect(screen.getByText('Demo')).toBeTruthy()); + const row = screen.getByText('Demo').closest('div')!.parentElement!.parentElement!; + const uninstallButton = within(row).getAllByRole('button').at(-1)!; + + invokeMock.mockResolvedValueOnce(undefined); // uninstall + invokeMock.mockResolvedValueOnce([]); // refresh sau khi gỡ + fireEvent.click(uninstallButton); + + await waitFor(() => { + expect(invokeMock).toHaveBeenCalledWith('artifact_installer_uninstall', { key: 'demo', marketId: undefined }); + }); + await waitFor(() => { + expect(screen.getByText(/External extensions changed|Đã thay đổi tiện ích/)).toBeTruthy(); + }); + }); +}); diff --git a/src/components/SettingsInstalledExtensions.tsx b/src/components/SettingsInstalledExtensions.tsx new file mode 100644 index 00000000..dece1fc8 --- /dev/null +++ b/src/components/SettingsInstalledExtensions.tsx @@ -0,0 +1,274 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Download, RefreshCw, Trash2 } from 'lucide-react'; +import { useLocale } from '@/contexts/LocaleContext'; +import { useExtensionUpdates } from '@/contexts/ExtensionUpdateContext'; +import { isTauri } from '@/lib/platform'; +import { Button } from '@/components/ui/button'; +import { Callout } from '@/components/ui/callout'; +import { Spinner } from '@/components/ui/spinner'; +import { + checkForServiceUpdate, + checkForUpdate, + installPlugin, + installService, + listInstalledArtifacts, + recordKey, + uninstallArtifact, + type InstalledArtifactRecord, +} from '@/platform'; + +/** + * Danh sách tiện ích ĐÃ CÀI (cả plugin lẫn service, bất kể cài qua Chợ tiện + * ích hay dán URL tay) — cập nhật/gỡ tại đây, một chỗ DUY NHẤT, không phụ + * thuộc vào việc người dùng đã cài nó bằng đường nào. Tách khỏi + * `SettingsExtensionInstaller.tsx` (chỉ còn form "dán URL cài mới") thành tab + * riêng của `SettingsExtensions.tsx` — trước đây danh sách này BỊ CHÔN dưới + * tab "Cài từ URL", nên một người chỉ dùng tab "Chợ tiện ích" để cài sẽ không + * bao giờ tìm ra chỗ để gỡ. + * + * Cùng lý do "không áp dụng ngay lập tức" đã ghi ở SettingsExtensionInstaller: + * mọi thao tác ở đây kết thúc bằng cờ "cần khởi động lại", không phải cập + * nhật UI tại chỗ ngay. + */ + +/** id/bin THẬT gửi xuống Rust (`artifact_installer_uninstall`'s `key`) — + * khác `recordKey` (dùng làm khoá React/UI) khi record có `marketId`: xem + * giải thích đầy đủ ở bản gốc trong SettingsExtensionInstaller.tsx. */ +function rawArtifactId(record: InstalledArtifactRecord): string { + return record.kind === 'plugin' ? record.manifest.id : 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'; + +export function SettingsInstalledExtensions() { + const { t } = useLocale(); + const { updates: autoUpdates } = useExtensionUpdates(); + + const [installed, setInstalled] = useState([]); + const [rowBusy, setRowBusy] = useState>({}); + const [rowError, setRowError] = useState>({}); + const [rowUpdateVersion, setRowUpdateVersion] = useState>({}); + const [pendingServiceAction, setPendingServiceAction] = useState>( + {}, + ); + const [needsRestart, setNeedsRestart] = useState(false); + + const refresh = useCallback(async () => { + if (!isTauri) return; + setInstalled(await listInstalledArtifacts()); + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + useEffect(() => { + if (autoUpdates.length === 0) return; + setRowUpdateVersion((prev) => { + const next = { ...prev }; + for (const u of autoUpdates) next[u.key] = u.remoteVersion; + return next; + }); + }, [autoUpdates]); + + if (!isTauri) { + return {t('settings.extensions.install.webWarning')}; + } + + const performUninstall = async (record: InstalledArtifactRecord) => { + const key = recordKey(record); + setRowBusy((prev) => ({ ...prev, [key]: true })); + setRowError((prev) => ({ ...prev, [key]: '' })); + try { + await uninstallArtifact(rawArtifactId(record), record.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 })); + } + }; + + const performUpdate = async (record: InstalledArtifactRecord) => { + const key = recordKey(record); + setRowBusy((prev) => ({ ...prev, [key]: true })); + setRowError((prev) => ({ ...prev, [key]: '' })); + try { + if (record.kind === 'plugin') { + await installPlugin(record.sourceUrl, record.marketId); + } else { + await installService(record.sourceUrl, record.marketId); + } + setRowUpdateVersion((prev) => ({ ...prev, [key]: undefined })); + 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 })); + } + }; + + /** 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`. */ + const handleUninstallClick = (record: InstalledArtifactRecord) => { + if (record.kind === 'plugin') { + void performUninstall(record); + return; + } + setPendingServiceAction((prev) => ({ ...prev, [recordKey(record)]: 'uninstall' })); + }; + + const handleUpdateClick = (record: InstalledArtifactRecord) => { + if (record.kind === 'plugin') { + void performUpdate(record); + return; + } + setPendingServiceAction((prev) => ({ ...prev, [recordKey(record)]: 'update' })); + }; + + const handleConfirmServiceAction = (record: InstalledArtifactRecord, action: PendingServiceAction) => { + if (action === 'update') { + void performUpdate(record); + } else { + void performUninstall(record); + } + }; + + const handleCancelServiceAction = (key: string) => { + setPendingServiceAction((prev) => ({ ...prev, [key]: undefined })); + }; + + const handleCheckUpdate = async (record: InstalledArtifactRecord) => { + const key = recordKey(record); + setRowBusy((prev) => ({ ...prev, [key]: true })); + setRowError((prev) => ({ ...prev, [key]: '' })); + try { + const { available, remote } = + record.kind === 'plugin' ? await checkForUpdate(record) : await checkForServiceUpdate(record); + setRowUpdateVersion((prev) => ({ ...prev, [key]: available && remote ? remote.version : 'up-to-date' })); + } catch (e) { + setRowError((prev) => ({ ...prev, [key]: String(e instanceof Error ? e.message : e) })); + } finally { + setRowBusy((prev) => ({ ...prev, [key]: false })); + } + }; + + const handleRestart = async () => { + const { relaunch } = await import('@tauri-apps/plugin-process'); + await relaunch(); + }; + + return ( +
+ {needsRestart && ( + void handleRestart()}> + {t('settings.extensions.restart.now')} + + } + > + {t('settings.extensions.restart.needed')} + + )} + +
+ {installed.length === 0 ? ( +

{t('settings.extensions.installed.empty')}

+ ) : ( + installed.map((record) => { + const key = recordKey(record); + const busy = rowBusy[key] ?? false; + const updateVersion = rowUpdateVersion[key]; + const pending = pendingServiceAction[key]; + return ( +
+
+
+

+ + {record.kind === 'plugin' + ? t('settings.extensions.install.kind.plugin') + : t('settings.extensions.install.kind.service')} + + {record.kind === 'plugin' ? ( + <> + {record.manifest.label}{' '} + {record.manifest.id}@{record.manifest.version} + + ) : ( + {record.manifest.bin}@{record.manifest.version} + )} + {record.marketId && ( + + {record.marketId} + + )} +

+

{record.sourceUrl}

+ {rowError[key] &&

{rowError[key]}

} + {updateVersion === 'up-to-date' && ( +

{t('settings.extensions.installed.upToDate')}

+ )} + {updateVersion && updateVersion !== 'up-to-date' && ( +

+ {t('settings.extensions.installed.updateAvailable', { version: updateVersion })} +

+ )} +
+
+ {updateVersion && updateVersion !== 'up-to-date' ? ( + + ) : ( + + )} + +
+
+ + {pending && ( + + + +
+ } + > + {t('settings.extensions.installed.serviceWarning')} + + )} +
+ ); + }) + )} +
+ + ); +} diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts index 73d94a98..6ce07577 100644 --- a/src/lib/i18n.ts +++ b/src/lib/i18n.ts @@ -244,6 +244,7 @@ export const DICTIONARY = { }, 'settings.extensions.tabs.marketplace': { vi: 'Chợ tiện ích', en: 'Marketplace' }, 'settings.extensions.tabs.installUrl': { vi: 'Cài từ URL', en: 'Install by URL' }, + 'settings.extensions.tabs.installed': { vi: 'Đã cài', en: 'Installed' }, 'settings.extensions.install.title': { vi: 'Cài tiện ích từ bên ngoài', en: 'Install external extension' }, 'settings.extensions.install.description': {