feat: move plugin workspace into project settings
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
# Project Configuration owns Plugin navigation
|
||||
|
||||
## Status
|
||||
|
||||
Proposed for integration; implemented and verified in task
|
||||
`20260906-move-plugins-project-settings-7c91a4e2`.
|
||||
|
||||
## Date
|
||||
|
||||
2026-09-06
|
||||
|
||||
## Context
|
||||
|
||||
The integrated product exposed one reviewed unified Plugin workspace at
|
||||
`/plugins` through a standalone Code sidebar entry. Project Configuration no
|
||||
longer contained its predecessor Plugin Services sheet. The user first directed
|
||||
that Plugins belong in Project Settings, then clarified that the workspace must
|
||||
live on the same project page used to configure Agents and Skills rather than
|
||||
replace that page after navigation.
|
||||
|
||||
The navigation change must not collapse the existing independent lifecycles for
|
||||
Marketplace acquisition, device installation, project enablement, Agent Skill
|
||||
assignment, runtime authorization, and billing. It also must not restore the
|
||||
retired duplicate sheet or move Main-owned Plugin authority into Renderer code.
|
||||
|
||||
## Decision
|
||||
|
||||
- The Agent/Skill Project Configuration page is the visible owner of the single
|
||||
unified Plugin workspace.
|
||||
- Its `插件` resource card opens the workspace in a wide same-page drawer. The
|
||||
Project Configuration page remains mounted behind it.
|
||||
- `/project-config/plugins` is the canonical deep link for the drawer-open state;
|
||||
it renders Project Configuration rather than a standalone Plugin page.
|
||||
- `/plugins` preserves query and navigation state while redirecting to the
|
||||
canonical URL. `/plugin-marketplace`, `/my-plugins`, and `/project-plugins`
|
||||
remain deterministic filtered compatibility redirects.
|
||||
- The Code sidebar has no standalone Plugin entry.
|
||||
- The canonical workspace remains accessible before Code Provider initialization
|
||||
and when no project is active, so Marketplace and account/device views do not
|
||||
incorrectly depend on local project selection.
|
||||
- The existing unified controller, data sources, mutation semantics,
|
||||
authentication, and lifecycle separation remain unchanged.
|
||||
|
||||
## Rationale
|
||||
|
||||
Plugin installation and activation configure resources that projects may use, so
|
||||
Project Configuration is the clearer navigation owner. Reusing the unified
|
||||
workspace avoids competing product surfaces and preserves one authoritative view
|
||||
across Marketplace, Account Library, device packages, and project status.
|
||||
Compatibility redirects protect existing deep links and authenticated return
|
||||
flows while establishing an unambiguous canonical location.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Project Configuration gains a Plugin card beside models, Skills, and knowledge,
|
||||
with access retained in the no-active-project state.
|
||||
- Closing the Plugin drawer returns to the already-mounted Project Configuration
|
||||
page; Agent assignment actions close the drawer to expose that same page.
|
||||
- Existing bookmarks continue to work, but tests and new product links should use
|
||||
`/project-config/plugins`.
|
||||
- Canonical project memory that names the Code sidebar or `/plugins` as the sole
|
||||
current entry must be superseded during Integration Mode.
|
||||
- No backend contract, package trust boundary, runtime permission, billing rule,
|
||||
or native Web Search behavior changes.
|
||||
|
||||
## Supersedes
|
||||
|
||||
- The navigation-only claim that the Code sidebar owns the single `插件` entry and
|
||||
`/plugins` is the canonical product route.
|
||||
- It does not supersede the retirement of the duplicate Project Configuration
|
||||
Plugin Services sheet; that implementation remains retired.
|
||||
|
||||
## Evidence
|
||||
|
||||
- User direction: “插件应该在项目设置中”, clarified as “是放在配置agent、skill那个项目页”.
|
||||
- Focused unit verification: 62/62 passed.
|
||||
- Electron Plugin journeys: 4/4 passed.
|
||||
- Full unit, typecheck, lint, and Vite production build passed.
|
||||
|
||||
## Related
|
||||
|
||||
- `.project-docs/10-decisions/adr-008-interactive-ai-app-scaffold.md`
|
||||
- `.project-docs/30-worklog/tasks/20260906-move-plugins-project-settings-7c91a4e2.md`
|
||||
- `src/pages/ProjectConfiguration/index.tsx`
|
||||
- `src/pages/Plugins/index.tsx`
|
||||
- `src/pages/Plugins/legacy-plugin-redirect.tsx`
|
||||
@@ -0,0 +1,100 @@
|
||||
# Task: Move plugins into project settings
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260906-move-plugins-project-settings-7c91a4e2
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260906-move-plugins-project-settings-7c91a4e2-move-plugins-project-settings
|
||||
- Worktree: /Users/inmanx/Documents/makelore-task-move-plugins-project-settings-7c91a4e2
|
||||
- Base commit: f8eee430f4f4167dc2ecc682ece96c652cdde14b
|
||||
- Owner: codex
|
||||
- Status: Ready for integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Move the single user-visible Plugin workspace from the Code sidebar directly
|
||||
into the same Project Configuration page used to configure Agents, Skills,
|
||||
models, and knowledge.
|
||||
- Make `/project-config/plugins` the canonical Plugin URL while retaining
|
||||
deterministic compatibility redirects from `/plugins`, `/plugin-marketplace`,
|
||||
`/my-plugins`, and `/project-plugins`.
|
||||
- Update focused unit and Electron E2E coverage, the packaged-product marker, and
|
||||
README current-state wording for the navigation change.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Preserve the existing unified Plugin workspace and its projections of
|
||||
Marketplace, Account Library, official-device state, Device Packages, current
|
||||
project state, and retained IDs; do not restore the retired duplicate Plugin
|
||||
Services sheet.
|
||||
- Keep acquisition, device installation, project enablement, Agent Skill
|
||||
assignment, runtime authorization, and billing as separate lifecycles.
|
||||
- Preserve Main-owned Host API, authentication, package verification, and runtime
|
||||
authority. Renderer navigation must not introduce direct IPC or local-runtime
|
||||
HTTP access.
|
||||
- Keep Project Configuration and its nested Plugin route available before Code
|
||||
Provider initialization, including the no-active-project state.
|
||||
- Render the nested route as a same-page Project Configuration drawer, not as a
|
||||
replacement standalone Plugin page.
|
||||
- Preserve legacy deep-link query/state and authenticated return paths.
|
||||
- Keep the single Makelore light visual system and Chinese product copy.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Added a `插件` ResourceCard alongside models, Skills, and knowledge in Project
|
||||
Configuration, retained access in the no-active-project state, and removed the
|
||||
standalone Code sidebar Plugin entry.
|
||||
- Changed `/project-config/plugins` to render the Project Configuration page with
|
||||
its wide Plugin drawer open. The Agent/Skill project page remains mounted behind
|
||||
the drawer; closing it returns to the same page. Query/state-preserving
|
||||
`/plugins` compatibility and existing filtered legacy redirects remain intact.
|
||||
- Reused the unified Plugin controller and view inside the drawer without
|
||||
restoring the retired duplicate Plugin Services implementation.
|
||||
- Kept the unified Plugin controller, data sources, mutations, authentication,
|
||||
and lifecycle semantics unchanged.
|
||||
- Updated initialization gates, login return path, README, packaged Marketplace
|
||||
artifact assertion, focused unit tests, and Electron E2E journeys.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused navigation, layout, embedded Plugin, controller, login, and artifact
|
||||
verification — 6 files, 62 tests passed; the embedded-view regression failed
|
||||
before implementation and passed afterward.
|
||||
- `corepack pnpm run typecheck` — passed.
|
||||
- Scoped ESLint across all changed source and test files — passed with no output.
|
||||
- `corepack pnpm run build:vite` — Renderer, Main, Preload, and utility builds passed;
|
||||
only existing Browserslist, mixed-import, and chunk-size warnings remained.
|
||||
- `node ./node_modules/@playwright/test/cli.js test tests/e2e/project-plugins.spec.ts tests/e2e/plugin-marketplace.spec.ts` — 4/4 passed, including assertions that the Project Configuration page remains mounted behind the Plugin drawer.
|
||||
- `corepack pnpm test` — 221 regular files (1861 passed, 3 skipped) and the pressure suite (1 passed).
|
||||
- `corepack pnpm run lint:check` — passed with 0 errors and 5 pre-existing warnings.
|
||||
- `git diff --check` — passed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Integration should reconcile the navigation wording in canonical project memory
|
||||
and review overlap with planning task `20260906-remove-project-gate-7c31` before
|
||||
merging. No product-semantic conflict was found in the current peer-task audit.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: `.project-docs/30-worklog/current-state.md`,
|
||||
`.project-docs/20-architecture/system-overview.md`, and
|
||||
`.project-docs/40-domain/business-rules.md`.
|
||||
- Proposal: record the Agent/Skill Project Configuration page as the visible
|
||||
owner of the single unified Plugin workspace. Its `插件` card opens a same-page
|
||||
wide drawer; `/project-config/plugins` is the canonical deep link for that
|
||||
state, and old public URLs remain deterministic compatibility redirects. This
|
||||
rehomes the reviewed unified workspace and does not restore the superseded
|
||||
duplicate Plugin Services implementation.
|
||||
- Evidence: explicit user direction in this task, focused unit coverage, 4/4
|
||||
Electron E2E, full unit/typecheck/lint/build verification, and the task-owned
|
||||
proposal `20260906-move-plugins-project-settings-7c91a4e2__project-settings-plugin-navigation.md`.
|
||||
- Future impact: new Plugin navigation must originate in Project Configuration,
|
||||
while Plugin lifecycle boundaries, Web Search exclusion, and Main-owned
|
||||
authority remain unchanged.
|
||||
- Semantic conflict: this supersedes canonical statements that the Code sidebar
|
||||
owns the only Plugin entry and `/plugins` is canonical; it does not conflict
|
||||
with ADR-008 or the Plugin lifecycle boundaries.
|
||||
- Human confirmation: already supplied and clarified by the user: Plugins belong
|
||||
on the same project page used to configure Agents and Skills, not on a separate
|
||||
page. No additional product-direction approval is required during integration.
|
||||
@@ -28,7 +28,7 @@ Makelore 是一个面向软件、视觉创作与智能机器人的 AI 桌面工
|
||||
- AI 绘画生成:服务端从已确认规格编译专业图片或视频指令并返回不可变 Quote;客户端只展示媒介、画幅、数量、格式、时长、警告和设计点,不展示或改写供应商 Prompt、模型、价格原子或存储地址。确认时只提交 Quote 身份。图片可使用当前 Workspace 的上传素材或生成作品,视频可绑定已审核首帧;任务与资产始终属于 Workspace。
|
||||
- AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。
|
||||
- AI 绘画项目栏只展示 Workspace,不再在项目下创建独立设计会话。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。
|
||||
- 插件工作台:Code 侧栏只有一个“插件”入口,`/plugins` 在同一列表中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态;旧 Marketplace、My Plugins、Project Plugins URL 只做确定性筛选重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority,不会自动推进下一步。任一来源失败不会清空其他来源;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。
|
||||
- 项目插件设置:Code 侧栏不再单列“插件”;配置 Agent、Skill、模型与知识的项目配置页内提供“插件”卡片,并在同页宽抽屉中投影官方目录、账号 Library、本机 Device Packages 与当前项目状态。深链使用 `/project-config/plugins` 打开该项目页抽屉;旧 `/plugins`、Marketplace、My Plugins、Project Plugins URL 只做保留查询条件的确定性重定向。来源身份保持 `official:`、`local:`、`retained:` 分离;“免费获取”、官方设备交付、项目启用和伙伴分配仍分别写入既有 authority,不会自动推进下一步。任一来源失败不会清空其他来源;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包并明确其本机全局生效范围。
|
||||
- 官方插件运行架构:Renderer 只调用 Main-owned Marketplace facade;Main 负责账号、请求 deadline、签名/摘要校验、不可变 Release、current selection 与原子回滚。Pi parent worker 使用冻结的 effective snapshot,将每个有效 Skill 与已验证 Package Store root 成对传给 resource loader、Extension Host 和 CLI;普通 `skill_only` 下载包不依赖运行时 Policy,也不得携带可执行脚本。Project Scaffold 的 `.mjs` 是代码所有的 bundled 例外,只能从签名客户端固定资源根通过应用自带 Node 执行,不能扩展为 Marketplace 下载 artifact 的脚本权限。正式激活仍等待官方 Ed25519 公钥(production key activation HOLD);生产私钥只能来自部署 secret,测试使用注入的临时密钥。
|
||||
- 原生 Web Search 是冻结 selected model/provider/credential 的 parent-only 模型工具;只有 Works `model_capabilities` 明确声明支持时才出现,使用当前模型的正常计费链,不依赖 Marketplace 获取、Release、Admission、Hosted Provider 或 `agent_browser` 回退。
|
||||
- 本机 Device Packages 只由 Conversation 工具发起安装,支持 npm、Git、绝对本地 Plugin 目录和 loose `SKILL.md`。Main 在预览和独立确认后提交不可变本机 generation,禁用生命周期脚本;确认界面明确提示 Pi extension 以桌面当前用户权限执行。新建/空闲 parent 会同时加载所有显式安装且当前启用的 Skill,以及必需 Makelore 扩展之外的全部 Pi extension,active parent 在 turn settled 后切换,child 不继承这些资源,也不会扫描 ambient 项目或用户目录;Renderer 不提供安装源选择器。
|
||||
|
||||
@@ -69,7 +69,7 @@ const MARKETPLACE_ARTIFACT_MARKERS = Object.freeze({
|
||||
'/api/coding/plugin-marketplace/library',
|
||||
'免费获取',
|
||||
'获取、项目启用与伙伴分配彼此独立',
|
||||
'/plugins',
|
||||
'/project-config/plugins',
|
||||
]),
|
||||
});
|
||||
const FORBIDDEN_PROVIDER_AUTHORITY_MARKERS = Object.freeze([
|
||||
|
||||
10
src/App.tsx
10
src/App.tsx
@@ -26,14 +26,17 @@ import { subscribeHostEvent } from '@/lib/host-events';
|
||||
import { reportDesktopActivity, type DesktopActivityModule } from '@/lib/host-api';
|
||||
import { installRendererPerformanceDiagnostics } from '@/lib/performance-diagnostics';
|
||||
import { resolveSupportedLanguage } from '../shared/language';
|
||||
import { LegacyPluginRedirect } from './pages/Plugins/legacy-plugin-redirect';
|
||||
import {
|
||||
LegacyPluginRedirect,
|
||||
PluginWorkspaceRedirect,
|
||||
PROJECT_SETTINGS_PLUGINS_PATH,
|
||||
} from './pages/Plugins/legacy-plugin-redirect';
|
||||
|
||||
const Models = lazy(() => import('./pages/Models').then(({ Models: component }) => ({ default: component })));
|
||||
const Chat = lazy(() => import('./pages/Chat').then(({ Chat: component }) => ({ default: component })));
|
||||
const Home = lazy(() => import('./pages/Home').then(({ Home: component }) => ({ default: component })));
|
||||
const PreviewScene = lazy(() => import('./pages/Makelore').then(({ PreviewScene: component }) => ({ default: component })));
|
||||
const ProjectConfiguration = lazy(() => import('./pages/ProjectConfiguration').then(({ ProjectConfiguration: component }) => ({ default: component })));
|
||||
const Plugins = lazy(() => import('./pages/Plugins').then(({ Plugins: component }) => ({ default: component })));
|
||||
const Workbench = lazy(() => import('./pages/Workbench').then(({ Workbench: component }) => ({ default: component })));
|
||||
const ImageCanvas = lazy(() => import('./pages/ImageCanvas').then(({ ImageCanvas: component }) => ({ default: component })));
|
||||
const ImagePromptMuseum = lazy(() => import('./pages/ImagePromptMuseum').then(({ ImagePromptMuseum: component }) => ({ default: component })));
|
||||
@@ -442,7 +445,8 @@ function App() {
|
||||
)}
|
||||
>
|
||||
<Route path="/project-config" element={<ProjectConfiguration />} />
|
||||
<Route path="/plugins" element={<Plugins />} />
|
||||
<Route path={PROJECT_SETTINGS_PLUGINS_PATH} element={<ProjectConfiguration />} />
|
||||
<Route path="/plugins" element={<PluginWorkspaceRedirect />} />
|
||||
<Route path="/plugin-marketplace" element={<LegacyPluginRedirect from="/plugin-marketplace" />} />
|
||||
<Route path="/my-plugins" element={<LegacyPluginRedirect from="/my-plugins" />} />
|
||||
<Route path="/project-plugins" element={<LegacyPluginRedirect from="/project-plugins" />} />
|
||||
|
||||
@@ -38,6 +38,7 @@ export function MainLayout() {
|
||||
const isPromptMuseum = location.pathname === '/image-prompts' || location.pathname.startsWith('/image-prompts/');
|
||||
const isChatWorkspace = location.pathname === '/chat';
|
||||
const isInitializationSafeRoute = location.pathname === '/project-config'
|
||||
|| location.pathname.startsWith('/project-config/')
|
||||
|| location.pathname === '/plugins'
|
||||
|| !isProgrammingModule;
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
FolderKanban,
|
||||
LogOut,
|
||||
Plus,
|
||||
Puzzle,
|
||||
Settings as SettingsIcon,
|
||||
UserCircle2,
|
||||
} from 'lucide-react';
|
||||
@@ -584,23 +583,6 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
|
||||
<Plus className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed && '新建项目'}
|
||||
</button>
|
||||
<div className="mt-3 grid gap-1">
|
||||
<button
|
||||
type="button"
|
||||
data-testid="sidebar-nav-plugins"
|
||||
aria-current={location.pathname === '/plugins' ? 'page' : undefined}
|
||||
aria-label="插件"
|
||||
onClick={() => navigate(activeProject ? '/plugins?scope=project' : '/plugins?scope=all')}
|
||||
className={cn(
|
||||
'motion-press flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
|
||||
location.pathname === '/plugins' ? 'bg-brand-selected text-foreground' : 'text-foreground hover:bg-surface-subtle',
|
||||
sidebarCollapsed && 'justify-center px-0',
|
||||
)}
|
||||
>
|
||||
<Puzzle className="h-4 w-4 shrink-0 text-brand" />
|
||||
{!sidebarCollapsed ? <span>插件</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -90,6 +90,7 @@ export function isAiModuleAllowed(moduleId: AiModuleId, access: ModuleAccess): b
|
||||
}
|
||||
|
||||
export function isProgrammingProviderRoute(pathname: string): boolean {
|
||||
if (matchesRoute(pathname, '/project-config/plugins')) return false;
|
||||
return PROGRAMMING_PROVIDER_ROUTE_PREFIXES.some((route) => matchesRoute(pathname, route));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useRef, type FormEvent } from 'react';
|
||||
import { AlertCircle, Puzzle, RefreshCw, Search } from 'lucide-react';
|
||||
import { AlertCircle, ArrowLeft, Puzzle, RefreshCw, Search } from 'lucide-react';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { DataServiceInstanceState } from '../../../shared/data-service';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { codingPluginsStore, useCodingPluginsStore } from '@/stores/coding-plugins';
|
||||
@@ -23,6 +24,7 @@ import type {
|
||||
} from './plugin-workspace-model';
|
||||
import { buildPluginWorkspaceProjection, isBundledOfficialPlugin } from './plugin-workspace-model';
|
||||
import { resolvePluginWorkspaceSearch, serializePluginWorkspaceSearch } from './plugin-workspace-query';
|
||||
import { PROJECT_SETTINGS_PLUGINS_PATH } from './legacy-plugin-redirect';
|
||||
|
||||
const DELIVERY_TEXT = {
|
||||
system_included: '随 MakeLore 提供',
|
||||
@@ -151,6 +153,8 @@ export interface PluginsViewProps {
|
||||
onResetDataService(): void | Promise<void>;
|
||||
onRemoveDataServiceCollection(collection: string): void | Promise<void>;
|
||||
onRemoveDataServiceProject(): void | Promise<void>;
|
||||
embedded?: boolean;
|
||||
onBackToProjectSettings?(): void;
|
||||
}
|
||||
|
||||
export function PluginsView(props: PluginsViewProps) {
|
||||
@@ -176,21 +180,46 @@ export function PluginsView(props: PluginsViewProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<main data-testid="plugins-page" className="mx-auto w-full max-w-7xl text-foreground">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-brand">MakeLore Code</p>
|
||||
<main
|
||||
data-testid="plugins-page"
|
||||
className={cn('w-full text-foreground', !props.embedded && 'mx-auto max-w-7xl')}
|
||||
>
|
||||
{props.embedded ? (
|
||||
<div className="flex justify-end">
|
||||
<Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />刷新全部来源
|
||||
</Button>
|
||||
</div>
|
||||
) : <header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
{props.onBackToProjectSettings ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-testid="project-plugins-back-button"
|
||||
aria-label="返回项目配置"
|
||||
title="返回项目配置"
|
||||
onClick={props.onBackToProjectSettings}
|
||||
className="mt-0.5 h-8 w-8 shrink-0 text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-brand">项目配置</p>
|
||||
<h1 className="mt-1 text-balance text-3xl font-semibold tracking-[-0.03em]">插件</h1>
|
||||
<p className="mt-2 max-w-3xl text-pretty text-sm leading-6 text-muted-foreground">
|
||||
MakeLore 运营发布官方插件;本机 Skill 和 Pi extension 仅通过对话安装。获取、项目启用与伙伴分配彼此独立,本页集中展示账号、设备、当前项目与伙伴状态。
|
||||
MakeLore 运营发布官方插件;本机 Skill 和 Pi extension 仅通过对话安装。获取、项目启用与伙伴分配彼此独立,这里集中展示账号、设备、当前项目与伙伴状态。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" className="min-h-10" onClick={() => void props.onRefresh()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />刷新全部来源
|
||||
</Button>
|
||||
</header>
|
||||
</header>}
|
||||
|
||||
<section aria-label="插件筛选" className="mt-6 rounded-2xl border border-border/70 bg-surface-subtle p-4">
|
||||
<section aria-label="插件筛选" className={cn(props.embedded ? 'mt-4' : 'mt-6', 'rounded-2xl border border-border/70 bg-surface-subtle p-4')}>
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-[0.8fr_0.8fr_1fr_2fr_auto]">
|
||||
<label className="text-sm font-medium">
|
||||
范围
|
||||
@@ -351,7 +380,7 @@ function scopeFallbackSearchFromLocationState(state: unknown): string | null {
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
export function Plugins() {
|
||||
export function Plugins({ embedded = false }: { embedded?: boolean } = {}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -613,7 +642,7 @@ export function Plugins() {
|
||||
marketplace: pluginMarketplaceStore.getState(),
|
||||
device: devicePackageStore.getState(),
|
||||
project: codingPluginsStore.getState(),
|
||||
loginReturnPath: `/plugins?${serializePluginWorkspaceSearch(filters).toString()}`,
|
||||
loginReturnPath: `${PROJECT_SETTINGS_PLUGINS_PATH}?${serializePluginWorkspaceSearch(filters).toString()}`,
|
||||
navigate: (path, options) => navigate(path, options),
|
||||
openSettings: () => document.getElementById('plugin-settings')?.scrollIntoView({ behavior: 'smooth', block: 'start' }),
|
||||
}));
|
||||
@@ -641,6 +670,8 @@ export function Plugins() {
|
||||
onResetDataService={() => safe(codingPluginsStore.getState().reset())}
|
||||
onRemoveDataServiceCollection={(collection) => safe(codingPluginsStore.getState().removeCollection(collection))}
|
||||
onRemoveDataServiceProject={() => safe(codingPluginsStore.getState().removeProject())}
|
||||
embedded={embedded}
|
||||
onBackToProjectSettings={embedded ? undefined : () => navigate('/project-config')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
|
||||
export const PROJECT_SETTINGS_PLUGINS_PATH = '/project-config/plugins';
|
||||
|
||||
export type LegacyPluginPath = '/plugin-marketplace' | '/my-plugins' | '/project-plugins';
|
||||
|
||||
const LEGACY_PLUGIN_REDIRECTS: Record<LegacyPluginPath, string> = {
|
||||
'/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`,
|
||||
};
|
||||
|
||||
export function LegacyPluginRedirect({ from }: { from: LegacyPluginPath }) {
|
||||
return <Navigate to={LEGACY_PLUGIN_REDIRECTS[from]} replace />;
|
||||
}
|
||||
|
||||
export function PluginWorkspaceRedirect() {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<Navigate
|
||||
to={{ pathname: PROJECT_SETTINGS_PLUGINS_PATH, search: location.search }}
|
||||
state={location.state}
|
||||
replace
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Trash2, Upload, X } from 'lucide-react';
|
||||
import { ArrowLeft, Blocks, Check, Cpu, FileText, Files, FolderOpen, Plus, Puzzle, Trash2, Upload, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -8,6 +8,8 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Sheet, SheetClose, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet';
|
||||
import { AgentCreationDialog, type AgentCreationInput } from '@/components/coding/AgentCreationDialog';
|
||||
import { ProjectPublishAction } from '@/components/works/ProjectPublishAction';
|
||||
import { Plugins } from '@/pages/Plugins';
|
||||
import { PROJECT_SETTINGS_PLUGINS_PATH } from '@/pages/Plugins/legacy-plugin-redirect';
|
||||
import { buildCodingModelOptions, parseCodingModelKey, type CodingModelOption } from '@/lib/coding-model-options';
|
||||
import { getCodingSkills } from '@/lib/coding-product-tools';
|
||||
import { abortCodingConversation } from '@/lib/coding-conversations';
|
||||
@@ -70,6 +72,23 @@ function DrawerFrame({ title, desc, onClose, children, closeLabel = '关闭', cl
|
||||
return <div className="flex min-h-0 flex-1 flex-col"><div className="glass-surface drawer-motion-header flex items-center justify-between gap-3 border-b border-border/70 bg-background px-5 py-4"><div className="min-w-0 flex-1"><SheetTitle className="text-lg font-semibold">{title}</SheetTitle><SheetDescription className="mt-1 text-sm font-medium text-muted-foreground">{desc}</SheetDescription></div>{closeIcon === 'back' ? closeButton : <SheetClose asChild>{closeButton}</SheetClose>}</div><div className="drawer-motion-body min-h-0 flex-1 overflow-auto p-5">{children}</div></div>;
|
||||
}
|
||||
|
||||
function PluginWorkspaceSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
return <Sheet open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
|
||||
<SheetContent
|
||||
data-testid="project-plugins-sheet"
|
||||
className="glass-surface flex w-[min(1080px,96vw)] flex-col overflow-hidden border-l border-border/70 bg-background/90 p-0 text-foreground sm:max-w-none"
|
||||
>
|
||||
<DrawerFrame
|
||||
title="插件"
|
||||
desc="在当前项目页查看插件、启用项目能力并管理伙伴分配;获取、设备安装和项目启用仍彼此独立,本机 Skill 和 Pi extension 仍仅通过对话安装。"
|
||||
onClose={onClose}
|
||||
>
|
||||
<Plugins embedded />
|
||||
</DrawerFrame>
|
||||
</SheetContent>
|
||||
</Sheet>;
|
||||
}
|
||||
|
||||
function SkillDetail({ skill }: { skill: SkillInfo }) {
|
||||
return <div data-testid="skill-detail-view" className="space-y-4">
|
||||
<section data-testid="skill-structure" className="rounded-xl border border-foreground/15 bg-surface-subtle p-4">
|
||||
@@ -198,8 +217,14 @@ export function ProjectConfiguration() {
|
||||
const activeSkill = skills.find((skill) => skill.id === activeSkillId) ?? null;
|
||||
const playSaveConfirmation = () => undefined;
|
||||
const handleBack = () => navigate(-1);
|
||||
const pluginWorkspaceOpen = location.pathname === PROJECT_SETTINGS_PLUGINS_PATH;
|
||||
const openPluginWorkspace = () => navigate({
|
||||
pathname: PROJECT_SETTINGS_PLUGINS_PATH,
|
||||
search: `?scope=${activeProject ? 'project' : 'all'}`,
|
||||
});
|
||||
const closePluginWorkspace = () => navigate('/project-config', { replace: true });
|
||||
|
||||
if (!activeProject) return <main data-testid="project-configuration-empty-state" className="flex min-h-full items-start bg-background p-8 text-foreground"><p className="pt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p></main>;
|
||||
if (!activeProject) return <><main data-testid="project-configuration-empty-state" className="min-h-full bg-background p-8 text-foreground"><div className="mx-auto w-full max-w-7xl"><h1 className="text-xl font-semibold">项目配置</h1><p className="mt-4 text-xl font-semibold tracking-[-0.02em]">一念成光,万物可创。</p><p className="mt-2 text-sm font-medium text-muted-foreground">新建或打开一个项目后即可配置智能体、知识与项目能力;插件目录仍可在这里查看。</p><div className="mt-6 max-w-sm"><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="查看账号与本机插件" onClick={openPluginWorkspace} /></div></div></main><PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} /></>;
|
||||
if (!draft) return <div className="p-8 text-center text-sm font-medium">正在读取项目配置…</div>;
|
||||
|
||||
const updateAgent = (next: CodingProjectAgent) => setDraft((current) => current ? { ...current, agents: current.agents.map((item) => item.id === next.id ? { ...next, updatedAt: new Date().toISOString() } : item) } : current);
|
||||
@@ -455,7 +480,7 @@ export function ProjectConfiguration() {
|
||||
</form>
|
||||
)}
|
||||
</section>
|
||||
<section className="grid gap-3 md:grid-cols-3"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /></section>
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4"><ResourceCard id="models" icon={<Cpu />} title="可用模型" subtitle={`${modelOptions.length} 个可用模型`} onClick={() => setDrawerMode('models')} /><ResourceCard id="skills" icon={<Blocks />} title="可用技能" subtitle={`${skills.length} 项可用技能`} onClick={() => { setActiveSkillId(null); setDrawerMode('skills'); }} /><ResourceCard id="knowledge" icon={<Files />} title="知识文件" subtitle={`${knowledge.length} 个知识文件`} onClick={() => setDrawerMode('knowledge')} /><ResourceCard id="plugins" icon={<Puzzle />} title="插件" subtitle="管理获取、启用与分配状态" onClick={openPluginWorkspace} /></section>
|
||||
<section className="config-motion-partners surface-card rounded-2xl border border-border/70 bg-background p-5 shadow-soft"><div className="mb-4 flex items-center justify-between gap-3"><div><h2 className="text-xl font-semibold">项目智能体</h2><p className="mt-1 text-sm font-medium text-muted-foreground">智能体配置仅属于当前项目;每个智能体可以拥有多条独立对话。</p></div><Button type="button" size="icon" className="h-8 w-8 rounded-full border border-brand/20 bg-brand-soft text-brand" aria-label="创建智能体" title="创建智能体" onClick={() => { if (modelOptions.length === 0) { toast.error('请先在模型设置中配置至少一个可用模型。'); setDrawerMode('models'); return; } setActiveAgentId(null); setPartnerDialogOpen(true); }}><Plus className="h-4 w-4" /></Button></div><div data-testid="agent-cards-scroll-region" className="-m-2 flex gap-4 overflow-auto p-2">{draft.agents.filter((agent) => !agent.archivedAt).map((agent) => <AgentCard key={agent.id} agent={agent} onOpen={() => { setActiveAgentId(agent.id); setPartnerDialogOpen(true); }} />)}{draft.agents.every((agent) => agent.archivedAt) ? <div className="w-full rounded-xl border border-dashed border-foreground/15 p-6 text-center text-sm font-medium text-muted-foreground">当前项目还没有智能体,点击右上角的 + 创建。</div> : null}</div>{draft.agents.some((agent) => agent.archivedAt) ? <div className="mt-4 border-t border-border/70 pt-4"><p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">已归档</p><div className="mt-2 flex flex-wrap gap-2">{draft.agents.filter((agent) => agent.archivedAt).map((agent) => <Button key={agent.id} type="button" variant="outline" className="border-border/70 bg-surface-subtle" onClick={() => updateAgent({ ...agent, archivedAt: null })}>{agent.name || '未命名智能体'} · 恢复</Button>)}</div></div> : null}</section>
|
||||
<div data-testid="project-configuration-actions" className="glass-surface config-motion-actions sticky bottom-3 z-10 mt-auto flex shrink-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background/85 p-2 shadow-float sm:flex-row sm:items-start sm:justify-between">
|
||||
<Button type="button" variant="outline" size="sm" onClick={() => setDeleteDialogOpen(true)} className="h-9 w-full border-brand/25 bg-background px-3 font-semibold text-foreground shadow-none hover:border-brand/40 hover:bg-brand-soft sm:w-auto"><Trash2 className="mr-2 h-4 w-4" />删除项目</Button>
|
||||
@@ -492,6 +517,7 @@ export function ProjectConfiguration() {
|
||||
{drawerMode === 'skills' && activeSkill ? <DrawerFrame title={`${activeSkill.name} · 技能详情`} desc="查看技能目录结构和主文件 SKILL.md。" onClose={() => setActiveSkillId(null)} closeLabel="返回技能列表" closeIcon="back"><SkillDetail skill={activeSkill} /></DrawerFrame> : null}
|
||||
{drawerMode === 'skills' && !activeSkill ? <DrawerFrame title="可用技能" desc="查看技能目录与主文件;在智能体配置中启用所需技能。" onClose={() => setDrawerMode(null)}><div className="space-y-3">{skills.length > 0 ? skills.map((skill) => <button key={skill.id} type="button" data-testid={`skill-card-${skill.id}`} aria-label={`查看技能详情:${skill.name}`} onClick={() => setActiveSkillId(skill.id)} className="w-full rounded-lg border border-foreground/15 bg-white p-3 text-left transition hover:border-brand/40 hover:bg-brand-soft/30"><div className="flex items-start gap-3"><Blocks className="mt-0.5 h-4 w-4 shrink-0 text-brand" /><span className="min-w-0 flex-1"><span className="block font-semibold">{skill.name}</span><span className="mt-1 block text-sm text-muted-foreground">{skill.description}</span><span className="mt-2 block text-[11px] font-medium text-brand">查看目录与 SKILL.md</span></span><ArrowLeft className="mt-0.5 h-4 w-4 shrink-0 rotate-180 text-muted-foreground" aria-hidden="true" /></div></button>) : <div className="rounded-lg border border-dashed border-foreground/15 bg-white p-4 text-sm font-medium text-muted-foreground">当前没有可查看的技能。</div>}</div></DrawerFrame> : null}
|
||||
</SheetContent></Sheet>
|
||||
<PluginWorkspaceSheet open={pluginWorkspaceOpen} onClose={closePluginWorkspace} />
|
||||
<ConfirmDialog
|
||||
open={Boolean(archiveAgentId)}
|
||||
title={`归档智能体“${draft.agents.find((agent) => agent.id === archiveAgentId)?.name || '未命名智能体'}”?`}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(() => (
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
|
||||
@@ -105,8 +105,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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user