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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/evidence/project-directory-copy/README.md
Original file line number Diff line number Diff line change
@@ -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)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/evidence/project-directory-copy/after-en.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
55 changes: 55 additions & 0 deletions web/src/v2/components/project-header.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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} />
</LocaleProvider></QueryClientProvider>);
expect(getWorkspaceProject).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: "复制项目目录" })).not.toBeInTheDocument();
const user = userEvent.setup();
const trigger = screen.getByRole("button", { name: "页面设计 项目信息" });
trigger.focus();
Expand All @@ -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(<QueryClientProvider client={new QueryClient()}><LocaleProvider>
<ProjectHeader client={{ hasControl: true, getWorkspaceProject } as unknown as APIClient} workspace={workspace}
label={workspace.name} expanded onToggle={vi.fn()} onEdit={vi.fn()} />
</LocaleProvider></QueryClientProvider>);
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(<QueryClientProvider client={new QueryClient()}><LocaleProvider>
<ProjectHeader client={{ hasControl: true, getWorkspaceProject } as unknown as APIClient} workspace={workspace}
label={workspace.name} expanded onToggle={vi.fn()} />
</LocaleProvider></QueryClientProvider>);
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);
});
26 changes: 25 additions & 1 deletion web/src/v2/components/project-header.tsx
Original file line number Diff line number Diff line change
@@ -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 <button className="v2-project-card-edit" aria-label={t("复制项目目录", "Copy project directory")}
aria-disabled={state === "copying"} onClick={() => void copy()} type="button">
{state === "copied" ? <Check aria-hidden="true" size={16} /> : <Copy aria-hidden="true" size={16} />}
<span role="status" aria-live="polite">{state === "copying" ? t("正在复制…", "Copying…")
: state === "copied" ? t("已复制项目目录", "Project directory copied")
: state === "failed" ? t("复制失败,请重试", "Copy failed. Try again")
: t("复制项目目录", "Copy project directory")}</span>
</button>;
}

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;
Expand Down Expand Up @@ -95,6 +118,7 @@ export function ProjectHeader({ workspace, label, expanded, onToggle, onNewChat,
</> : <p>{!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 && <button onClick={() => void details.refetch()} type="button">{t("重试", "Retry")}</button>}</p>}
{details.data?.directory_path && <ProjectDirectoryCopy key={`${workspace.id}:${details.data.directory_path}`} path={details.data.directory_path} />}
{onEdit && <button className="v2-project-card-edit" ref={edit} onClick={() => {
clearTimer(); setOpen(false); more.current?.focus(); onEdit(details.data ?? workspace);
}} type="button"><Settings aria-hidden="true" size={16} />{t("编辑项目", "Edit project")}</button>}
Expand Down
Loading