merge: integrate project settings plugin workspace

# Conflicts:
#	src/components/layout/MainLayout.tsx
#	src/pages/ProjectConfiguration/index.tsx
This commit is contained in:
inman
2026-09-07 10:16:19 +08:00
18 changed files with 362 additions and 76 deletions

View File

@@ -86,10 +86,13 @@ test.describe('Unified plugin workspace', () => {
))).toBe(true);
await page.getByTestId('ai-module-option-programming').click();
await page.keyboard.press('Escape');
await page.getByTestId('sidebar-nav-plugins').click();
await expect(page.getByTestId('sidebar-nav-plugins')).toHaveCount(0);
await page.getByTestId('resource-card-plugins').click();
await expect(page.getByTestId('project-configuration-empty-state')).toBeVisible();
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(page.getByTestId('plugins-page')).toBeVisible();
await expect(page).toHaveURL(/\/plugins\?scope=all/);
await expect(page).toHaveURL(/\/project-config\/plugins\?scope=all/);
await expect(page.getByTestId('plugins-page').getByText('尚未获取')).toBeVisible();
await page.getByRole('button', { name: '查看灵感笔记详情' }).click();
await expect(page.getByRole('dialog', { name: '灵感笔记' })).toContainText('详细介绍');
@@ -148,8 +151,11 @@ test.describe('Unified plugin workspace', () => {
});
});
await page.getByTestId('ai-module-option-programming').click();
await page.getByTestId('sidebar-nav-plugins').click();
await expect(page.getByTestId('sidebar-nav-plugins')).toHaveCount(0);
await page.getByTestId('resource-card-plugins').click();
await expect(page.getByTestId('project-configuration-empty-state')).toBeVisible();
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(page.getByTestId('plugins-page')).toBeVisible();
await expect(page.getByRole('alert')).toContainText('官方目录刷新失败');
await expect(page.getByRole('heading', { name: 'Local Search' })).toBeVisible();
@@ -274,7 +280,10 @@ test.describe('Unified plugin workspace', () => {
))).toBe(true);
await page.getByTestId('ai-module-option-programming').click();
await page.keyboard.press('Escape');
await page.getByTestId('sidebar-nav-plugins').click();
await expect(page.getByTestId('sidebar-nav-plugins')).toHaveCount(0);
await page.getByTestId('resource-card-plugins').click();
await expect(page.getByTestId('project-configuration-empty-state')).toBeVisible();
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(page.getByTestId('plugins-page')).toBeVisible();
await expect(page.getByTestId('sidebar-nav-plugin-marketplace')).toHaveCount(0);
await expect(page.getByTestId('sidebar-nav-my-plugins')).toHaveCount(0);

View File

@@ -89,9 +89,13 @@ test.describe('Unified project plugin workspace', () => {
});
});
await page.getByTestId('sidebar-nav-plugins').click();
await expect(page.getByTestId('sidebar-nav-plugins')).toHaveCount(0);
await page.getByTestId('resource-card-plugins').click();
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
await expect(page.getByTestId('project-plugins-sheet')).toBeVisible();
await expect(page.getByTestId('plugins-page')).toBeVisible();
await expect(page).toHaveURL(/\/plugins\?scope=project/);
await expect(page).toHaveURL(/\/project-config\/plugins\?scope=project/);
await expect(page.getByText('项目配置')).toBeVisible();
await page.getByRole('button', { name: '查看开发数据服务详情' }).click();
await expect(page.getByText('按平台包含,不按单次插件调用扣点。')).toBeVisible();
const localProjectId = await app.evaluate(() => (

View File

@@ -81,7 +81,7 @@ function renderLogin(initialEntries: React.ComponentProps<typeof MemoryRouter>['
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
<Route path="/plugins" element={<LocationProbe />} />
<Route path="/project-config/plugins" element={<LocationProbe />} />
</Routes>
</MemoryRouter>,
);
@@ -91,7 +91,7 @@ function renderPluginLoginJourney(canonicalPath: string) {
return render(
<MemoryRouter initialEntries={[canonicalPath]}>
<Routes>
<Route path="/plugins" element={<PluginSignInAction />} />
<Route path="/project-config/plugins" element={<PluginSignInAction />} />
<Route path="/login" element={<Login />} />
<Route path="/module-select" element={<div>Module Selection</div>} />
</Routes>
@@ -245,7 +245,7 @@ describe('Login page', () => {
});
it('returns a signed-out plugin action to the exact canonical workspace query after login', async () => {
const canonicalPath = '/plugins?scope=all&source=official&state=available&q=notes&plugin=official%3Amakelore.notes';
const canonicalPath = '/project-config/plugins?scope=all&source=official&state=available&q=notes&plugin=official%3Amakelore.notes';
loginWithPassword.mockImplementation(async () => {
useAuthStore.setState({ accessToken: 'password-access-token' });
});

View File

@@ -86,8 +86,8 @@ describe('MainLayout module isolation', () => {
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
});
it('keeps the canonical plugin workspace outside the local project initialization gate', () => {
renderLayout('/plugins');
it('keeps the project-settings plugin workspace outside the local project initialization gate', () => {
renderLayout('/project-config/plugins');
expect(screen.getByTestId('route-content')).toBeVisible();
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();

View File

@@ -36,7 +36,7 @@ const MARKETPLACE_ARTIFACT_TEXT = [
'plugin-marketplace\\/install\\/', 'plugin-marketplace\\/update\\/',
'effectiveSkillIds', 'pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog', '/api/coding/plugin-marketplace/library',
'免费获取', '获取、项目启用与伙伴分配彼此独立', '/plugins',
'免费获取', '获取、项目启用与伙伴分配彼此独立', '/project-config/plugins',
].join('\n');
async function createAsarFixture(source: string, archive: string) {
@@ -389,7 +389,7 @@ describe('final Pi product artifact verification', () => {
'makelore-device-package.v1 /api/coding/device-packages device-parent-workers',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 获取、项目启用与伙伴分配彼此独立 /plugins',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 获取、项目启用与伙伴分配彼此独立 /project-config/plugins',
].join('\n'));
const appAsar = path.join(root, 'app.asar');
await createAsarFixture(source, appAsar);
@@ -407,7 +407,7 @@ describe('final Pi product artifact verification', () => {
'makelore.game-resource /api/plugins/v1/hosted/game-resource/generations',
'/api/coding/plugin-marketplace plugin-marketplace\\/install\\/ plugin-marketplace\\/update\\/',
'effectiveSkillIds pluginReleaseIds',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件 /plugins',
'/api/coding/plugin-marketplace/catalog /api/coding/plugin-marketplace/library 免费获取 我的插件 /project-config/plugins',
].join('\n'));
const staleAsar = path.join(root, 'stale.asar');
await createAsarFixture(staleSource, staleAsar);

View File

@@ -1,7 +1,11 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { LegacyPluginRedirect } from '@/pages/Plugins/legacy-plugin-redirect';
import {
LegacyPluginRedirect,
PluginWorkspaceRedirect,
PROJECT_SETTINGS_PLUGINS_PATH,
} from '@/pages/Plugins/legacy-plugin-redirect';
import { getGuardedAiModuleForPath, isProgrammingProviderRoute } from '@/lib/ai-modules';
function LocationProbe() {
@@ -17,15 +21,15 @@ function LocationProbe() {
describe('plugin navigation cutover', () => {
it.each([
['/plugin-marketplace', '/plugins?scope=all&source=official'],
['/my-plugins', '/plugins?scope=all&state=mine'],
['/project-plugins', '/plugins?scope=project'],
['/plugin-marketplace', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&source=official`],
['/my-plugins', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&state=mine`],
['/project-plugins', `${PROJECT_SETTINGS_PLUGINS_PATH}?scope=project`],
] as const)('replace-redirects %s to %s', async (legacy, canonical) => {
render(
<MemoryRouter initialEntries={['/previous', legacy]} initialIndex={1}>
<Routes>
<Route path={legacy} element={<LegacyPluginRedirect from={legacy} />} />
<Route path="/plugins" element={<LocationProbe />} />
<Route path={PROJECT_SETTINGS_PLUGINS_PATH} element={<LocationProbe />} />
<Route path="/previous" element={<output data-testid="previous">previous</output>} />
</Routes>
</MemoryRouter>,
@@ -36,8 +40,26 @@ describe('plugin navigation cutover', () => {
expect(await screen.findByTestId('previous')).toBeVisible();
});
it('classifies the canonical page as Code without making it provider-gated', () => {
expect(getGuardedAiModuleForPath('/plugins')).toBe('programming');
expect(isProgrammingProviderRoute('/plugins')).toBe(false);
it('moves the former canonical URL into project settings without losing its query', async () => {
render(
<MemoryRouter initialEntries={['/previous', '/plugins?scope=all&state=mine']} initialIndex={1}>
<Routes>
<Route path="/plugins" element={<PluginWorkspaceRedirect />} />
<Route path={PROJECT_SETTINGS_PLUGINS_PATH} element={<LocationProbe />} />
<Route path="/previous" element={<output data-testid="previous">previous</output>} />
</Routes>
</MemoryRouter>,
);
expect(await screen.findByTestId('location')).toHaveTextContent(
`${PROJECT_SETTINGS_PLUGINS_PATH}?scope=all&state=mine`,
);
fireEvent.click(screen.getByRole('button', { name: '返回' }));
expect(await screen.findByTestId('previous')).toBeVisible();
});
it('classifies the project-settings plugin page as Code without making it provider-gated', () => {
expect(getGuardedAiModuleForPath(PROJECT_SETTINGS_PLUGINS_PATH)).toBe('programming');
expect(isProgrammingProviderRoute(PROJECT_SETTINGS_PLUGINS_PATH)).toBe(false);
});
});

View File

@@ -23,7 +23,7 @@ function dependencies() {
project: {
setEnabled: vi.fn().mockResolvedValue(undefined),
},
loginReturnPath: '/plugins?scope=all&source=official&state=available',
loginReturnPath: '/project-config/plugins?scope=all&source=official&state=available',
navigate: vi.fn(),
openSettings: vi.fn(),
} satisfies PluginWorkspaceDispatchDependencies;
@@ -65,7 +65,7 @@ describe('plugin workspace controller', () => {
expect(deps.device.setEnabled.mock.calls).toEqual([['j', true], ['k', false]]);
expect(deps.device.uninstall).toHaveBeenCalledExactlyOnceWith('l');
expect(deps.navigate.mock.calls).toEqual([
['/login', { state: { from: '/plugins?scope=all&source=official&state=available' } }],
['/login', { state: { from: '/project-config/plugins?scope=all&source=official&state=available' } }],
['/project-config'],
]);
expect(deps.openSettings).toHaveBeenCalledExactlyOnceWith();

View File

@@ -208,10 +208,10 @@ function PluginsRouteHarness() {
<>
<Plugins />
<div data-testid="plugins-location">{location.pathname}{location.search}</div>
<button type="button" onClick={() => navigate('/plugins')}></button>
<button type="button" onClick={() => navigate('/project-config/plugins')}></button>
<button
type="button"
onClick={() => navigate('/plugins?scope=illegal&source=remote&state=broken')}
onClick={() => navigate('/project-config/plugins?scope=illegal&source=remote&state=broken')}
></button>
</>
);
@@ -249,16 +249,24 @@ function preparePluginsRouteState() {
}
describe('PluginsView', () => {
it('renders as project-configuration drawer content without a duplicate standalone header', () => {
render(<MemoryRouter><PluginsView {...props()} embedded /></MemoryRouter>);
expect(screen.getByTestId('plugins-page')).toBeVisible();
expect(screen.getByRole('button', { name: '刷新全部来源' })).toBeVisible();
expect(screen.queryByRole('heading', { name: '插件', level: 1 })).not.toBeInTheDocument();
});
it('keeps the production URL, scope control, and fallback notice aligned across in-place changes', async () => {
preparePluginsRouteState();
render(
<MemoryRouter initialEntries={['/plugins?scope=project']}>
<MemoryRouter initialEntries={['/project-config/plugins?scope=project']}>
<PluginsRouteHarness />
</MemoryRouter>,
);
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=all&source=all&state=all',
'/project-config/plugins?scope=all&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('all');
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
@@ -273,19 +281,19 @@ describe('PluginsView', () => {
act(() => codingWorkspaceStore.setState({ activeProjectId: activeProject.id, activeProject }));
fireEvent.click(screen.getByRole('button', { name: '打开裸插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=project&source=all&state=all',
'/project-config/plugins?scope=project&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('project');
fireEvent.click(screen.getByRole('button', { name: '打开非法插件地址' }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=project&source=all&state=all',
'/project-config/plugins?scope=project&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('project');
act(() => codingWorkspaceStore.setState({ activeProjectId: null, activeProject: null }));
await waitFor(() => expect(screen.getByTestId('plugins-location')).toHaveTextContent(
'/plugins?scope=all&source=all&state=all',
'/project-config/plugins?scope=all&source=all&state=all',
));
expect(screen.getByLabelText('范围')).toHaveValue('all');
expect(screen.getByText('当前没有打开的项目,已显示全部插件。')).toBeVisible();
@@ -339,7 +347,7 @@ describe('PluginsView', () => {
vi.spyOn(devicePackageStore.getState(), 'load').mockResolvedValue(undefined);
vi.spyOn(codingPluginsStore.getState(), 'load').mockResolvedValue(undefined);
render(<MemoryRouter initialEntries={['/plugins?scope=project&source=all&state=all']}><Plugins /></MemoryRouter>);
render(<MemoryRouter initialEntries={['/project-config/plugins?scope=project&source=all&state=all']}><Plugins /></MemoryRouter>);
for (const name of ['开发数据服务', 'Pi Web Search', 'makelore.retained']) {
const heading = await screen.findByRole('heading', { name });
@@ -388,7 +396,7 @@ describe('PluginsView', () => {
render(
<MemoryRouter initialEntries={[
'/plugins?scope=all&source=all&state=all&plugin=official%3Amakelore.notes',
'/project-config/plugins?scope=all&source=all&state=all&plugin=official%3Amakelore.notes',
]}>
<Plugins />
</MemoryRouter>,