diff --git a/docs/evidence/project-directory-copy/README.md b/docs/evidence/project-directory-copy/README.md new file mode 100644 index 00000000..b53b0fb6 --- /dev/null +++ b/docs/evidence/project-directory-copy/README.md @@ -0,0 +1,33 @@ +# 项目目录复制验收 + +项目详情卡增加“复制项目目录 / Copy project directory”。按钮复制详情接口返回的完整 `directory_path`,沿用“编辑项目”按钮样式,成功与失败反馈直接显示在复制按钮中。原目录文本仍可选中。 + +## 真实页面来源 + +- 改前:`fc0935d6fc03ca7abccbd148ce52d26c2bc154f8` 原始生产构建。 +- 改后:同一基点上仅修改 `project-header.tsx` 与对应测试后的生产构建,没有修改样式。 +- Windows 上的 Chrome 155.0.8059.85,有界面运行,通过 Playwright CLI 操作;所有截图为 1440 × 960、浅色主题。 +- 改前由现有 `cmd/workbenchjourney` 启动真实生产 API 和原始 bundle;改后由 Vite preview 提供生产 bundle,API 请求代理到同一个服务。同一个项目由创建项目界面导入,名称、目录、聊天数及视口保持一致。 +- 项目目录为本次验收创建的真实目录,含中文和空格,长度 134 个字符。未替换页面 DOM、剪贴板实现或 API 响应。 + +## 结果 + +- 中文界面:从项目详情卡的“编辑项目”按钮按 Shift+Tab 到复制按钮,Enter 复制;实际读取系统剪贴板,与完整目录逐字一致。 +- 中英文拒绝场景:在同一浏览器上下文通过 CDP 设置真实剪贴板权限为 denied,并保持该 CDP 会话连接,点击复制后分别显示“复制失败,请重试”和“Copy failed. Try again”。恢复读取权限后确认剪贴板仍是操作前写入的测试标记,没有被失败的复制改变。 +- 完整目录在成功、失败时均保留。按钮保持可访问名称,并提供 `role="status"` 反馈。Escape 返回项目详情触发按钮的焦点。 +- 单元测试覆盖中英文键盘操作、中文/空格路径原样复制、剪贴板拒绝后重试、关闭再打开时状态重置,以及未取得项目详情时不显示复制按钮。 +- `npm test -- src/v2/components/project-header.test.tsx`:5 个测试通过;`npm run build` 和 `git diff --check` 通过。 +- 浏览器覆盖为上述 Windows Chrome;未执行原生桌面 WebView 或 macOS 验收。 + +## 截图 + +| 界面 | 改前 | 改后 | +| --- | --- | --- | +| 中文 | ![中文改前](before-zh.png) | ![中文改后](after-zh.png) | +| English | ![English before](before-en.png) | ![English after](after-en.png) | + +![中文复制成功](after-copied-zh.png) + +![中文真实权限拒绝](after-denied-zh.png) + +![English permission denied](after-denied-en.png) diff --git a/docs/evidence/project-directory-copy/after-copied-zh.png b/docs/evidence/project-directory-copy/after-copied-zh.png new file mode 100644 index 00000000..07404f35 Binary files /dev/null and b/docs/evidence/project-directory-copy/after-copied-zh.png differ diff --git a/docs/evidence/project-directory-copy/after-denied-en.png b/docs/evidence/project-directory-copy/after-denied-en.png new file mode 100644 index 00000000..5f66e83c Binary files /dev/null and b/docs/evidence/project-directory-copy/after-denied-en.png differ diff --git a/docs/evidence/project-directory-copy/after-denied-zh.png b/docs/evidence/project-directory-copy/after-denied-zh.png new file mode 100644 index 00000000..1218be2a Binary files /dev/null and b/docs/evidence/project-directory-copy/after-denied-zh.png differ diff --git a/docs/evidence/project-directory-copy/after-en.png b/docs/evidence/project-directory-copy/after-en.png new file mode 100644 index 00000000..3e20884d Binary files /dev/null and b/docs/evidence/project-directory-copy/after-en.png differ diff --git a/docs/evidence/project-directory-copy/after-zh.png b/docs/evidence/project-directory-copy/after-zh.png new file mode 100644 index 00000000..f3b36976 Binary files /dev/null and b/docs/evidence/project-directory-copy/after-zh.png differ diff --git a/docs/evidence/project-directory-copy/before-en.png b/docs/evidence/project-directory-copy/before-en.png new file mode 100644 index 00000000..4cf84252 Binary files /dev/null and b/docs/evidence/project-directory-copy/before-en.png differ diff --git a/docs/evidence/project-directory-copy/before-zh.png b/docs/evidence/project-directory-copy/before-zh.png new file mode 100644 index 00000000..c415d732 Binary files /dev/null and b/docs/evidence/project-directory-copy/before-zh.png differ diff --git a/web/src/v2/components/project-header.test.tsx b/web/src/v2/components/project-header.test.tsx index 7d4224f5..f482b62f 100644 --- a/web/src/v2/components/project-header.test.tsx +++ b/web/src/v2/components/project-header.test.tsx @@ -16,6 +16,7 @@ it("fetches full project details only when opened and exposes editing to keyboar label={workspace.name} expanded onToggle={vi.fn()} onEdit={onEdit} /> ); expect(getWorkspaceProject).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "复制项目目录" })).not.toBeInTheDocument(); const user = userEvent.setup(); const trigger = screen.getByRole("button", { name: "页面设计 项目信息" }); trigger.focus(); @@ -42,6 +43,60 @@ it("opens on hover and keeps the interactive card open while the pointer enters const card = await screen.findByRole("dialog", { name: "页面设计 项目信息" }); await user.hover(card); expect(card).toHaveTextContent("连接操作令牌"); + expect(within(card).queryByRole("button", { name: "复制项目目录" })).not.toBeInTheDocument(); await user.unhover(card); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); }); + +it.each([ + ["zh-CN", "复制项目目录", "已复制项目目录", "编辑项目", "页面设计 项目信息"], + ["en-US", "Copy project directory", "Project directory copied", "Edit project", "页面设计 project details"], +])("copies the complete project path from the keyboard in %s", async (locale, copyLabel, copiedLabel, editLabel, detailsLabel) => { + localStorage.setItem("prayu.locale.v1", locale); + const user = userEvent.setup(); + const writeText = vi.spyOn(navigator.clipboard, "writeText"); + const path = "D:\\团队 项目\\Release 2026\\完整目录"; + const getWorkspaceProject = vi.fn().mockResolvedValue({ ...workspace, directory_path: path, thread_count: 2, active_thread_count: 1 }); + render( + + ); + const trigger = screen.getByRole("button", { name: detailsLabel }); + trigger.focus(); + await user.keyboard("{Enter}"); + const copyButton = await screen.findByRole("button", { name: copyLabel }); + await waitFor(() => expect(screen.getByRole("button", { name: editLabel })).toHaveFocus()); + await user.keyboard("{Shift>}{Tab}{/Shift}"); + expect(copyButton).toHaveFocus(); + await user.keyboard("{Enter}"); + await waitFor(() => expect(within(copyButton).getByRole("status")).toHaveTextContent(copiedLabel)); + expect(writeText).toHaveBeenCalledExactlyOnceWith(path); + expect(await navigator.clipboard.readText()).toBe(path); + expect(screen.getByText(path)).toBeVisible(); + expect(copyButton).toHaveFocus(); + await user.keyboard("{Escape}"); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + await user.keyboard("{Enter}"); + expect(within(await screen.findByRole("button", { name: copyLabel })).getByRole("status")).toHaveTextContent(copyLabel); +}); + +it("reports clipboard rejection without success and lets the user retry", async () => { + const user = userEvent.setup(); + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockRejectedValueOnce(new DOMException("Write permission denied", "NotAllowedError")); + const path = "D:\\Projects\\目录 with spaces"; + const getWorkspaceProject = vi.fn().mockResolvedValue({ ...workspace, directory_path: path, thread_count: 0, active_thread_count: 0 }); + render( + + ); + await user.click(screen.getByRole("button", { name: "页面设计 项目信息" })); + const copyButton = await screen.findByRole("button", { name: "复制项目目录" }); + await user.click(copyButton); + expect(within(copyButton).getByRole("status")).toHaveTextContent("复制失败,请重试"); + expect(screen.queryByText("已复制项目目录")).not.toBeInTheDocument(); + expect(screen.getByText(path)).toBeVisible(); + await user.click(copyButton); + expect(within(copyButton).getByRole("status")).toHaveTextContent("已复制项目目录"); + expect(writeText).toHaveBeenNthCalledWith(2, path); +}); diff --git a/web/src/v2/components/project-header.tsx b/web/src/v2/components/project-header.tsx index 09119aa5..f34f4883 100644 --- a/web/src/v2/components/project-header.tsx +++ b/web/src/v2/components/project-header.tsx @@ -1,13 +1,36 @@ import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { useQuery } from "@tanstack/react-query"; -import { ChevronRight, Folder, FolderOpen, MessageCircle, MoreHorizontal, Pin, Settings, SquarePen } from "lucide-react"; +import { Check, ChevronRight, Copy, Folder, FolderOpen, MessageCircle, MoreHorizontal, Pin, Settings, SquarePen } from "lucide-react"; import type { APIClient } from "../../api/client"; import type { WorkspaceView } from "../../api/types"; import { useLocale } from "../../lib/locale"; export const projectDetailsKey = (id: string) => ["v2", "workspace-project", id] as const; +function ProjectDirectoryCopy({ path }: { path: string }) { + const { t } = useLocale(); + const [state, setState] = useState<"idle" | "copying" | "copied" | "failed">("idle"); + const copy = async () => { + if (state === "copying") return; + setState("copying"); + try { + await navigator.clipboard.writeText(path); + setState("copied"); + } catch { + setState("failed"); + } + }; + return ; +} + export function ProjectHeader({ workspace, label, expanded, onToggle, onNewChat, onEdit, client, pinned = false, onTogglePin }: { workspace: WorkspaceView; label: string; expanded: boolean; onToggle: () => void; onNewChat?: () => void; onEdit?: (workspace: WorkspaceView) => void; client?: APIClient; @@ -95,6 +118,7 @@ export function ProjectHeader({ workspace, label, expanded, onToggle, onNewChat, :

{!client?.hasControl ? t("连接操作令牌后可查看项目详情。", "Connect with a control token to view project details.") : details.isError ? t("项目信息读取失败。", "Could not load project details.") : t("正在读取项目信息…", "Loading project details…")} {details.isError && }

} + {details.data?.directory_path && } {onEdit && }