import { execFileSync } from "node:child_process"; import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { type Locator, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes"; import { test, expect } from "../support/fixtures"; import { daemonWsRoutePattern } from "../support/helpers/daemon-port"; import { getServerId } from "../support/helpers/server-id"; import { connectSeedClient } from "../support/helpers/seed-client"; import { createTempGitRepo } from "../support/helpers/workspace"; import { openChangesPanel, waitForWorkspaceTabsVisible } from "../support/helpers/workspace-tabs"; interface DirtyWorkspace { id: string; repoPath: string; } interface WorkspaceFixtureOptions { includeDeletedFile?: boolean; includeNestedFolders?: boolean; includeRenamedFile?: boolean; // A root-level file whose name sorts BEFORE the root-level "src" directory. // zz-untracked.txt cannot stand in for it: "src" < "zz", so that file lands // last whether paths are compared whole or segment by segment. includeRootFileSortingFirst?: boolean; includeUntrackedFile?: boolean; } interface CleanupTask { run: () => Promise; } const cleanupTasks: CleanupTask[] = []; const APP_SETTINGS_KEY = "@paseo:app-settings"; function changesTree(page: Page) { return page.getByTestId("changes-file-tree").filter({ visible: true }); } function diffHeaderForPath(panel: Locator, filePath: string): Locator { return panel.locator(`[data-diff-header-path="${filePath}"]`).getByTestId(/^diff-file-\d+$/); } async function readFileIfPresent(filePath: string): Promise { try { return await readFile(filePath, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return null; } throw error; } } async function failNextDiscardRequest(page: Page): Promise { await page.routeWebSocket(daemonWsRoutePattern(), (browserSocket) => { const serverSocket = browserSocket.connectToServer(); browserSocket.onMessage((message) => { if (typeof message === "string") { const envelope = JSON.parse(message) as { message?: { type?: string; cwd?: string; requestId?: string }; }; if (envelope.message?.type === "checkout.discard_changes.request") { browserSocket.send( JSON.stringify({ type: "session", message: { type: "checkout.discard_changes.response", payload: { cwd: envelope.message.cwd, success: false, error: { code: "UNKNOWN", message: "Injected revert failure" }, requestId: envelope.message.requestId, }, }, }), ); return; } } serverSocket.send(message); }); serverSocket.onMessage((message) => browserSocket.send(message)); }); } const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences"; const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react"; interface UseMountedTabSetInput { activeTabId: string | null; allTabIds: string[]; cap: number; } interface UseMountedTabSetResult { mountedTabIds: Set; } function createInitialMountedTabIds(input: UseMountedTabSetInput): Set { if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) { return new Set(); } return new Set([input.activeTabId]); } function setsEqual(left: Set, right: Set): boolean { if (left.size !== right.size) { return false; } for (const value of left) { if (!right.has(value)) { return false; } } return true; } export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult { const { activeTabId, allTabIds, cap } = input; const allTabIdsKey = allTabIds.join("\\u0000"); const availableTabIds = useMemo(() => { void allTabIdsKey; return new Set(allTabIds); }, [allTabIds, allTabIdsKey]); const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input)); const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []); useLayoutEffect(() => { const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId)); if (activeTabId && availableTabIds.has(activeTabId)) { const existingIndex = nextLru.indexOf(activeTabId); if (existingIndex >= 0) { nextLru.splice(existingIndex, 1); } nextLru.unshift(activeTabId); } if (nextLru.length > cap) { nextLru.length = cap; } lruRef.current = nextLru; setMountedTabIds((previousMountedTabIds) => { const nextMountedTabIds = new Set(nextLru); return setsEqual(previousMountedTabIds, nextMountedTabIds) ? previousMountedTabIds : nextMountedTabIds; }); }, [activeTabId, availableTabIds, cap]); return { mountedTabIds }; } `; const AFTER = `import { useLayoutEffect, useMemo, useRef, useState } from "react"; interface UseMountedTabSetInput { activeTabId: string | null; allTabIds: string[]; cap: number; } interface UseMountedTabSetResult { mountedTabIds: Set; } interface DeriveRenderMountedTabIdsInput { activeTabId: string | null; availableTabIds: Set; cap: number; mountedTabIds: Set; } function createInitialMountedTabIds(input: UseMountedTabSetInput): Set { if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) { return new Set(); } return new Set([input.activeTabId]); } function setsEqual(left: Set, right: Set): boolean { if (left.size !== right.size) { return false; } for (const value of left) { if (!right.has(value)) { return false; } } return true; } function deriveRenderMountedTabIds(input: DeriveRenderMountedTabIdsInput): Set { const { activeTabId, availableTabIds, cap, mountedTabIds } = input; if (!activeTabId || !availableTabIds.has(activeTabId) || mountedTabIds.has(activeTabId)) { return mountedTabIds; } const next = new Set([activeTabId]); const maxSize = Math.max(1, cap); for (const tabId of mountedTabIds) { if (next.size >= maxSize) { break; } if (availableTabIds.has(tabId)) { next.add(tabId); } } return next; } export function useMountedTabSet(input: UseMountedTabSetInput): UseMountedTabSetResult { const { activeTabId, allTabIds, cap } = input; const allTabIdsKey = allTabIds.join("\\u0000"); const availableTabIds = useMemo(() => { void allTabIdsKey; return new Set(allTabIds); }, [allTabIds, allTabIdsKey]); const [mountedTabIds, setMountedTabIds] = useState(() => createInitialMountedTabIds(input)); const lruRef = useRef(activeTabId && allTabIds.includes(activeTabId) ? [activeTabId] : []); const renderMountedTabIds = useMemo( () => deriveRenderMountedTabIds({ activeTabId, availableTabIds, cap, mountedTabIds, }), [activeTabId, availableTabIds, cap, mountedTabIds], ); useLayoutEffect(() => { const nextLru = lruRef.current.filter((tabId) => availableTabIds.has(tabId)); if (activeTabId && availableTabIds.has(activeTabId)) { const existingIndex = nextLru.indexOf(activeTabId); if (existingIndex >= 0) { nextLru.splice(existingIndex, 1); } nextLru.unshift(activeTabId); } if (nextLru.length > cap) { nextLru.length = cap; } lruRef.current = nextLru; setMountedTabIds((previousMountedTabIds) => { const nextMountedTabIds = new Set(nextLru); return setsEqual(previousMountedTabIds, nextMountedTabIds) ? previousMountedTabIds : nextMountedTabIds; }); }, [activeTabId, availableTabIds, cap]); return { mountedTabIds: renderMountedTabIds }; } `; test.afterEach(async () => { for (const task of cleanupTasks.splice(0)) { await task.run(); } }); test("Changes opens All changes for a clean checkout", async ({ page }) => { const workspace = await createWorkspaceWithCommittedDiff(); await openWorkspaceChangesSurface(page, workspace, 90_000); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText("All changes"); await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0"); }); test("All changes combines committed and pending edits and preserves an unfinished review", async ({ page, context, }) => { const workspace = await createWorkspaceWithNetDiff(); await context.grantPermissions(["clipboard-read", "clipboard-write"]); await configureDiffPresentation(page, { layout: "unified", wrapLines: false }); await openWorkspaceChangesSurface(page, workspace); const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const mode = tree.getByRole("button", { name: "Diff mode", exact: true }); await expect(mode).toContainText("All changes"); await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts"]); await expectRenderedAddedLine(page, "committed"); await test.step("an unfinished review survives the first dirty edit", async () => { await startReviewOnFirstChangedLine(page); await page .getByRole("textbox", { name: "Review comment" }) .fill("Unfinished review stays here"); await writeFile(path.join(workspace.repoPath, "c-untracked.txt"), "untracked\n"); await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]); await expect(mode).toContainText("All changes"); await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue( "Unfinished review stays here", ); await cancelInlineReview(page); }); await test.step("pending edits form a live net patch over the committed edit", async () => { await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "working\n"); await expectRenderedAddedLine(page, "working"); await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("a-tracked.ts, +1, -1"); }); await test.step("committing the same contents preserves the rendered comparison", async () => { execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath }); execFileSync("git", ["commit", "-m", "Commit pending changes"], { cwd: workspace.repoPath }); await expect(page.getByTestId("changes-primary-cta")).toContainText("Merge", { timeout: 30_000, }); await expect(mode).toContainText("All changes"); await expectNetComparisonFiles(page, ["a-tracked.ts", "b-committed.ts", "c-untracked.txt"]); await expectRenderedAddedLine(page, "working"); }); await test.step("undoing to the comparison base removes the net change", async () => { await writeFile(path.join(workspace.repoPath, "a-tracked.ts"), "base\n"); await expectNetComparisonFiles(page, ["b-committed.ts", "c-untracked.txt"]); await expect(mode).toContainText("All changes"); }); }); test("Changes keeps a manual comparison when checkout dirtiness changes", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await openWorkspaceChanges(page, workspace); const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); const mode = tree.getByTestId("changes-diff-status-trigger"); await expect(mode).toContainText("All changes"); await mode.click(); await page.getByTestId("changes-diff-mode-committed").click(); await expect(mode).toContainText("Committed"); await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible(); execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath }); execFileSync("git", ["commit", "-m", "Commit working changes"], { cwd: workspace.repoPath }); await expect(mode).toContainText("Committed"); await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toHaveCount(0, { timeout: 30_000, }); await writeFile(path.join(workspace.repoPath, "new-working-change.txt"), "uncommitted\n"); await expect(tree.getByRole("button", { name: "See uncommitted changes" })).toBeVisible({ timeout: 30_000, }); await expect(mode).toContainText("Committed"); await selectChangesComparison(page, "Uncommitted"); execFileSync("git", ["add", "--all"], { cwd: workspace.repoPath }); execFileSync("git", ["commit", "-m", "Commit new working change"], { cwd: workspace.repoPath }); await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible({ timeout: 30_000, }); await expect(mode).toContainText("Uncommitted"); await writeFile(path.join(workspace.repoPath, "another-working-change.txt"), "pending\n"); await expect(tree.getByText("another-working-change.txt", { exact: true })).toBeVisible({ timeout: 30_000, }); await expect(mode).toContainText("Uncommitted"); }); test("an empty Changes comparison links to the populated comparison", async ({ page }) => { const workspace = await createWorkspaceWithCommittedDiff(); await openWorkspaceChangesSurface(page, workspace); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); const mode = tree.getByTestId("changes-diff-status-trigger"); await mode.click(); await page.getByTestId("changes-diff-mode-committed").click(); await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0"); await mode.click(); await page.getByTestId("changes-diff-mode-uncommitted").click(); await expect(tree.getByText("No changes to display", { exact: true })).toBeVisible(); await expect(panel.getByText("No changes to display", { exact: true })).toBeVisible(); const seeCommitted = tree.getByRole("button", { name: "See committed changes" }); await expect(seeCommitted).toBeVisible(); await seeCommitted.click(); await expect(mode).toContainText("Committed"); await expect(panel.getByTestId("diff-file-0")).toHaveAccessibleName("committed-only.ts, +1, -0"); }); test("Changes comparison controls the working diff and tree selection focuses its file", async ({ page, }) => { const workspace = await createWorkspaceWithScrollableComparisons(); await openWorkspaceChangesSurface(page, workspace); await selectChangesComparison(page, "Committed"); await expectWorkingComparisonFiles(page, "committed"); await selectChangedFileAndExpectFocused(page, "committed/50-target.ts"); await selectChangesComparison(page, "Uncommitted"); await expectWorkingComparisonFiles(page, "uncommitted"); await selectChangedFileAndExpectFocused(page, "uncommitted/50-target.ts"); }); test("changes file actions open below the right-click without a reserved kebab", async ({ page, }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const deletedFileName = page.getByTestId("diff-file-1"); await expect(deletedFileName).toHaveAccessibleName(/zz-deleted\.ts/); await deletedFileName.dblclick(); expect(await page.evaluate(() => window.getSelection()?.toString() ?? "")).toBe(""); await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0); await page.getByTestId("diff-file-1-toggle").click({ button: "right" }); await expect(page.getByText("Copy path")).toBeVisible(); await page.getByText("Copy path", { exact: true }).click({ button: "right" }); await expect(page.getByText("Copy path")).toBeVisible(); await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0); await page.keyboard.press("Escape"); const fileRow = page.getByTestId("diff-file-0-toggle"); const fileRowBounds = await fileRow.boundingBox(); expect(fileRowBounds).not.toBeNull(); await fileRow.click({ button: "right", position: { x: 80, y: 10 } }); await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible(); const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox(); expect(menuBounds).not.toBeNull(); expect(Math.abs(menuBounds!.x - (fileRowBounds!.x + 80))).toBeLessThanOrEqual(1); expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10); await page.getByTestId("diff-file-0-open-file").click(); await expect(page.getByTestId("workspace-file-pane")).toBeVisible(); await expect(page.getByTestId("workspace-tab-file_src/use-mounted-tab-set.ts")).toBeVisible(); }); test("canvas file headers select without toggling for context menu and long press", async ({ page, }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const firstFile = page.getByTestId("diff-file-0-toggle"); const deletedFile = page.getByTestId("diff-file-1-toggle"); await deletedFile.click({ button: "right" }); await expect(deletedFile).toHaveAttribute("aria-selected", "true"); await expect(deletedFile).toHaveAttribute("aria-expanded", "true"); await expect(page.getByText("Copy path")).toBeVisible(); await page.keyboard.press("Escape"); await longPressFileHeader(page, firstFile); await expect(firstFile).toHaveAttribute("aria-selected", "true"); await expect(deletedFile).toHaveAttribute("aria-selected", "false"); await expect(firstFile).toHaveAttribute("aria-expanded", "true"); }); test("every interactive file header has the same hover feedback", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const first = page.getByTestId("diff-file-0-toggle"); const second = page.getByTestId("diff-file-1-toggle"); const firstCanvas = page.getByTestId("git-diff-sticky-header-0"); const secondCanvas = page.getByTestId("git-diff-sticky-header-1"); const normalBackground = await headerCanvasPixel(firstCanvas, first, 10); await first.hover(); await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).not.toBe(normalBackground); const hoverBackground = await headerCanvasPixel(firstCanvas, first, 10); await first.click(); await page.mouse.move(0, 0); await expect(first).toHaveAttribute("aria-expanded", "false"); await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground); await first.hover(); await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(hoverBackground); await second.hover(); await expect.poll(() => headerCanvasPixel(firstCanvas, first, 10)).toBe(normalBackground); await expect.poll(() => headerCanvasPixel(secondCanvas, second, 10)).toBe(hoverBackground); }); test("horizontal body scrolling never moves or repaints the canvas header", async ({ page }) => { const wideLine = `export const wide = "${"wide".repeat(160)}";`; const workspace = await createWorkspaceWithExactSelectionDiff(wideLine); await useUnwrappedDiffLines(page); await openSelectionWorkspaceChanges(page, workspace); const headerCanvas = page.getByTestId("git-diff-canvas"); const header = page.getByTestId("diff-file-0-toggle"); const beforePixel = await headerCanvasPixel(headerCanvas, header, 10); const beforeBounds = await header.boundingBox(); await horizontallyScrollFirstFile(page, 320); await page.waitForTimeout(50); expect(await headerCanvasPixel(headerCanvas, header, 10)).toBe(beforePixel); expect(await header.boundingBox()).toEqual(beforeBounds); }); test("in-flow file headers move with the diff document", async ({ page }) => { const workspace = await createWorkspaceWithStickyTransitionDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChangesSurface(page, workspace); const motion = await measureInFlowHeaderMotion(page); expect(motion).toEqual({ headerSurface: -120, shell: -120 }); }); test("the outgoing sticky header hands off without a gap or overlap", async ({ page, }, testInfo) => { const workspace = await createWorkspaceWithStickyTransitionDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChangesSurface(page, workspace); const scroller = page.getByTestId("git-diff-scroll"); const first = page.getByTestId("diff-file-0-toggle"); const second = page.getByTestId("diff-file-1-toggle"); const secondTop = await second.evaluate((element) => { const scrollElement = element.closest('[data-testid="git-diff-scroll"]'); if (!scrollElement) return Number.NaN; return ( scrollElement.scrollTop + element.getBoundingClientRect().top - scrollElement.getBoundingClientRect().top ); }); expect(secondTop).toBeGreaterThan(30); await scroller.evaluate((element, scrollTop) => { element.scrollTop = scrollTop; element.dispatchEvent(new Event("scroll", { bubbles: false })); }, secondTop - 15); await expect(first).toBeAttached(); await expect(second).toBeAttached(); const [scrollBounds, firstBounds, secondBounds] = await Promise.all([ scroller.boundingBox(), first.boundingBox(), second.boundingBox(), ]); if (!scrollBounds || !firstBounds || !secondBounds) { throw new Error("Sticky transition geometry is unavailable"); } expect(firstBounds.y - scrollBounds.y).toBeCloseTo(-15, 0); expect(secondBounds.y - scrollBounds.y).toBeCloseTo(15, 0); const transitionHits = await scroller.evaluate((element) => { const bounds = element.getBoundingClientRect(); return [14, 16].map( (y) => document .elementFromPoint(bounds.left + bounds.width / 2, bounds.top + y) ?.closest("[data-diff-header]")?.dataset.diffHeaderPath ?? null, ); }); expect(transitionHits).toEqual(["src/first.ts", "src/second.ts"]); await testInfo.attach("canvas-header-sticky-handoff", { body: await page.getByTestId("git-diff-canvas-root").screenshot(), contentType: "image/png", }); await scroller.evaluate((element, scrollTop) => { element.scrollTop = scrollTop; element.dispatchEvent(new Event("scroll", { bubbles: false })); }, secondTop); await expect .poll(async () => { const bounds = await second.boundingBox(); return bounds ? bounds.y - scrollBounds.y : Number.NaN; }) .toBeCloseTo(0, 0); }); test("changes context menus duplicate files and folders", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); await page.getByTestId("diff-file-0-duplicate").click(); await expect .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src/use-mounted-tab-set copy.ts"))) .toBe(AFTER); await changesTree(page).getByTestId("diff-folder-src-toggle").click({ button: "right" }); await page.getByTestId("diff-folder-src-duplicate").click(); await expect .poll(() => readFileIfPresent(path.join(workspace.repoPath, "src copy/use-mounted-tab-set.ts"))) .toBe(AFTER); }); test("changes tree aligns every file status after its diff stat", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true, includeUntrackedFile: true, }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const tree = changesTree(page); const modifiedRow = tree.getByTestId("diff-tree-file-0"); const deletedRow = tree.getByTestId("diff-tree-file-1"); const addedRow = tree.getByTestId("diff-tree-file-2"); const modifiedStatus = modifiedRow.getByRole("img", { name: "Modified" }); await expect(modifiedStatus).toBeVisible(); await expect(deletedRow.getByRole("img", { name: "Deleted" })).toBeVisible(); await expect(addedRow.getByRole("img", { name: "New" })).toBeVisible(); const [statBounds, statusBounds] = await Promise.all([ modifiedRow.getByTestId("diff-tree-file-0-stat").boundingBox(), modifiedStatus.boundingBox(), ]); if (!statBounds || !statusBounds) throw new Error("Changes tree trailing status has no bounds"); expect(statusBounds.x - (statBounds.x + statBounds.width)).toBeGreaterThanOrEqual(8); }); test("the scrolling diff lists files in changes tree order", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true, includeRootFileSortingFirst: true, }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const tree = changesTree(page); const treeNames = tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]'); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const diffHeaders = panel.getByTestId(/^diff-file-\d+$/); // Directories sort before files at every level, so the root note lands last // even though "a-root-note.txt" compares below "src/..." as a whole string. const expected = ["changed.ts", "root.ts", "use-mounted-tab-set.ts", "a-root-note.txt"]; // Equal counts also prove no folder is collapsed: flattenDiffTree drops the // descendants of a collapsed folder, which would make the tree a subsequence // of the diff rather than a match, and mask a real ordering difference. await expect(treeNames).toHaveCount(expected.length); await expect(diffHeaders).toHaveCount(expected.length); await expect(treeNames).toHaveText(expected); const expectedHeaderNames = [ "src/zz-folder/nested/changed.ts, +1, -1", "src/zz-folder/root.ts, +1, -1", "src/use-mounted-tab-set.ts, +37, -1", "a-root-note.txt, +1, -0", ]; for (const [index, accessibleName] of expectedHeaderNames.entries()) { await expect(diffHeaders.nth(index)).toHaveAccessibleName(accessibleName); } }); test("changes context menu recursively collapses descendant folders", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const tree = changesTree(page); await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); const rootRow = tree.getByTestId("diff-folder-src-toggle"); const rootLabel = tree.getByTestId("diff-folder-src-toggle").getByText("src", { exact: true }); await expect(rootRow).toHaveCSS("opacity", "1"); await expect(rootLabel).toHaveCSS("opacity", "0.76"); await rootRow.hover(); await expect(rootLabel).toHaveCSS("opacity", "1"); await page.mouse.move(0, 0); const nestedLabel = tree .getByTestId("diff-folder-src/zz-folder-toggle") .getByText("zz-folder", { exact: true }); const [rootBounds, nestedBounds] = await Promise.all([ rootLabel.boundingBox(), nestedLabel.boundingBox(), ]); if (!rootBounds || !nestedBounds) throw new Error("Changes tree rows have no bounds"); expect(nestedBounds.x - rootBounds.x).toBe(12); await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); await page.getByTestId("diff-folder-src-collapse-folder").click(); await expect(tree.getByTestId("diff-folder-src/zz-folder")).toHaveCount(0); await tree.getByTestId("diff-folder-src-toggle").click(); await expect(tree.getByTestId("diff-folder-src/zz-folder")).toBeVisible(); await expect(tree.getByText("root.ts", { exact: true })).toHaveCount(0); await tree.getByTestId("diff-folder-src/zz-folder-toggle").click(); await expect(tree.getByText("root.ts", { exact: true })).toBeVisible(); await expect(tree.getByTestId("diff-folder-src/zz-folder/nested")).toBeVisible(); await expect(tree.getByText("changed.ts", { exact: true })).toHaveCount(0); await tree.getByTestId("diff-folder-src/zz-folder/nested-toggle").click(); await expect(tree.getByText("changed.ts", { exact: true })).toBeVisible(); }); test("changes context menus expose folder revert and restore a file after confirmation", async ({ page, }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); await selectChangesComparison(page, "Uncommitted"); const tree = changesTree(page); await tree.getByTestId("diff-folder-src-toggle").click({ button: "right" }); const folderRevert = page.getByTestId("diff-folder-src-revert"); await expect(folderRevert).toBeVisible(); const revertLabelColor = await folderRevert .getByText("Discard changes", { exact: true }) .evaluate((element) => getComputedStyle(element).color); await expect(folderRevert.locator("svg")).toHaveCSS("stroke", revertLabelColor); await page.keyboard.press("Escape"); await tree.getByTestId("diff-tree-file-0-toggle").click({ button: "right" }); const cancelledConfirmation = new Promise((resolve) => { page.once("dialog", async (dialog) => { const message = dialog.message(); await dialog.dismiss(); resolve(message); }); }); await page.getByTestId("diff-tree-file-0-revert").click(); expect(await cancelledConfirmation).toContain("src/use-mounted-tab-set.ts"); await expect(tree.getByTestId("diff-tree-file-0")).toBeVisible(); await expect .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .toBe(AFTER); await tree.getByTestId("diff-tree-file-0-toggle").click({ button: "right" }); const confirmation = new Promise((resolve) => { page.once("dialog", async (dialog) => { const message = dialog.message(); await dialog.accept(); resolve(message); }); }); await page.getByTestId("diff-tree-file-0-revert").click(); expect(await confirmation).toContain("src/use-mounted-tab-set.ts"); await expect(tree.getByTestId("diff-tree-file-0")).toHaveCount(0, { timeout: 30_000 }); await expect .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .toBe(BEFORE); }); test("discarding a staged rename restores its source path", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeRenamedFile: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); await selectChangesComparison(page, "Uncommitted"); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts"); await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0"); const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/); const toggleTestId = await renamedToggle.getAttribute("data-testid"); expect(toggleTestId).not.toBeNull(); const rowTestId = toggleTestId!.slice(0, -"-toggle".length); await renamedToggle.click({ button: "right" }); const confirmation = new Promise((resolve) => { page.once("dialog", async (dialog) => { await dialog.accept(); resolve(); }); }); await page.getByTestId(`${rowTestId}-revert`).click(); await confirmation; await expect(page.getByText("zz-renamed.ts", { exact: true })).toHaveCount(0, { timeout: 30_000, }); await expect .poll(() => readFile(path.join(workspace.repoPath, "src/rename-source.ts"), "utf8")) .toBe("export const renamed = true;\n"); }); test("discarding an untracked file removes it from the working tree", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeUntrackedFile: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); await selectChangesComparison(page, "Uncommitted"); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const untrackedHeader = diffHeaderForPath(panel, "zz-untracked.txt"); await expect(untrackedHeader).toHaveAccessibleName("zz-untracked.txt, +1, -0"); const untrackedToggle = untrackedHeader.getByTestId(/^diff-file-\d+-toggle$/); const toggleTestId = await untrackedToggle.getAttribute("data-testid"); expect(toggleTestId).not.toBeNull(); const rowTestId = toggleTestId!.slice(0, -"-toggle".length); await untrackedToggle.click({ button: "right" }); const confirmation = new Promise((resolve) => { page.once("dialog", async (dialog) => { await dialog.accept(); resolve(); }); }); await page.getByTestId(`${rowTestId}-revert`).click(); await confirmation; await expect(page.getByText("zz-untracked.txt", { exact: true })).toHaveCount(0, { timeout: 30_000, }); await expect( readFile(path.join(workspace.repoPath, "zz-untracked.txt"), "utf8"), ).rejects.toThrow(); }); test("shows a revert error returned by the daemon", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await failNextDiscardRequest(page); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); await selectChangesComparison(page, "Uncommitted"); await page.getByTestId("diff-file-0-toggle").click({ button: "right" }); const confirmation = new Promise((resolve) => { page.once("dialog", async (dialog) => { await dialog.accept(); resolve(); }); }); await page.getByTestId("diff-file-0-revert").click(); await confirmation; await expect(page.getByText("Injected revert failure", { exact: true })).toBeVisible({ timeout: 30_000, }); await expect(page.getByTestId("diff-file-0")).toBeVisible(); await expect .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .toBe(AFTER); }); test("Changes keeps review navigation and controls inside its workspace tab", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true }); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const visiblePanel = page.getByTestId("working-diff-panel").filter({ visible: true }); await expect(visiblePanel).toBeVisible(); await expect(visiblePanel.getByTestId("changes-repository-header")).toHaveCount(0); await expect(visiblePanel.getByTestId("changes-branch-switcher")).toHaveCount(0); await expect(visiblePanel.getByTestId("changes-diff-status-trigger")).toHaveCount(0); await expect(visiblePanel.getByTestId("changes-selected-diff-stat")).toHaveCount(0); await expect(visiblePanel.getByTestId("changes-header")).toHaveCount(1); await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName( "src/use-mounted-tab-set.ts, +37, -1", ); await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName( "src/zz-deleted.ts, +0, -1", ); await expect(visiblePanel.getByTestId("changes-primary-cta")).toHaveCount(0); await expect(page.getByTestId("changes-primary-cta")).toHaveCount(1); await expect(page.getByTestId("changes-primary-cta")).toContainText("Commit"); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); await visiblePanel.getByTestId("diff-file-0-toggle").click(); await expect(visiblePanel.getByTestId("diff-file-0-body")).not.toBeVisible(); await expect(visiblePanel.getByRole("button", { name: "Diff options" })).toHaveCount(0); await expect(page.getByTestId("changes-open-tab")).toHaveCount(0); const collapseFiles = visiblePanel.getByTestId("changes-toggle-collapse-all"); await expect(collapseFiles).toHaveAttribute("aria-label", "Collapse all files"); await collapseFiles.click(); await expect(collapseFiles).toHaveAttribute("aria-label", "Expand all files"); await collapseFiles.click(); await expect(visiblePanel.getByTestId("diff-file-0-body")).toBeVisible(); const layout = visiblePanel.getByTestId("changes-toggle-layout"); await expect(layout).toHaveAttribute("aria-label", "Switch to side-by-side diff"); await layout.click(); await expect(layout).toHaveAttribute("aria-label", "Switch to unified diff"); const whitespace = visiblePanel.getByTestId("changes-toggle-whitespace"); await expect(whitespace).toHaveAttribute("aria-label", "Hide whitespace"); const wrapLines = visiblePanel.getByTestId("changes-toggle-wrap-lines"); await expect(wrapLines).toHaveAttribute("aria-label", "Wrap long lines"); await expect(visiblePanel.getByTestId("changes-refresh")).toHaveAttribute( "aria-label", "Refresh", ); await wrapLines.click(); await expect(wrapLines).toHaveAttribute("aria-label", "Scroll long lines"); await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1); await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), BEFORE); await expect( visiblePanel.locator('[data-diff-header-path="src/use-mounted-tab-set.ts"]'), ).toHaveCount(0, { timeout: 30_000 }); await expect(visiblePanel.locator('[data-diff-header-path="src/zz-deleted.ts"]')).toBeVisible(); await writeFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), AFTER); await expect(diffHeaderForPath(visiblePanel, "src/use-mounted-tab-set.ts")).toHaveAccessibleName( "src/use-mounted-tab-set.ts, +37, -1", { timeout: 30_000 }, ); await expect(diffHeaderForPath(visiblePanel, "src/zz-deleted.ts")).toHaveAccessibleName( "src/zz-deleted.ts, +0, -1", ); }); test("compact Changes keeps its actions compact and menu-only", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); await page.setViewportSize({ width: 480, height: 900 }); // The shell survives the breakpoint, so the wide Explorer can still be on screen right after the // resize. Check for the compact tab only once the shell reports the compact layout. await expect(page.getByTestId("compact-shell")).toBeVisible(); const compactChangesTab = page .getByTestId("workspace-tab-changes_tree") .filter({ visible: true }); if (!(await compactChangesTab.isVisible())) { await page.getByTestId("workspace-explorer-toggle").first().click(); } await expect(compactChangesTab).toBeVisible(); await compactChangesTab.click(); const compactExplorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); await expect(compactExplorer.getByTestId("changes-header")).toBeVisible(); const actions = compactExplorer.getByTestId("changes-actions-menu-trigger"); const options = compactExplorer.getByRole("button", { name: "Diff options" }); const [actionsBox, optionsBox, glyphBox] = await Promise.all([ actions.boundingBox(), options.boundingBox(), options.locator("svg").boundingBox(), ]); if (!actionsBox || !optionsBox || !glyphBox) { throw new Error("Compact Changes toolbar geometry could not be measured"); } expect(actionsBox.width).toBe(48); expect(actionsBox.height).toBe(28); expect(optionsBox.width).toBe(32); expect(optionsBox.height).toBe(32); expect(glyphBox.width).toBe(18); expect(glyphBox.height).toBe(18); await expect(actions).not.toContainText("Commit"); await expect(actions.locator("svg")).toHaveCount(2); await actions.click(); await expect(page.getByTestId("changes-primary-cta-menu")).toBeVisible(); await expect(page.getByTestId("changes-menu-commit")).toContainText("Commit"); await page.keyboard.press("Escape"); await options.click(); const wrapLines = page.getByText("Wrap long lines", { exact: true }); await expect(wrapLines).toBeVisible(); await wrapLines.click(); await options.click(); await expect( page.getByTestId("changes-options-menu-content").getByTestId("changes-toggle-wrap-lines"), ).toContainText("Scroll long lines"); }); test("compact Explorer renders inline hunks with a saved tree-only desktop preference", async ({ page, }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await page.addInitScript( ({ key }) => { localStorage.setItem(key, JSON.stringify({ inlineDiff: false, layout: "split" })); }, { key: CHANGES_PREFERENCES_KEY }, ); const explorer = await openCompactChanges(page, workspace); await explorer.getByRole("button", { name: "Diff mode", exact: true }).click(); await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click(); await expect(explorer.getByTestId("git-diff-canvas")).toBeVisible(); await expect(explorer.getByTestId("diff-file-0-toggle")).toBeVisible(); await explorer.getByTestId("diff-file-0-toggle").click({ button: "right" }); page.once("dialog", (dialog) => dialog.accept()); await page.getByTestId("diff-file-0-revert").click(); await expect .poll(() => readFile(path.join(workspace.repoPath, "src/use-mounted-tab-set.ts"), "utf8")) .toBe(BEFORE); }); test("compact Changes jumps to a file from the changed-files sheet", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff({ includeNestedFolders: true }); await useUnwrappedDiffLines(page); const explorer = await openCompactChanges(page, workspace); await openChangedFilesOverview(page); await test.step("reopening the overview expands folders again", async () => { await expectOverviewReopensExpanded(page, "zz-folder", "changed.ts"); }); await test.step("jumping to a nested file closes the overview and scrolls to its diff", async () => { await jumpToOverviewFile(page, explorer, "src/zz-folder/nested/changed.ts"); }); }); test("Jump to file stays out of the desktop diff and of an empty comparison", async ({ page }) => { const committed = await createWorkspaceWithCommittedDiff(); await openWorkspaceChangesSurface(page, committed, 90_000); await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0); const explorer = await openCompactChanges(page, committed); await expect(explorer.getByRole("button", { name: "Jump to file" })).toBeVisible(); await selectEmptyUncommittedComparison(page, explorer); await expect(page.getByRole("button", { name: "Jump to file" })).toHaveCount(0); }); test("canvas diff stays sharp while its workspace pane is resized", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const canvas = page.getByTestId("git-diff-canvas"); const root = page.getByTestId("git-diff-canvas-root"); const handle = page .getByTestId("workspace-explorer-sidebar-resize-handle") .getByRole("separator"); await expect(handle).toBeVisible(); await expect .poll(async () => { const [canvasWidth, rootWidth] = await Promise.all([ canvas.evaluate((element) => (element as HTMLCanvasElement).getBoundingClientRect().width), root.evaluate((element) => element.getBoundingClientRect().width), ]); return Math.abs(canvasWidth - rootWidth) < 1; }) .toBe(true); const [handleBounds, before] = await Promise.all([ handle.boundingBox(), canvas.evaluate((element) => { const canvasElement = element as HTMLCanvasElement; return { width: canvasElement.getBoundingClientRect().width, ratio: window.devicePixelRatio || 1, }; }), ]); if (!handleBounds) throw new Error("Explorer sidebar resize handle has no bounds"); await page.mouse.move(handleBounds.x + handleBounds.width / 2, handleBounds.y + 120); await page.mouse.down(); await page.mouse.move(handleBounds.x + 120, handleBounds.y + 120); await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => resolve()))); const duringDrag = await Promise.all([ canvas.evaluate((element) => (element as HTMLCanvasElement).getBoundingClientRect().width), root.evaluate((element) => element.getBoundingClientRect().width), ]); expect(duringDrag[0]).toBeCloseTo(before.width, 0); expect(duringDrag[1]).toBeGreaterThan(before.width + 10); await page.mouse.up(); const resizeFrames = await page.evaluate(async () => { const canvasElement = document.querySelector( '[data-testid="git-diff-canvas"]', )!; const ratio = window.devicePixelRatio || 1; const frames: Array<{ cssWidth: number; bitmapWidth: number }> = []; for (let index = 0; index < 10; index += 1) { await new Promise((resolve) => requestAnimationFrame(() => resolve())); frames.push({ cssWidth: canvasElement.getBoundingClientRect().width, bitmapWidth: canvasElement.width / ratio, }); } return frames; }); for (const frame of resizeFrames) { expect(Math.abs(frame.cssWidth - frame.bitmapWidth)).toBeLessThan(1); } await expect .poll(async () => { const [canvasWidth, rootWidth, backingWidth] = await Promise.all([ canvas.evaluate((element) => (element as HTMLCanvasElement).getBoundingClientRect().width), root.evaluate((element) => element.getBoundingClientRect().width), canvas.evaluate((element) => (element as HTMLCanvasElement).width), ]); return ( Math.abs(canvasWidth - rootWidth) < 1 && Math.abs(backingWidth / before.ratio - rootWidth) < 1 ); }) .toBe(true); }); test("changes diff waits for configured fonts, then applies code typography changes", async ({ page, }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useCodeFont(page, 12); await useUnwrappedDiffLines(page); await holdBrowserFontLoads(page); await navigateToWorkspaceChanges(page, workspace); await test.step("commit no geometry before the configured fonts are ready", async () => { await expect(page.getByTestId("git-diff-canvas")).toBeVisible(); await expect(page.getByTestId("diff-file-0-body")).toHaveCount(0); await releaseBrowserFontLoads(page); await expectExpandedMountedTabDiff(page); }); const before = await readDiffTypographyGeometry(page); await changeCodeTypographyFromSettings(page, { fontSize: 18, fontFamily: "Courier New, Courier, monospace", }); await returnToWorkspaceChanges(page); await expectStoredCodeFontSize(page, 18); await scrollToLowerUnwrappedDiffRows(page); await expectDiffCodeFontSize(page, 18); await expectDiffCodeFontFamily(page, "Courier"); await expectVisibleDiffRowsShareTypography(page); const after = await readDiffTypographyGeometry(page); expect(after.horizontalExtent).toBeGreaterThan(before.horizontalExtent); expect(after.canvasPixels).not.toEqual(before.canvasPixels); }); test("creates, cancels, edits, and deletes a review while keeping Changes focused", async ({ page, }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const changesTab = page.getByTestId("workspace-tab-working_diff").filter({ visible: true }); const focusedBackground = await changesTab.evaluate( (element) => getComputedStyle(element).backgroundColor, ); await test.step("start a focused review with the browser text menu", async () => { await startReviewOnFirstChangedLine(page); await expect(page.getByTestId("inline-review-editor-input")).toBeFocused(); await expect(changesTab).toHaveCSS("background-color", focusedBackground); await page.getByTestId("inline-review-editor-input").click({ button: "right" }); await expect(page.getByTestId("diff-source-context-menu")).toHaveCount(0); await cancelInlineReview(page); }); await test.step("save, edit, and delete the review", async () => { await startReviewOnFirstChangedLine(page); await saveInlineReview(page, "Please keep this branch explicit"); await editInlineReview(page, "Please keep this branch named explicitly"); await deleteInlineReview(page); }); }); test("split canvas creates a review on the changed side and keeps it in that column", async ({ page, }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await configureDiffPresentation(page, { layout: "split", wrapLines: false }); await openWorkspaceChanges(page, workspace); await setOpenChangesPresentation(page, { layout: "split", wrapLines: false }); await startReviewOnFirstChangedLine(page, "right"); const [editor, body] = await Promise.all([ page.getByTestId("inline-review-editor").boundingBox(), page.getByTestId("diff-file-0-body").boundingBox(), ]); expect(editor).not.toBeNull(); expect(body).not.toBeNull(); expect(editor!.x).toBeGreaterThanOrEqual(body!.x + body!.width / 2); await cancelInlineReview(page); }); test("scrolling clears the hovered review affordance", async ({ page }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); await hoverFirstChangedGutter(page); await expect(page.getByRole("button", { name: "Add review comment" })).toBeVisible(); await page.getByTestId("git-diff-scroll").evaluate((element) => { element.scrollTop += 80; element.dispatchEvent(new Event("scroll", { bubbles: false })); }); await expect(page.getByRole("button", { name: "Add review comment" })).toHaveCount(0); }); test("canvas diff uses the overlay scrollbar and its thumb controls vertical scrolling", async ({ page, }) => { const lines = Array.from({ length: 240 }, (_, index) => `export const line${index} = ${index};`); const workspace = await createWorkspaceWithExactSelectionDiff(lines.join("\n")); await openSelectionWorkspaceChanges(page, workspace); const root = page.getByTestId("git-diff-canvas-root"); const scroller = page.getByTestId("git-diff-scroll"); const grab = root.getByTestId("workspace-overlay-scrollbar-grab"); await expect(grab).toBeVisible(); await expect(scroller).toHaveCSS("scrollbar-width", "none"); const bounds = await grab.boundingBox(); if (!bounds) throw new Error("Diff overlay scrollbar thumb has no bounds"); await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await page.mouse.down(); await expect(grab).toHaveCSS("cursor", "grabbing"); await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 180); await page.mouse.up(); await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); }); test("canvas headers keep a many-file diff bounded while scrolling end to end", async ({ page, }) => { test.setTimeout(180_000); const workspace = await createWorkspaceWithManyTinyDiffs(2_000); await useUnwrappedDiffLines(page); await openWorkspaceChangesSurface(page, workspace, 90_000); const root = page.getByTestId("git-diff-canvas-root"); const scroller = page.getByTestId("git-diff-scroll"); await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2); await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120); await scroller.evaluate((element) => { element.scrollTop = element.scrollHeight - element.clientHeight; element.dispatchEvent(new Event("scroll", { bubbles: false })); }); await expect(page.getByTestId("diff-file-1999")).toBeVisible(); await expect.poll(() => root.locator('[data-diff-header="true"]').count()).toBeLessThan(120); await expect(root.locator('[data-testid^="git-diff-sticky-header-"]')).toHaveCount(2); }); test("the whole reviewable row reveals the gutter affordance and uses a text cursor", async ({ page, }) => { const workspace = await createWorkspaceWithMountedTabDiff(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); const body = page.getByTestId("diff-file-0-body"); const canvas = page.getByTestId("git-diff-canvas"); const [bodyBounds, fontSize] = await Promise.all([ body.boundingBox(), canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)), ]); if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(fontSize * 1.5); await page.mouse.move(bodyBounds.x + bodyBounds.width - 32, bodyBounds.y + lineHeight * 1.5); const affordance = page.getByRole("button", { name: "Add review comment" }); await expect(affordance).toBeVisible(); await expect(affordance.locator("svg")).toBeVisible(); const affordanceBounds = await affordance.boundingBox(); expect(affordanceBounds?.width).toBeCloseTo(22, 0); expect(affordanceBounds?.height).toBeCloseTo(22, 0); await expect(affordance).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)"); await expect(page.getByTestId("git-diff-scroll")).toHaveCSS("cursor", "text"); }); test("selects, preserves, copies, and dismisses diff text without opening a review", async ({ page, }) => { await openCopyableSelectionDiff(page, "ABCDEFGHIJ"); await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }); await test.step("copy the selected characters with the keyboard", async () => { await copyDiffSelectionWithKeyboard(page); await expectClipboardText(page, "CDEFGH"); await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); }); await test.step("copy the preserved selection after resizing", async () => { await resizeDiffViewportHeight(page, 960); await copyFromChangedLineMenu(page, "Copy"); await expectClipboardText(page, "CDEFGH"); }); await test.step("copy the complete source line", async () => { await copyFromChangedLineMenu(page, "Copy line"); await expectClipboardText(page, "ABCDEFGHIJ"); }); await test.step("dismiss a new selection without starting a review", async () => { await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }); await clickFirstChangedLine(page); await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); await rightClickFirstChangedLine(page); await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled(); await page.keyboard.press("Escape"); await clickFirstChangedLine(page); await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); }); }); test("canvas diff clears a selection when collapsing an earlier file", async ({ page }) => { const workspace = await createWorkspaceWithTwoSelectionDiffs(); await useUnwrappedDiffLines(page); await openWorkspaceChanges(page, workspace); await dragExactAddedText(page, { startOffset: 2, endOffset: 8 }, 1); await page.getByTestId("diff-file-0-toggle").click(); await rightClickFirstChangedLine(page, 1); await expect(page.getByTestId("diff-source-copy-selection")).toBeDisabled(); }); test("canvas diff replaces a selection with forward and backward drags", async ({ context, page, }) => { const workspace = await createWorkspaceWithExactSelectionDiff("ABCDE\nFGHIJ"); await context.grantPermissions(["clipboard-read", "clipboard-write"]); await useUnwrappedDiffLines(page); await openSelectionWorkspaceChanges(page, workspace); for (const [start, end, expectedText] of [ [{ line: 0, offset: 2 }, { line: 1, offset: 3 }, "CDE\nFGH"], [{ line: 1, offset: 5 }, { line: 0, offset: 3 }, "DE\nFGHIJ"], ] as const) { await dragAddedTextRange(page, { lines: ["ABCDE", "FGHIJ"], start, end }); await page.keyboard.press("ControlOrMeta+C"); await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(expectedText); } }); test("canvas diff copies only the selected split side", async ({ context, page }) => { const lines = ["RIGHT-ONE", "RIGHT-TWO"]; const workspace = await createWorkspaceWithExactSelectionDiff(lines.join("\n")); await context.grantPermissions(["clipboard-read", "clipboard-write"]); await configureDiffPresentation(page, { layout: "split", wrapLines: false }); await openSelectionWorkspaceChanges(page, workspace); await setOpenChangesPresentation(page, { layout: "split", wrapLines: false }); const before = await readSelectionPaintSamples(page, "right"); await dragAddedTextRange(page, { lines, side: "right", start: { line: 0, offset: 1 }, end: { line: 1, offset: 5 }, }); await page.keyboard.press("ControlOrMeta+C"); await expect .poll(() => page.evaluate(() => navigator.clipboard.readText())) .toBe("IGHT-ONE\nRIGHT"); const after = await readSelectionPaintSamples(page, "right"); expect(after.gutter).toEqual(before.gutter); expect(after.opposite).toEqual(before.opposite); expect(after.code).not.toEqual(before.code); }); test("canvas diff copies exact wrapped fragments", async ({ context, page }) => { const content = "abcdefghijklmnopqrstuvwxyz".repeat(8); const workspace = await createWorkspaceWithExactSelectionDiff(content); await context.grantPermissions(["clipboard-read", "clipboard-write"]); await configureDiffPresentation(page, { layout: "unified", wrapLines: true }); await openSelectionWorkspaceChanges(page, workspace); await setOpenChangesPresentation(page, { layout: "unified", wrapLines: true }); await dragAddedTextRange(page, { lines: [content], wrapped: true, start: { line: 0, offset: 35 }, end: { line: 0, offset: 95 }, }); await page.keyboard.press("ControlOrMeta+C"); await expect .poll(() => page.evaluate(() => navigator.clipboard.readText())) .toBe(content.slice(35, 95)); }); test("horizontally scrolled selection copies exactly and does not paint the gutter", async ({ context, page, }) => { const content = "0123456789".repeat(50); const workspace = await createWorkspaceWithExactSelectionDiff(content); await context.grantPermissions(["clipboard-read", "clipboard-write"]); await useUnwrappedDiffLines(page); await openSelectionWorkspaceChanges(page, workspace); const horizontalOffset = await horizontallyScrollFirstFile(page, 320); const before = await readSelectionPaintSamples(page); await dragAddedTextRange(page, { lines: [content], horizontalOffset, start: { line: 0, offset: 48 }, end: { line: 0, offset: 58 }, }); await page.keyboard.press("ControlOrMeta+C"); await expect .poll(() => page.evaluate(() => navigator.clipboard.readText())) .toBe(content.slice(48, 58)); const after = await readSelectionPaintSamples(page); expect(after.gutter).toEqual(before.gutter); expect(after.code).not.toEqual(before.code); }); test("dragging within one wide grapheme or outside its cell never opens a review", async ({ page, }) => { const workspace = await createWorkspaceWithExactSelectionDiff("πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦tail"); await useCodeFont(page, 40); await useUnwrappedDiffLines(page); await openSelectionWorkspaceChanges(page, workspace); await dragWithinFirstAddedGrapheme(page); await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); await dragFirstAddedLineIntoHeader(page); await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); const body = await page.getByTestId("diff-file-0-body").boundingBox(); if (!body) throw new Error("Expanded diff body has no bounds"); await page.mouse.click(body.x + 60, body.y + 90, { button: "right" }); await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); }); test("collapsing and expanding restores the real horizontal scroll offset", async ({ page }) => { const workspace = await createWorkspaceWithExactSelectionDiff("x".repeat(400)); await useUnwrappedDiffLines(page); await openSelectionWorkspaceChanges(page, workspace); const retainedOffset = await horizontallyScrollFirstFile(page, 320); await page.getByTestId("diff-file-0-toggle").click(); await expect(page.getByTestId("diff-file-0-horizontal-scroll")).toHaveCount(0); await page.getByTestId("diff-file-0-toggle").click(); await expect .poll(() => page.getByTestId("diff-file-0-horizontal-scroll").evaluate((element) => element.scrollLeft), ) .toBe(retainedOffset); }); async function useCodeFont(page: Page, codeFontSize: number): Promise { await page.addInitScript( ({ settingsKey, fontSize }) => { if (localStorage.getItem(settingsKey)) { return; } localStorage.setItem( settingsKey, JSON.stringify({ theme: "dark", sendBehavior: "interrupt", serviceUrlBehavior: "ask", terminalScrollbackLines: 10_000, uiFontFamily: "", monoFontFamily: "", uiFontSize: 16, codeFontSize: fontSize, syntaxTheme: "one", }), ); }, { settingsKey: APP_SETTINGS_KEY, fontSize: codeFontSize }, ); } async function useUnwrappedDiffLines(page: Page): Promise { await configureDiffPresentation(page, { layout: "unified", wrapLines: false }); } async function configureDiffPresentation( page: Page, requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean }, ): Promise { await page.addInitScript( ({ preferencesKey, presentation }) => { localStorage.setItem( preferencesKey, JSON.stringify({ layout: presentation.layout, desktopTreeVisible: false, wrapLines: presentation.wrapLines, hideWhitespace: false, }), ); }, { preferencesKey: CHANGES_PREFERENCES_KEY, presentation: requestedPresentation }, ); } async function setOpenChangesPresentation( page: Page, requestedPresentation: { layout: "unified" | "split"; wrapLines: boolean }, ): Promise { const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true }); const layoutItem = diffPanel.getByTestId("changes-toggle-layout"); const currentLayout = (await layoutItem.getAttribute("aria-label")) === "Switch to unified diff" ? "split" : "unified"; if (currentLayout !== requestedPresentation.layout) { await layoutItem.click(); } const wrapItem = diffPanel.getByTestId("changes-toggle-wrap-lines"); const currentWrapLines = (await wrapItem.getAttribute("aria-label")) === "Scroll long lines"; if (currentWrapLines !== requestedPresentation.wrapLines) { await wrapItem.click(); } } /** Holds every font load in the document until released; later loads pass through. */ async function holdBrowserFontLoads(page: Page): Promise { await page.addInitScript(() => { const fontSet = document.fonts; const originalLoad = fontSet.load.bind(fontSet); const pending: Array<() => void> = []; let released = false; Object.defineProperty(fontSet, "load", { configurable: true, value(font: string, text?: string) { if (released) return originalLoad(font, text); return new Promise((resolve, reject) => { pending.push(() => { originalLoad(font, text).then(resolve, reject); }); }); }, }); Object.assign(window, { __releasePaseoDiffFontLoads() { released = true; for (const release of pending.splice(0)) release(); }, }); }); } async function releaseBrowserFontLoads(page: Page): Promise { await page.evaluate(() => { ( window as typeof window & { __releasePaseoDiffFontLoads: () => void } ).__releasePaseoDiffFontLoads(); }); } async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise { const canvas = page.getByTestId("git-diff-canvas"); await expect .poll(async () => { return canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)); }) .toBe(fontSize); } async function expectDiffCodeFontFamily(page: Page, fontFamily: string): Promise { await expect .poll(() => page .getByTestId("git-diff-canvas") .evaluate((element) => getComputedStyle(element).fontFamily), ) .toContain(fontFamily); } async function expectVisibleDiffRowsShareTypography(page: Page): Promise { await expect(page.getByTestId("git-diff-canvas")).toBeVisible(); await expect(page.locator('[data-testid^="diff-code-row-"]')).toHaveCount(0); } async function createWorkspaceWithMountedTabDiff( options: WorkspaceFixtureOptions = {}, ): Promise { const files = [{ path: "src/use-mounted-tab-set.ts", content: BEFORE }]; if (options.includeDeletedFile) { files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" }); } if (options.includeRenamedFile) { files.push({ path: "src/rename-source.ts", content: "export const renamed = true;\n" }); } if (options.includeNestedFolders) { files.push( { path: "src/zz-folder/root.ts", content: "export const root = 1;\n" }, { path: "src/zz-folder/nested/changed.ts", content: "export const nested = 1;\n" }, ); } const repo = await createTempGitRepo("changes-pane-", { files }); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { await client.close().catch(() => undefined); await repo.cleanup().catch(() => undefined); }, }); await writeFile(path.join(repo.path, "src/use-mounted-tab-set.ts"), AFTER); if (options.includeUntrackedFile) { await writeFile(path.join(repo.path, "zz-untracked.txt"), "remove me\n"); } if (options.includeRootFileSortingFirst) { await writeFile(path.join(repo.path, "a-root-note.txt"), "root note\n"); } if (options.includeDeletedFile) { await unlink(path.join(repo.path, "src/zz-deleted.ts")); } if (options.includeRenamedFile) { execFileSync("git", ["mv", "src/rename-source.ts", "src/zz-renamed.ts"], { cwd: repo.path, }); } if (options.includeNestedFolders) { await writeFile(path.join(repo.path, "src/zz-folder/root.ts"), "export const root = 2;\n"); await writeFile( path.join(repo.path, "src/zz-folder/nested/changed.ts"), "export const nested = 2;\n", ); } const createdWorkspace = await client.createWorkspace({ source: { kind: "directory", path: repo.path }, }); if (!createdWorkspace.workspace) { throw new Error(createdWorkspace.error ?? `Failed to create workspace ${repo.path}`); } return { id: createdWorkspace.workspace.id, repoPath: repo.path }; } async function createWorkspaceWithNetDiff(): Promise { const repo = await createTempGitRepo("changes-net-", { files: [{ path: "a-tracked.ts", content: "base\n" }], }); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { await client.close(); await repo.cleanup(); }, }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path }); await writeFile(path.join(repo.path, "a-tracked.ts"), "committed\n"); await writeFile(path.join(repo.path, "b-committed.ts"), "committed only\n"); execFileSync("git", ["add", "--all"], { cwd: repo.path }); execFileSync("git", ["commit", "-m", "Committed changes"], { cwd: repo.path }); const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } }); if (!created.workspace) throw new Error(created.error ?? "Failed to create net diff workspace"); return { id: created.workspace.id, repoPath: repo.path }; } async function expectNetComparisonFiles(page: Page, files: string[]): Promise { const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText( files, ); await expect(panel.getByTestId(/^diff-file-\d+$/)).toHaveCount(files.length); for (const file of files) { await expect(diffHeaderForPath(panel, file)).toBeVisible(); } } async function expectRenderedAddedLine(page: Page, contents: string): Promise { await changesTree(page).getByText("a-tracked.ts", { exact: true }).click(); await expect(async () => { await page.keyboard.press("Escape"); await page.evaluate(() => navigator.clipboard.writeText("")); const body = page.getByTestId("diff-file-0-body"); const canvas = page.getByTestId("git-diff-canvas"); const bounds = await body.boundingBox(); if (!bounds) throw new Error("Expanded diff body has no bounds"); const fontSize = await canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize), ); // Replacement: hunk header, removed line, added line. await page.mouse.click(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5, { button: "right", }); const copyLine = page.getByRole("menuitem", { name: "Copy line", exact: true }); await expect(copyLine).toBeEnabled({ timeout: 1000 }); await copyLine.click(); await expect .poll(() => page.evaluate(() => navigator.clipboard.readText()), { timeout: 1000 }) .toBe(contents); }).toPass({ timeout: 15_000 }); } async function createWorkspaceWithCommittedDiff(): Promise { const repo = await createTempGitRepo("changes-committed-", { files: [{ path: "tracked.ts", content: "export const tracked = 1;\n" }], }); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { await client.close().catch(() => undefined); await repo.cleanup().catch(() => undefined); }, }); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path }); await writeFile(path.join(repo.path, "committed-only.ts"), "export const committed = true;\n"); execFileSync("git", ["add", "committed-only.ts"], { cwd: repo.path }); execFileSync("git", ["commit", "-m", "Add committed-only file"], { cwd: repo.path }); const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } }); if (!created.workspace) throw new Error(created.error ?? "Failed to create committed workspace"); return { id: created.workspace.id, repoPath: repo.path }; } async function createWorkspaceWithScrollableComparisons(): Promise { const repo = await createTempGitRepo("changes-comparison-focus-", { files: [{ path: "tracked.ts", content: "export const tracked = true;\n" }], }); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { await client.close().catch(() => undefined); await repo.cleanup().catch(() => undefined); }, }); const longFile = Array.from( { length: 120 }, (_, index) => `export const line${index} = ${index};`, ).join("\n"); execFileSync("git", ["checkout", "-b", "feature"], { cwd: repo.path }); await writeComparisonFiles(repo.path, "committed", longFile); execFileSync("git", ["add", "committed"], { cwd: repo.path }); execFileSync("git", ["commit", "-m", "Add committed comparison files"], { cwd: repo.path }); await writeComparisonFiles(repo.path, "uncommitted", longFile); const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } }); if (!created.workspace) throw new Error(created.error ?? "Failed to create comparison workspace"); return { id: created.workspace.id, repoPath: repo.path }; } async function writeComparisonFiles( repoPath: string, directory: "committed" | "uncommitted", longFile: string, ): Promise { await mkdir(path.join(repoPath, directory), { recursive: true }); await Promise.all([ writeFile(path.join(repoPath, directory, "00-before.ts"), `${longFile}\n`), writeFile(path.join(repoPath, directory, "50-target.ts"), "export const target = true;\n"), writeFile(path.join(repoPath, directory, "99-after.ts"), `${longFile}\n`), ]); } async function createWorkspaceWithExactSelectionDiff(content: string): Promise { const repo = await createTempGitRepo("changes-canvas-selection-", { files: [{ path: "src/selection.ts", content: "" }], }); await writeFile(path.join(repo.path, "src/selection.ts"), `${content}\n`); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { await client.close().catch(() => undefined); await repo.cleanup().catch(() => undefined); }, }); const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } }); if (!created.workspace) throw new Error(created.error ?? "Failed to create selection workspace"); return { id: created.workspace.id, repoPath: repo.path }; } async function createWorkspaceWithTwoSelectionDiffs(): Promise { const repo = await createTempGitRepo("changes-canvas-selection-shift-", { files: [ { path: "src/first.ts", content: "" }, { path: "src/second.ts", content: "" }, ], }); await Promise.all([ writeFile(path.join(repo.path, "src/first.ts"), "FIRST\n"), writeFile(path.join(repo.path, "src/second.ts"), "ABCDEFGHIJ\n"), ]); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { await client.close().catch(() => undefined); await repo.cleanup().catch(() => undefined); }, }); const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } }); if (!created.workspace) throw new Error(created.error ?? "Failed to create selection workspace"); return { id: created.workspace.id, repoPath: repo.path }; } async function createWorkspaceWithManyTinyDiffs(fileCount: number): Promise { const files = Array.from({ length: fileCount }, (_, index) => ({ path: `src/file-${String(index).padStart(4, "0")}.ts`, content: "export const value = 0;\n", })); const repo = await createTempGitRepo("changes-canvas-many-files-", { files }); await Promise.all( files.map((file, index) => writeFile(path.join(repo.path, file.path), `export const value = ${index + 1};\n`), ), ); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { await client.close().catch(() => undefined); await repo.cleanup().catch(() => undefined); }, }); const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } }); if (!created.workspace) throw new Error(created.error ?? "Failed to create many-file workspace"); return { id: created.workspace.id, repoPath: repo.path }; } async function createWorkspaceWithStickyTransitionDiff(): Promise { const source = Array.from( { length: 48 }, (_, index) => `export const value${index} = ${index};`, ).join("\n"); const files = ["src/first.ts", "src/second.ts"].map((filePath) => ({ path: filePath, content: `${source}\n`, })); const repo = await createTempGitRepo("changes-canvas-sticky-transition-", { files }); const changedSource = Array.from( { length: 48 }, (_, index) => `export const value${index} = ${index + 100};`, ).join("\n"); await Promise.all( files.map((file) => writeFile(path.join(repo.path, file.path), `${changedSource}\n`)), ); const client = await connectSeedClient(); cleanupTasks.push({ run: async () => { await client.close().catch(() => undefined); await repo.cleanup().catch(() => undefined); }, }); const created = await client.createWorkspace({ source: { kind: "directory", path: repo.path } }); if (!created.workspace) throw new Error(created.error ?? "Failed to create sticky workspace"); return { id: created.workspace.id, repoPath: repo.path }; } async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise { await navigateToWorkspaceChanges(page, workspace); await expectExpandedMountedTabDiff(page); } async function navigateToWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise { await page.setViewportSize({ width: 1400, height: 900 }); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await waitForWorkspaceTabsVisible(page); await page.getByTestId("workspace-explorer-toggle").first().click(); await openChangesInVisibleExplorer(page); } /** The Explorer overlay a phone-sized viewport shows, with its Changes tab selected. */ async function openCompactChanges(page: Page, workspace: DirtyWorkspace): Promise { await page.setViewportSize({ width: 390, height: 844 }); // Reload at the compact size: panel selection starts at the center on a cold // mount, independently of any desktop sidebar used earlier in the test. await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await page.reload(); await page.getByTestId("workspace-explorer-toggle").first().click(); const changesTab = page.getByTestId("workspace-tab-changes_tree").filter({ visible: true }); await expect(changesTab).toBeVisible({ timeout: 30_000 }); await changesTab.click(); const explorer = page.getByTestId("workspace-explorer-sidebar").filter({ visible: true }); await expect(explorer.getByTestId("changes-header")).toBeVisible({ timeout: 30_000 }); return explorer; } async function openChangedFilesOverview(page: Page): Promise { const jumpToFile = page.getByRole("button", { name: "Jump to file" }); await expect(jumpToFile).toBeVisible(); await jumpToFile.click(); await expect(page.getByTestId("changes-jump-to-file-sheet")).toContainText("Jump to file"); } async function expectOverviewReopensExpanded( page: Page, folderName: string, childFileName: string, ): Promise { const sheet = page.getByTestId("changes-jump-to-file-sheet"); const tree = changesTree(page); const folder = tree .getByRole("button") .filter({ has: page.getByText(folderName, { exact: true }) }); const child = tree.getByText(childFileName, { exact: true }); await expect(folder).toBeVisible(); await expect(child).toBeVisible(); await folder.click(); await expect(child).toHaveCount(0); await sheet.getByRole("button", { name: "Close", exact: true }).click(); await openChangedFilesOverview(page); await expect(child).toBeVisible(); } async function jumpToOverviewFile(page: Page, explorer: Locator, filePath: string): Promise { const sheet = page.getByTestId("changes-jump-to-file-sheet"); await changesTree(page).getByText(path.basename(filePath), { exact: true }).click(); await expect(sheet).toHaveCount(0); await expect(diffHeaderForPath(explorer, filePath)).toBeInViewport(); } async function selectEmptyUncommittedComparison(page: Page, explorer: Locator): Promise { await explorer.getByRole("button", { name: "Diff mode", exact: true }).click(); await page.getByRole("menuitem", { name: "Uncommitted", exact: true }).click(); await expect(explorer.getByText("No changes to display", { exact: true })).toBeVisible(); } async function openWorkspaceChangesSurface( page: Page, workspace: DirtyWorkspace, timeout?: number, ): Promise { await page.setViewportSize({ width: 1400, height: 900 }); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await waitForWorkspaceTabsVisible(page); await openChangesPanel(page, timeout); } async function openSelectionWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise { await page.setViewportSize({ width: 1400, height: 900 }); await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id)); await waitForWorkspaceTabsVisible(page); await openChangesPanel(page); const diffPanel = page.getByTestId("working-diff-panel").filter({ visible: true }); await expect(diffPanel.getByTestId("diff-file-0")).toHaveAccessibleName(/selection\.ts/, { timeout: 30_000, }); await expectExpandedMountedTabDiff(page); } async function openChangesInVisibleExplorer(page: Page): Promise { const explorer = page.getByTestId("workspace-explorer-sidebar"); await expect(explorer).toBeVisible({ timeout: 30_000 }); const changesTab = explorer.getByRole("button", { name: /Working tree diff/i }).first(); await changesTab.click(); const changedFile = explorer .locator('[data-testid^="diff-tree-file-"][data-testid$="-toggle"]') .filter({ visible: true }) .first(); await expect(changedFile).toBeVisible({ timeout: 30_000 }); await changedFile.click(); await expect(page.getByTestId("working-diff-panel").filter({ visible: true })).toBeVisible({ timeout: 30_000, }); } async function selectChangesComparison( page: Page, comparison: "Committed" | "Uncommitted", ): Promise { const tree = page.getByTestId("changes-tree-panel").filter({ visible: true }); await tree.getByTestId("changes-diff-status-trigger").click(); await page.getByTestId(`changes-diff-mode-${comparison.toLowerCase()}`).click(); await expect(tree.getByTestId("changes-diff-status-trigger")).toContainText(comparison); } async function expectWorkingComparisonFiles( page: Page, comparison: "committed" | "uncommitted", ): Promise { const tree = changesTree(page); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const paths = ["00-before.ts", "50-target.ts", "99-after.ts"]; await expect(tree.locator('[data-testid^="diff-tree-file-"][data-testid$="-name"]')).toHaveText( paths, ); for (const fileName of paths) { await expect(diffHeaderForPath(panel, `${comparison}/${fileName}`)).toBeAttached(); } } async function selectChangedFileAndExpectFocused(page: Page, filePath: string): Promise { const tree = changesTree(page); const panel = page.getByTestId("working-diff-panel").filter({ visible: true }); const scroller = panel.getByTestId("git-diff-scroll"); await scroller.evaluate((element) => { element.scrollTop = 0; element.dispatchEvent(new Event("scroll", { bubbles: false })); }); await tree.getByText(path.basename(filePath), { exact: true }).click(); const header = diffHeaderForPath(panel, filePath); await expect(header).toBeVisible(); await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); const [scrollBounds, headerBounds] = await Promise.all([ scroller.boundingBox(), header.boundingBox(), ]); if (!scrollBounds || !headerBounds) throw new Error("Focused diff header geometry is unavailable"); expect(headerBounds.y - scrollBounds.y).toBeCloseTo(0, 0); } async function expectExpandedMountedTabDiff(page: Page): Promise { await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("git-diff-canvas")).toBeVisible({ timeout: 30_000 }); } async function changeCodeTypographyFromSettings( page: Page, typography: { fontSize: number; fontFamily: string }, ): Promise { await page.getByTestId("sidebar-settings").click(); await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`)); await page.getByRole("button", { name: "Appearance" }).click(); // A committed font change remounts the screen (AppearanceStyleBoundary), and the new form starts // from the stored size. Edit the size only in the remounted form, or the remount drops it. const sizeField = page.getByLabel("Code font size"); await sizeField.evaluate((field) => field.setAttribute("data-e2e-before-font-family", "")); await page.getByLabel("Code font family").fill(typography.fontFamily); await page.getByLabel("Code font family").press("Enter"); await expect(page.locator("[data-e2e-before-font-family]")).toHaveCount(0); await sizeField.fill(String(typography.fontSize)); await page.getByLabel("Code font size").press("Enter"); await expect(page.getByLabel("Code font family")).toHaveValue(typography.fontFamily); await expect(page.getByLabel("Code font size")).toHaveValue(String(typography.fontSize)); await expectStoredCodeFontSize(page, typography.fontSize); } async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise { await expect .poll(async () => { const raw = await page.evaluate( (settingsKey) => localStorage.getItem(settingsKey), APP_SETTINGS_KEY, ); if (!raw) { return null; } return (JSON.parse(raw) as { codeFontSize?: number }).codeFontSize ?? null; }) .toBe(codeFontSize); } async function startReviewOnFirstChangedLine( page: Page, side: "unified" | "right" = "unified", ): Promise { const body = page.getByTestId("diff-file-0-body"); const canvas = page.getByTestId("git-diff-canvas"); const [bodyBounds, fontSize] = await Promise.all([ body.boundingBox(), canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)), ]); if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(fontSize * 1.5); const columnLeft = side === "right" ? bodyBounds.x + bodyBounds.width / 2 : bodyBounds.x; await page.mouse.move(columnLeft + 20, bodyBounds.y + lineHeight * 1.5); await page.getByRole("button", { name: "Add review comment" }).click(); await expect(page.getByTestId("inline-review-editor")).toBeVisible(); } async function clickFirstChangedLine(page: Page): Promise { const body = page.getByTestId("diff-file-0-body"); const canvas = page.getByTestId("git-diff-canvas"); const [bodyBounds, fontSize] = await Promise.all([ body.boundingBox(), canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)), ]); if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(fontSize * 1.5); await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5); } async function openCopyableSelectionDiff(page: Page, content: string): Promise { const workspace = await createWorkspaceWithExactSelectionDiff(content); await page.context().grantPermissions(["clipboard-read", "clipboard-write"]); await configureDiffPresentation(page, { layout: "unified", wrapLines: false }); await openSelectionWorkspaceChanges(page, workspace); } async function copyDiffSelectionWithKeyboard(page: Page): Promise { await page.evaluate(() => navigator.clipboard.writeText("")); await page.keyboard.press("ControlOrMeta+C"); } async function copyFromChangedLineMenu(page: Page, action: "Copy" | "Copy line"): Promise { await page.evaluate(() => navigator.clipboard.writeText("")); await rightClickFirstChangedLine(page); const item = page.getByRole("menuitem", { name: action, exact: true }); await expect(item).toBeEnabled(); await item.click(); } async function expectClipboardText(page: Page, text: string): Promise { await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(text); } async function resizeDiffViewportHeight(page: Page, height: number): Promise { const canvas = page.getByTestId("git-diff-canvas"); const previousViewport = page.viewportSize()!; const previousHeight = await canvas.evaluate((element) => element.getBoundingClientRect().height); await page.setViewportSize({ width: previousViewport.width, height }); await expect(canvas).toHaveCSS( "height", `${previousHeight + height - previousViewport.height}px`, ); } async function rightClickFirstChangedLine(page: Page, fileIndex = 0): Promise { const body = page.getByTestId(`diff-file-${fileIndex}-body`); const canvas = page.getByTestId("git-diff-canvas"); const [bodyBounds, fontSize] = await Promise.all([ body.boundingBox(), canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)), ]); if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(fontSize * 1.5); await page.mouse.click(bodyBounds.x + 120, bodyBounds.y + lineHeight * 1.5, { button: "right", }); } async function hoverFirstChangedGutter(page: Page): Promise { const body = page.getByTestId("diff-file-0-body"); const canvas = page.getByTestId("git-diff-canvas"); const [bodyBounds, fontSize] = await Promise.all([ body.boundingBox(), canvas.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)), ]); if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(fontSize * 1.5); const gutterWidth = 2 * Math.ceil(fontSize * 0.62) + 12; await page.mouse.move(bodyBounds.x + gutterWidth, bodyBounds.y + lineHeight * 1.5); } async function saveInlineReview(page: Page, body: string): Promise { await page.getByTestId("inline-review-editor-input").fill(body); await page.getByTestId("inline-review-editor-save").click(); await expect(page.getByText(body, { exact: true })).toBeVisible(); } async function cancelInlineReview(page: Page): Promise { await page.getByTestId("inline-review-editor-cancel").click(); await expect(page.getByTestId("inline-review-editor")).toHaveCount(0); } async function editInlineReview(page: Page, body: string): Promise { await page.getByTestId(/^review-comment-edit-/).click(); await expect(page.getByTestId("inline-review-editor")).toBeVisible(); await page.getByTestId("inline-review-editor-input").fill(body); await page.getByTestId("inline-review-editor-save").click(); await expect(page.getByText(body, { exact: true })).toBeVisible(); } async function deleteInlineReview(page: Page): Promise { await page.getByTestId(/^review-comment-delete-/).click(); await expect(page.getByTestId(/^review-comment-delete-/)).toHaveCount(0); } async function dragExactAddedText( page: Page, offsets: { startOffset: number; endOffset: number }, fileIndex = 0, ): Promise { const body = page.getByTestId(`diff-file-${fileIndex}-body`); const canvas = page.getByTestId("git-diff-canvas"); const [bodyBounds, metrics] = await Promise.all([ body.boundingBox(), canvas.evaluate((element) => { const style = getComputedStyle(element); const fontSize = Number.parseFloat(style.fontSize); const measurementCanvas = document.createElement("canvas"); const context = measurementCanvas.getContext("2d")!; context.font = `${fontSize}px ${style.fontFamily}`; return { fontSize, characterWidth: context.measureText("A").width }; }), ]); if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(metrics.fontSize * 1.5); const gutterWidth = Math.max(2, String(1).length) * Math.ceil(metrics.fontSize * 0.62) + 12; const textLeft = bodyBounds.x + gutterWidth + 8; await page.mouse.move( textLeft + offsets.startOffset * metrics.characterWidth + 1, bodyBounds.y + lineHeight * 1.5, ); await page.mouse.down(); await page.mouse.move( textLeft + offsets.endOffset * metrics.characterWidth - 1, bodyBounds.y + lineHeight * 1.5, { steps: 8 }, ); await page.mouse.up(); } async function dragAddedTextRange( page: Page, input: { lines: string[]; start: { line: number; offset: number }; end: { line: number; offset: number }; side?: "left" | "right"; wrapped?: boolean; horizontalOffset?: number; }, ): Promise { const body = page.getByTestId("diff-file-0-body"); const canvas = page.getByTestId("git-diff-canvas"); const [bounds, metrics] = await Promise.all([ body.boundingBox(), canvas.evaluate((element) => { const style = getComputedStyle(element); const fontSize = Number.parseFloat(style.fontSize); const context = document.createElement("canvas").getContext("2d")!; context.font = `${fontSize}px ${style.fontFamily}`; return { fontSize, characterWidth: context.measureText("A").width }; }), ]); if (!bounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(metrics.fontSize * 1.5); const gutterWidth = 2 * Math.ceil(metrics.fontSize * 0.62) + 12; const columnWidth = input.side ? bounds.width / 2 : bounds.width; const columnLeft = input.side === "right" ? bounds.x + columnWidth : bounds.x; const availableWidth = columnWidth - gutterWidth - 16; const charactersPerFragment = Math.max(1, Math.floor(availableWidth / metrics.characterWidth)); const fragmentsBefore = (line: number) => input.wrapped ? input.lines .slice(0, line) .reduce( (total, text) => total + Math.max(1, Math.ceil(text.length / charactersPerFragment)), 0, ) : line; const point = ({ line, offset }: { line: number; offset: number }) => { const fragment = input.wrapped ? Math.floor(offset / charactersPerFragment) : 0; const localOffset = input.wrapped ? offset % charactersPerFragment : offset; return { x: columnLeft + gutterWidth + 8 + localOffset * metrics.characterWidth - (input.horizontalOffset ?? 0), y: bounds.y + (1 + fragmentsBefore(line) + fragment + 0.5) * lineHeight, }; }; const start = point(input.start); const end = point(input.end); await page.mouse.move(start.x, start.y); await page.mouse.down(); await page.mouse.move(end.x, end.y, { steps: 12 }); await page.mouse.up(); } async function readSelectionPaintSamples( page: Page, side: "unified" | "right" = "unified", ): Promise<{ gutter: number[]; code: number[]; opposite: number[] }> { return page.getByTestId("diff-file-0-body").evaluate((body, selectedSide) => { const canvas = document.querySelector('[data-testid="git-diff-canvas"]')!; const bodyBounds = body.getBoundingClientRect(); const canvasBounds = canvas.getBoundingClientRect(); const scaleX = canvas.width / canvasBounds.width; const scaleY = canvas.height / canvasBounds.height; const context = canvas.getContext("2d")!; const sample = (left: number, top: number, width: number, height: number) => Array.from( context.getImageData( Math.round((left - canvasBounds.left) * scaleX), Math.round((top - canvasBounds.top) * scaleY), Math.max(1, Math.round(width * scaleX)), Math.max(1, Math.round(height * scaleY)), ).data, ); const columnLeft = selectedSide === "right" ? bodyBounds.left + bodyBounds.width / 2 : bodyBounds.left; return { gutter: sample(columnLeft + 2, bodyBounds.top + 24, 8, 8), code: sample(columnLeft + 80, bodyBounds.top + 24, 80, 10), opposite: sample(bodyBounds.left + 80, bodyBounds.top + 24, 80, 10), }; }, side); } async function dragWithinFirstAddedGrapheme(page: Page): Promise { const bodyBounds = await page.getByTestId("diff-file-0-body").boundingBox(); const fontSize = await page .getByTestId("git-diff-canvas") .evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)); if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(fontSize * 1.5); const gutterWidth = 2 * Math.ceil(fontSize * 0.62) + 12; const x = bodyBounds.x + gutterWidth + 10; const y = bodyBounds.y + lineHeight * 1.5; await page.mouse.move(x, y); await page.mouse.down(); await page.mouse.move(x + 5, y, { steps: 3 }); await page.mouse.up(); } async function dragFirstAddedLineIntoHeader(page: Page): Promise { const bodyBounds = await page.getByTestId("diff-file-0-body").boundingBox(); const fontSize = await page .getByTestId("git-diff-canvas") .evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize)); if (!bodyBounds) throw new Error("Expanded diff body has no bounds"); const lineHeight = Math.round(fontSize * 1.5); const x = bodyBounds.x + 60; await page.mouse.move(x, bodyBounds.y + lineHeight * 1.5); await page.mouse.down(); await page.mouse.move(x, bodyBounds.y - 10, { steps: 4 }); await page.mouse.up(); } async function horizontallyScrollFirstFile(page: Page, requestedOffset: number): Promise { const horizontalScroll = page.getByTestId("diff-file-0-horizontal-scroll"); const retainedOffset = await horizontalScroll.evaluate((element, offset) => { element.scrollLeft = offset; element.dispatchEvent(new Event("scroll")); return element.scrollLeft; }, requestedOffset); expect(retainedOffset).toBeGreaterThan(0); return retainedOffset; } async function measureInFlowHeaderMotion( page: Page, ): Promise<{ headerSurface: number; shell: number }> { return page.getByTestId("git-diff-scroll").evaluate(async (element) => { const scroll = element as HTMLElement; const headerSurface = document.querySelector('[data-testid="git-diff-canvas"]'); const shell = document.querySelector('[data-diff-header-path="src/second.ts"]'); if (!headerSurface || !shell) throw new Error("Diff header motion surfaces are unavailable"); const shellDocumentTop = scroll.scrollTop + shell.getBoundingClientRect().top - scroll.getBoundingClientRect().top; scroll.scrollTop = Math.max(0, shellDocumentTop - scroll.clientHeight + 80); scroll.dispatchEvent(new Event("scroll", { bubbles: false })); await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve())), ); const before = { headerSurface: headerSurface.getBoundingClientRect().top, shell: shell.getBoundingClientRect().top, }; scroll.scrollTop += 120; const after = { headerSurface: headerSurface.getBoundingClientRect().top, shell: shell.getBoundingClientRect().top, }; return { headerSurface: after.headerSurface - before.headerSurface, shell: after.shell - before.shell, }; }); } async function longPressFileHeader(page: Page, header: Locator): Promise { const bounds = await header.boundingBox(); if (!bounds) throw new Error("File header has no bounds"); await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2); await page.mouse.down(); await page.waitForTimeout(600); await page.mouse.up(); } async function headerCanvasPixel( canvas: Locator, header: Locator, offsetY: number, ): Promise { const [canvasBounds, headerBounds] = await Promise.all([ canvas.boundingBox(), header.boundingBox(), ]); if (!canvasBounds || !headerBounds) throw new Error("Canvas header has no bounds"); return canvas.evaluate( (element, point) => { const target = element as HTMLCanvasElement; const ratio = target.width / target.getBoundingClientRect().width; const context = target.getContext("2d"); if (!context) throw new Error("Canvas header has no 2D context"); return Array.from( context.getImageData(Math.round(point.x * ratio), Math.round(point.y * ratio), 1, 1).data, ).join(","); }, { x: 4, y: headerBounds.y - canvasBounds.y + offsetY }, ); } async function readDiffTypographyGeometry(page: Page): Promise<{ horizontalExtent: number; canvasPixels: string; }> { const horizontalExtent = await page .getByTestId("diff-file-0-horizontal-scroll") .evaluate((element) => element.scrollWidth); const canvasPixels = (await page.getByTestId("git-diff-canvas").screenshot()).toString("base64"); return { horizontalExtent, canvasPixels }; } async function returnToWorkspaceChanges(page: Page): Promise { await page.getByTestId("settings-back-to-workspace").click(); await waitForWorkspaceTabsVisible(page); await openChangesInVisibleExplorer(page); await expectExpandedMountedTabDiff(page); } async function scrollToLowerUnwrappedDiffRows(page: Page): Promise { await page.getByTestId("git-diff-scroll").evaluate((element) => { element.scrollTop = element.scrollHeight - element.clientHeight; element.dispatchEvent(new Event("scroll", { bubbles: false })); }); await expect(page.getByTestId("git-diff-canvas")).toBeVisible(); }