源码
tests/desktop/file-manager.spec.ts
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, realpath, rename, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import type { ElectronApplication, Locator, Page } from "@playwright/test";
import { chunk, done, fakeServer, send } from "../fake-server.ts";
import { runCleanup } from "../helpers/cleanup.ts";
import { launchDesktop } from "../helpers/desktop.ts";
import { expect, test } from "../helpers/test.ts";
const exec = promisify(execFile);
async function expectSplit(manager: Locator) {
for (const id of ["preview", "directory"])
await expect
.poll(() => manager.getByTestId(id).evaluate((element) => element.clientWidth))
.toBeGreaterThan(60);
await expect(manager.getByLabel("调整文件目录宽度", { exact: true })).toBeVisible();
await expect
.poll(() =>
manager.evaluate((element) => {
const directory = element.querySelector<HTMLElement>('[data-panel][id="directory"]');
return directory ? directory.clientWidth / element.clientWidth : 1;
}),
)
.toBeLessThanOrEqual(0.61);
}
async function dragDirectoryToHalf(page: Page, manager: Locator) {
await expectSplit(manager);
const split = await manager.locator(".file-manager-split").boundingBox();
const handle = await manager.getByLabel("调整文件目录宽度", { exact: true }).boundingBox();
if (!split || !handle) throw new Error("Missing file navigation splitter");
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2);
await page.mouse.down();
await page.mouse.move(split.x + split.width / 2, handle.y + handle.height / 2, { steps: 8 });
await page.mouse.up();
await expect
.poll(async () => {
const directory = await manager.getByTestId("directory").boundingBox();
return directory ? Math.abs(directory.width - split.width / 2) : 100;
})
.toBeLessThan(5);
}
test("chat file previews and single-file diffs share ZCode's file container and in-tab workspace navigation", async () => {
test.setTimeout(60000);
const dir = await realpath(await mkdtemp(join(tmpdir(), "zpi-unified-files-")));
const project = join(dir, "project");
await mkdir(project);
await writeFile(join(project, "workspace-notes-sentinel.md"), "# Workspace notes\n");
const server = await fakeServer((body, response) => {
const messages = body.messages as unknown as { role: string }[];
if (!messages.some((message) => message.role === "tool")) {
send(
response,
chunk({
tool_calls: [
{
index: 0,
id: "write-file",
type: "function",
function: {
name: "write",
arguments: JSON.stringify({ path: "hello.md", content: "Unified file preview\n" }),
},
},
],
}),
);
done(response, "tool_calls");
} else {
send(
response,
chunk({
content: "[hello.md](hello.md) 和 [workspace-notes-sentinel.md](workspace-notes-sentinel.md)",
}),
);
done(response);
}
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
const page = await app.firstWindow();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(920, 760));
await page.getByLabel("添加项目", { exact: true }).click();
await expect(page.getByLabel("选择项目", { exact: true })).toContainText("project");
await page.getByLabel("消息", { exact: true }).fill("创建文件");
await page.getByLabel("消息", { exact: true }).press("Enter");
await expect(page.getByTestId("run")).toHaveAttribute("data-status", "completed");
const pane = page.locator(".right-pane");
const manager = pane.locator(".file-manager:visible");
const answer = page.locator(".answer");
const fileLink = answer.getByRole("button", { name: "hello.md", exact: true });
const cardOpen = page
.locator(".assistant-preview-card")
.filter({ hasText: "hello.md" })
.getByRole("button", { name: "打开", exact: true });
await page.evaluate(() => {
localStorage.setItem(
"react-resizable-panels:zpi.file-manager-layout:preview:directory",
JSON.stringify({ preview: 0, directory: 100 }),
);
});
await cardOpen.click();
await expect(pane).not.toHaveAttribute("data-animating", "true");
await expect.poll(() => manager.evaluate((element) => element.clientWidth)).toBeLessThan(384);
await expect
.poll(() => manager.locator(".file-manager-preview").evaluate((element) => element.clientWidth))
.toBeGreaterThan(80);
await expect(manager.locator(".file-markdown-preview")).toContainText("Unified file preview");
await dragDirectoryToHalf(page, manager);
await manager.getByLabel("收起文件导航", { exact: true }).click();
await expect
.poll(() => manager.getByTestId("preview").evaluate((element) => element.clientWidth))
.toBeGreaterThan(160);
await page.getByLabel("收起右侧栏", { exact: true }).click();
await expect(pane).toBeHidden();
await cardOpen.click();
await expect
.poll(() => manager.getByTestId("preview").evaluate((element) => element.clientWidth))
.toBeGreaterThan(80);
await expect(pane.getByRole("tab")).toHaveCount(1);
await page.getByLabel("收起右侧栏", { exact: true }).click();
await expect(pane).toBeHidden();
await page.locator(".changed-files-summary").click();
await page.getByLabel("审查 hello.md", { exact: true }).click();
await expect(pane).not.toHaveAttribute("data-animating", "true");
await expect
.poll(() => manager.locator(".file-manager-preview").evaluate((element) => element.clientWidth))
.toBeGreaterThan(80);
await expect(manager.locator(".changes-pane")).toContainText("Unified file preview");
await manager.getByLabel("显示文件导航", { exact: true }).click();
await dragDirectoryToHalf(page, manager);
await pane.screenshot({ path: test.info().outputPath("narrow-diff-split.png") });
await page.getByLabel("收起右侧栏", { exact: true }).click();
await expect(pane).toBeHidden();
await page.getByLabel("审查 hello.md", { exact: true }).click();
await expect
.poll(() => manager.getByTestId("preview").evaluate((element) => element.clientWidth))
.toBeGreaterThan(80);
await expect(pane.getByRole("tab")).toHaveCount(2);
await page.getByLabel("收起右侧栏", { exact: true }).click();
await expect(pane).toBeHidden();
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1800, 1050));
await fileLink.click();
await expect(manager.locator(".file-manager-toolbar .preview-header")).toHaveCount(1);
await expect(manager.locator(".file-manager-directory")).toBeVisible();
await expect(manager.getByRole("treeitem").filter({ hasText: "hello.md" })).toHaveAttribute(
"aria-selected",
"true",
);
await manager.getByLabel("收起文件导航", { exact: true }).click();
await page.getByLabel("审查 hello.md", { exact: true }).click();
const diffTab = pane
.getByRole("tab", { name: "hello.md", exact: true })
.filter({ has: page.locator(".pane-tab-diff-badge") });
await expect(diffTab).toHaveAttribute("aria-selected", "true");
await expect(manager.locator(".file-manager-directory")).toBeHidden();
await expect(manager.locator(".file-manager-toolbar .preview-source")).toBeVisible();
await expect(manager.locator(".changes-pane .preview-header")).toHaveCount(0);
await manager.getByLabel("显示文件导航", { exact: true }).click();
await expect(
manager.getByRole("treeitem").filter({ hasText: "workspace-notes-sentinel.md" }),
).toBeVisible();
await manager.getByLabel("搜索文件", { exact: true }).fill("workspace-notes-sentinel");
await expect(manager.getByRole("treeitem")).toHaveCount(1);
await manager.getByLabel("清空文件搜索", { exact: true }).click();
await page.emulateMedia({ colorScheme: "light" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await pane.screenshot({ path: "test-results/unified-file-diff-light.png", animations: "disabled" });
await page.emulateMedia({ colorScheme: "dark" });
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await pane.screenshot({ path: "test-results/unified-file-diff-dark.png", animations: "disabled" });
await manager.getByRole("button", { name: "查看源码", exact: true }).click();
await expect(pane.getByRole("tab")).toHaveCount(2);
await expect(
pane
.getByRole("tab", { name: "hello.md", exact: true })
.filter({ hasNot: page.locator(".pane-tab-diff-badge") }),
).toHaveAttribute("aria-selected", "true");
await diffTab.locator(".pane-tab-trigger").click();
await manager.getByRole("treeitem").filter({ hasText: "workspace-notes-sentinel.md" }).click();
await expect(manager.locator(".file-markdown-preview")).toContainText("Workspace notes");
await expect(pane.getByRole("tab", { name: "workspace-notes-sentinel.md", exact: true })).toBeVisible();
await expect(pane.locator(".pane-tab-diff-badge")).toHaveCount(0);
await page.getByLabel("审查 hello.md", { exact: true }).click();
await expect(diffTab).toBeVisible();
await expect(pane.getByRole("tab")).toHaveCount(3);
await expect(pane.getByRole("tab", { name: "workspace-notes-sentinel.md", exact: true })).toBeVisible();
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(920, 760));
await expectSplit(manager);
await manager.getByRole("treeitem").filter({ hasText: "workspace-notes-sentinel.md" }).click();
await expect(manager.locator(".file-manager-directory")).toBeVisible();
await expect(manager.locator(".file-markdown-preview")).toContainText("Workspace notes");
expect(errors).toEqual([]);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("file launcher, preview/navigation layout, search, Git filtering, refresh and task isolation match ZCode", async () => {
test.setTimeout(90000);
const dir = await realpath(await mkdtemp(join(tmpdir(), "zpi-file-manager-")));
const project = join(dir, "project");
await mkdir(join(project, "src", "features", "auth"), { recursive: true });
await mkdir(join(project, "other"));
await writeFile(join(project, "README.md"), "# Preview sentinel\n");
await writeFile(join(project, "src", "features", "auth", "index.ts"), "export const auth = true;\n");
await writeFile(join(project, ".env"), "HIDDEN=1");
await exec("git", ["init", "-q"], { cwd: project });
await exec("git", ["add", "."], { cwd: project });
await exec("git", ["-c", "user.email=test@example.test", "-c", "user.name=Test", "commit", "-qm", "seed"], {
cwd: project,
});
await writeFile(join(project, "README.md"), "# Preview sentinel changed\n");
const server = await fakeServer((_, response) => {
send(response, chunk({ content: "ok" }));
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
const page = await app.firstWindow();
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1600, 1000));
await page.getByLabel("添加项目", { exact: true }).click();
await page.getByLabel("展开右侧栏", { exact: true }).click();
const pane = page.locator(".right-pane");
const launcher = pane.locator(".pane-empty-launcher");
await expect(launcher.locator(".pane-launcher-label")).toHaveText(["文件", "浏览器", "终端", "审查"]);
await expect(pane.getByRole("tab", { name: "新标签页", exact: true })).toBeVisible();
await launcher.getByRole("button", { name: "文件", exact: true }).click();
const manager = pane.locator(".file-manager").first();
const tree = manager.getByRole("tree");
const row = (name: string) => tree.getByRole("treeitem").filter({ hasText: name });
await expect(manager).toBeVisible();
await expect(pane.getByRole("tab", { name: "新标签页", exact: true })).toHaveCount(0);
await expect(row(".env")).toBeVisible();
await manager.getByRole("button", { name: "更多", exact: true }).click();
const moreMenu = page.locator(".file-tree-more");
const menuItems = moreMenu.getByRole("menuitem");
await expect(moreMenu).toHaveAttribute("data-align", "end");
await expect(menuItems.first().locator(".lucide-folder-open")).toBeVisible();
await expect(menuItems.first().locator("img")).toHaveCount(0);
await moreMenu.evaluate(async (element) => {
await Promise.all(element.getAnimations().map((animation) => animation.finished));
});
const alignment = await menuItems.evaluateAll((items) =>
items.map((item) => {
const icon = item.querySelector("img,svg");
const text = Array.from(item.childNodes).find(
(node) => node.nodeType === Node.TEXT_NODE && node.textContent?.trim(),
);
if (!icon || !text) throw new Error("Missing menu icon or text");
const range = document.createRange();
range.selectNodeContents(text);
return {
gap: range.getBoundingClientRect().left - icon.getBoundingClientRect().right,
left: range.getBoundingClientRect().left,
justify: getComputedStyle(item).justifyContent,
};
}),
);
expect(alignment.map((item) => item.justify)).toEqual(["flex-start", "flex-start"]);
expect(alignment.map((item) => item.gap)).toEqual([8, 8]);
expect(alignment[0].left).toBe(alignment[1].left);
await moreMenu.screenshot({ path: test.info().outputPath("file-menu-aligned.png") });
await page.keyboard.press("Escape");
await expect(manager.locator(".file-manager-empty")).toHaveText("从目录中选择一个文件");
await row("README.md").click();
await expect(pane.getByRole("tab", { name: "README.md", exact: true })).toBeVisible();
await expect(manager.locator(".file-markdown-preview")).toContainText("Preview sentinel changed");
await expect(manager.locator(".file-manager-toolbar .preview-header")).toHaveCount(1);
await expect(manager.locator(".file-pane > .preview-header")).toHaveCount(0);
await row("src").click();
await expect(row("src/features/auth")).toBeVisible();
await expect(row("index.ts")).toBeVisible();
const search = manager.getByRole("textbox", { name: "搜索文件", exact: true });
await search.fill("authindex");
await expect(row("src/features/auth/index.ts")).toBeVisible();
await search.fill("features");
await row("src/features").first().click();
await expect(search).toHaveValue("");
await expect(row("index.ts")).toBeVisible();
await manager.getByLabel("仅显示变更文件", { exact: true }).click();
await expect(row("README.md")).toBeVisible();
await expect(row(".env")).toHaveCount(0);
await manager.getByLabel("显示全部文件", { exact: true }).click();
await rename(
join(project, "src", "features", "auth", "index.ts"),
join(project, "src", "features", "auth", "renamed.ts"),
);
await expect(row("renamed.ts")).toBeVisible();
await manager.getByLabel("刷新文件树", { exact: true }).click();
await expect(row("renamed.ts")).toBeVisible();
await expect(row("index.ts")).toHaveAttribute("data-git-status", "deleted");
await row("index.ts").click();
await expect(manager.locator(".file-markdown-preview")).toBeVisible();
await row("renamed.ts").click({ button: "right" });
await page.getByRole("menuitem", { name: "添加到聊天", exact: true }).click();
const composer = page.locator("main").getByLabel("消息", { exact: true });
await expect(composer).toContainText("renamed.ts");
await row("README.md").evaluate((element) => {
const transfer = new DataTransfer();
element.dispatchEvent(new DragEvent("dragstart", { bubbles: true, dataTransfer: transfer }));
document
.querySelector("main .composer")
?.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer }));
element.dispatchEvent(new DragEvent("dragend", { bubbles: true, dataTransfer: transfer }));
});
await expect(composer).toContainText("README.md");
expect(server.requests).toHaveLength(0);
await manager.getByLabel("收起文件导航", { exact: true }).click();
await expect(manager.locator(".file-manager-directory")).toBeHidden();
await page.getByLabel("新增侧栏标签", { exact: true }).click();
await pane.locator('.pane-empty-launcher:visible [data-side-pane-open-tab-item="files"]').click();
const second = pane.locator(".file-manager:visible");
await expect(second.locator(".file-manager-directory")).toBeHidden();
await second.getByLabel("显示文件导航", { exact: true }).click();
await expect(second.locator(".file-manager-directory")).toBeVisible();
await page.getByLabel("关闭标签 文件", { exact: true }).last().click();
await expect(manager.locator(".file-manager-directory")).toBeVisible();
const directoryBefore = await manager.locator(".file-manager-directory").boundingBox();
await manager.getByLabel("调整文件目录宽度", { exact: true }).press("ArrowLeft");
await expect
.poll(async () => (await manager.locator(".file-manager-directory").boundingBox())?.width ?? 0)
.toBeGreaterThan(directoryBefore?.width ?? 0);
await page.screenshot({ path: "test-results/file-manager-wide-light.png" });
await pane.screenshot({ path: "test-results/file-manager-panel-light.png" });
await page.evaluate(() => {
document.documentElement.dataset.theme = "dark";
});
await page.screenshot({ path: "test-results/file-manager-wide-dark.png" });
await pane.screenshot({ path: "test-results/file-manager-panel-dark.png" });
await pane.getByLabel("右侧栏宽度", { exact: true }).press("ArrowRight");
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(920, 760));
await expectSplit(manager);
await row("README.md").click();
await expect(manager.locator(".file-manager-directory")).toBeVisible();
await expect(manager.locator(".file-markdown-preview")).toBeVisible();
await page.screenshot({ path: "test-results/file-manager-narrow-dark.png" });
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1600, 1000));
await expect(manager.locator(".file-manager-directory")).toBeVisible();
const taskId = await page.evaluate(async () => {
const result = await window.zpi.listRecentSessions();
if (!result.ok) throw new Error(result.error.message);
const task = result.value.find((task) => task.draft && task.projectId);
if (!task) throw new Error("Missing project draft");
const configured = await window.zpi.setNewWorktree(task.id, true);
if (!configured.ok) throw new Error(configured.error.message);
return task.id;
});
await composer.fill("创建工作树");
await composer.press("Enter");
await expect(page.getByTestId("run")).toHaveAttribute("data-status", "completed");
const workspace = await page.evaluate((id) => window.zpi.getWorkspaceInfo(id), taskId);
expect(workspace).not.toMatchObject({ ok: true, value: { cwd: project } });
await writeFile(join(project, "original-root.txt"), "frozen target");
await manager.getByLabel("刷新文件树", { exact: true }).click();
await expect(row("original-root.txt")).toBeVisible();
await expect(manager.locator(".preview-breadcrumb")).toContainText("project");
await manager.getByLabel("收起文件导航", { exact: true }).click();
await page.reload();
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page.locator('[data-side-pane-open-tab-item="files"]').click();
await expect(page.locator(".file-manager-directory")).toBeVisible();
expect(errors).toEqual([]);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("unassigned task workspaces survive navigation and restart and remain separate between new tasks", async () => {
test.setTimeout(60000);
const dir = await realpath(await mkdtemp(join(tmpdir(), "zpi-draft-workspaces-")));
const server = await fakeServer((_, response) => {
send(response, chunk({ content: "ok" }));
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, url: server.url });
let page = await app.firstWindow();
await expect(page.getByLabel("消息", { exact: true })).toBeVisible();
const first = await page.evaluate(async () => {
const result = await window.zpi.listRecentSessions();
if (!result.ok) throw new Error(result.error.message);
return result.value[0];
});
expect(first.cwd).toBe(join(dir, "workspace", `draft-${first.id}`));
if (!first.cwd) throw new Error("Missing draft cwd");
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page.locator('[data-side-pane-open-tab-item="files"]').click();
await expect(page.locator(".file-tree-toolbar h3")).toHaveText(`draft-${first.id}`);
expect(server.requests).toHaveLength(0);
await writeFile(join(first.cwd, "keep.txt"), "keep draft data");
await page.getByLabel("消息", { exact: true }).fill("第一项任务");
await page.getByLabel("消息", { exact: true }).press("Enter");
await expect(page.getByTestId("run")).toHaveAttribute("data-status", "completed");
await page.getByLabel("新建任务", { exact: true }).first().click();
await expect
.poll(async () =>
page.evaluate(async () => {
const result = await window.zpi.listRecentSessions();
return result.ok ? result.value.length : 0;
}),
)
.toBe(2);
const second = await page.evaluate(async () => {
const result = await window.zpi.listRecentSessions();
if (!result.ok) throw new Error(result.error.message);
const draft = result.value.find((task) => task.draft);
if (!draft) throw new Error("Missing draft");
return draft;
});
expect(second.cwd).toBe(join(dir, "workspace", `draft-${second.id}`));
expect(second.cwd).not.toBe(first.cwd);
await page.getByLabel("新建任务", { exact: true }).first().click();
const reused = await page.evaluate(async () => {
const result = await window.zpi.listRecentSessions();
return result.ok ? result.value.filter((task) => task.draft).map((task) => task.id) : [];
});
expect(reused).toEqual([second.id]);
await app.close();
app = await launchDesktop({ dir, url: server.url });
page = await app.firstWindow();
await expect(page.getByLabel("消息", { exact: true })).toBeVisible();
const restored = await page.evaluate(async (id) => window.zpi.getWorkspaceInfo(id), first.id);
expect(restored).toMatchObject({ ok: true, value: { cwd: first.cwd } });
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page.locator('[data-side-pane-open-tab-item="files"]').click();
await expect(page.locator(".file-tree-toolbar h3")).toHaveText(`draft-${second.id}`);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});
test("unsent project drafts inherit the project and use a draft workspace immediately after detaching", async () => {
const dir = await realpath(await mkdtemp(join(tmpdir(), "zpi-unsent-workspace-")));
const project = join(dir, "ZPI");
await mkdir(project);
await writeFile(join(project, "project-only.txt"), "project file");
const server = await fakeServer((_, response) => {
send(response, chunk({ content: "ok" }));
done(response);
});
let app: ElectronApplication | undefined;
try {
app = await launchDesktop({ dir, project, url: server.url });
const page = await app.firstWindow();
await page.getByLabel("添加项目", { exact: true }).first().click();
await expect(page.getByLabel("选择项目", { exact: true })).toContainText("ZPI");
await page.getByLabel("新建任务", { exact: true }).first().click();
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page.locator('[data-side-pane-open-tab-item="files"]').click();
await expect(page.locator(".file-tree-toolbar h3")).toHaveText("ZPI");
await expect(page.getByRole("treeitem").filter({ hasText: "project-only.txt" })).toBeVisible();
await page.locator(".draft-project-chip").hover();
await page.getByLabel("取消选择当前项目", { exact: true }).click();
await expect(page.getByLabel("选择项目", { exact: true })).toHaveText("选择项目");
const draft = await page.evaluate(async () => {
const result = await window.zpi.listRecentSessions();
if (!result.ok) throw new Error(result.error.message);
const draft = result.value.find((task) => task.draft && task.projectId === null);
if (!draft) throw new Error("Missing unassigned draft");
return draft;
});
expect(draft.cwd).toBe(join(dir, "workspace", `draft-${draft.id}`));
await page.getByLabel("展开右侧栏", { exact: true }).click();
await page.locator('[data-side-pane-open-tab-item="files"]').click();
const manager = page.locator(".file-manager:visible");
await expect(manager.locator(".file-tree-toolbar h3")).toHaveText(`draft-${draft.id}`);
await expect(manager.getByRole("treeitem")).toHaveCount(0);
expect(server.requests).toHaveLength(0);
} finally {
await runCleanup([
() => rm(dir, { recursive: true, force: true }),
() => server.close(),
() => app?.close(),
]);
}
});