Files
paseo/packages/app/e2e/diff-row-alignment.spec.ts
nikuscs bd937850b3 feat: add workspace files to chat from Files and Changes
* feat(app): open changed files from context menu

* fix(app): preserve changed-file row behavior

* feat(app): add changed files to open chats

* feat(app): share file actions across explorer and changes

* feat(app): attach workspace files to focused chat

* feat(app): drag workspace files into chat

* refactor(app): use file actions menu for changed files

* fix(app): open changed-file actions on right click

* test(app): cover direct file attachment flow

* fix(app): enable add to chat on native

* fix(app): align workspace file pane rows

* refactor(app): simplify workspace file attachments

* fix(app): preserve uploaded file draft attachments

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-07-22 15:58:24 +02:00

666 lines
23 KiB
TypeScript

import { unlink, writeFile } from "node:fs/promises";
import path from "node:path";
import { type Page } from "@playwright/test";
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../src/utils/host-routes";
import { test, expect } from "./fixtures";
import { getServerId } from "./helpers/server-id";
import { connectSeedClient } from "./helpers/seed-client";
import { createTempGitRepo } from "./helpers/workspace";
import { waitForWorkspaceTabsVisible } from "./helpers/workspace-tabs";
interface DirtyWorkspace {
id: string;
}
interface WorkspaceFixtureOptions {
includeDeletedFile?: boolean;
}
interface CleanupTask {
run: () => Promise<void>;
}
const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings";
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<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): 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<string>;
}
interface DeriveRenderMountedTabIdsInput {
activeTabId: string | null;
availableTabIds: Set<string>;
cap: number;
mountedTabIds: Set<string>;
}
function createInitialMountedTabIds(input: UseMountedTabSetInput): Set<string> {
if (!input.activeTabId || !input.allTabIds.includes(input.activeTabId)) {
return new Set<string>();
}
return new Set<string>([input.activeTabId]);
}
function setsEqual(left: Set<string>, right: Set<string>): 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<string> {
const { activeTabId, availableTabIds, cap, mountedTabIds } = input;
if (!activeTabId || !availableTabIds.has(activeTabId) || mountedTabIds.has(activeTabId)) {
return mountedTabIds;
}
const next = new Set<string>([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 diff keeps code rows aligned with the gutter", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 9);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectDiffCodeFontSize(page, 9);
await expectVisibleDiffRowsAligned(page);
await expectDiffCodeTextAlignedWithGutterText(page, [
{
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
},
{ codeText: "return next;", lineNumber: "55" },
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
]);
await expectHoverCommentButtonAlignedWithCodeLine(page, {
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
lineNumber: "20",
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
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("changes diff switches between flat and tree file lists", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expectFlatFileList(page);
await expect(page.getByTestId("changes-toggle-layout")).toBeVisible();
await expect(page.getByTestId("changes-layout-unified")).toHaveCount(0);
await expect(page.getByTestId("changes-layout-split")).toHaveCount(0);
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-options-menu-content")).toBeVisible();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Hide whitespace");
await expect(page.getByTestId("changes-toggle-wrap-lines")).toContainText("Wrap long lines");
await expect(page.getByTestId("changes-refresh")).toContainText("Refresh");
await page.getByTestId("changes-toggle-whitespace").click();
await page.getByTestId("changes-options-menu").click();
await expect(page.getByTestId("changes-toggle-whitespace")).toContainText("Show whitespace");
await page.keyboard.press("Escape");
await scrollToLowerUnwrappedDiffRows(page);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByTestId("diff-folder-src-toggle").click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
await page.getByTestId("changes-toggle-view-mode").click();
await expectFlatFileList(page);
});
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace);
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
page.getByTestId("diff-file-0-stat").boundingBox(),
]);
expect(folderStat).not.toBeNull();
expect(fileStat).not.toBeNull();
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(),
]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull();
expect(rowBounds).not.toBeNull();
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
});
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff();
await useCodeFont(page, 12);
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await changeCodeFontSizeFromSettings(page, 18);
await returnToWorkspaceChanges(page);
await expectStoredCodeFontSize(page, 18);
await scrollToLowerUnwrappedDiffRows(page);
await expectDiffCodeFontSize(page, 18);
await expectVisibleDiffRowsShareTypography(page);
await expectVisibleDiffRowsAligned(page);
});
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
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<void> {
await page.addInitScript(
({ preferencesKey }) => {
localStorage.setItem(
preferencesKey,
JSON.stringify({
layout: "unified",
viewMode: "flat",
wrapLines: false,
hideWhitespace: false,
}),
);
},
{ preferencesKey: CHANGES_PREFERENCES_KEY },
);
}
async function expectFlatFileList(page: Page): Promise<void> {
await expect(page.locator('[data-testid^="diff-folder-"]')).toHaveCount(0);
await expect(page.getByTestId("diff-file-0")).toContainText("use-mounted-tab-set.ts");
await expect(page.getByTestId("diff-file-0")).toContainText("src");
}
async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<void> {
await expect
.poll(async () => {
return page
.getByTestId("diff-code-text-1")
.evaluate((text) => Number.parseFloat(getComputedStyle(text).fontSize));
})
.toBe(fontSize);
}
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
}
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
const geometry = await readVisibleDiffRowGeometry(page);
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
}
async function readVisibleDiffRowGeometry(page: Page): Promise<{
maxDelta: number;
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
rows: {
index: number;
gutterTop: number;
codeTop: number;
delta: number;
gutterLineHeight: number;
codeLineHeight: number;
}[];
}> {
return page.locator("body").evaluate(({ ownerDocument }) => {
const root = ownerDocument.querySelector('[data-testid="diff-file-0-body"]');
if (!root) {
throw new Error("Expanded diff body is not mounted");
}
const readRows = (prefix: string, textPrefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map((row) => {
const testId = row.getAttribute("data-testid") ?? "";
const index = Number(testId.slice(prefix.length));
const rect = row.getBoundingClientRect();
const text = root.querySelector<HTMLElement>(`[data-testid="${textPrefix}${index}"]`);
const lineHeight = text ? Number.parseFloat(getComputedStyle(text).lineHeight) : 0;
return { index, top: rect.top, height: rect.height, lineHeight };
});
const gutters = new Map(
readRows("diff-gutter-row-", "diff-gutter-text-").map((row) => [row.index, row]),
);
const codes = readRows("diff-code-row-", "diff-code-text-");
const rows = codes
.map((code) => {
const gutter = gutters.get(code.index);
if (!gutter) {
throw new Error(`Missing gutter row ${code.index}`);
}
return {
index: code.index,
gutterTop: gutter.top,
codeTop: code.top,
delta: Math.abs(code.top - gutter.top),
gutterLineHeight: gutter.lineHeight,
codeLineHeight: code.lineHeight,
};
})
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
return {
maxDelta: Math.max(...rows.map((row) => row.delta)),
mismatchedTypography: rows
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
.map((row) => ({
index: row.index,
gutterLineHeight: row.gutterLineHeight,
codeLineHeight: row.codeLineHeight,
})),
rows,
};
});
}
async function createWorkspaceWithMountedTabDiff(
options: WorkspaceFixtureOptions = {},
): Promise<DirtyWorkspace> {
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" });
}
const repo = await createTempGitRepo("diff-row-alignment-", { 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.includeDeletedFile) {
await unlink(path.join(repo.path, "src/zz-deleted.ts"));
}
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 };
}
async function openWorkspaceChanges(page: Page, workspace: DirtyWorkspace): Promise<void> {
await page.setViewportSize({ width: 1400, height: 900 });
await page.goto(buildHostWorkspaceRoute(getServerId(), workspace.id));
await waitForWorkspaceTabsVisible(page);
await page.getByRole("button", { name: "Open explorer" }).click();
await openChangesInVisibleExplorer(page);
await page.getByTestId("diff-file-0").click();
await expectExpandedMountedTabDiff(page);
}
async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByTestId("explorer-tab-changes")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
timeout: 30_000,
});
}
async function changeCodeFontSizeFromSettings(page: Page, codeFontSize: number): Promise<void> {
await page.getByTestId("sidebar-settings").click();
await expect(page).toHaveURL(new RegExp(`${buildSettingsSectionRoute("general")}|/settings$`));
await page.getByRole("button", { name: "Appearance" }).click();
await page.getByLabel("Code font size").fill(String(codeFontSize));
await page.getByLabel("Code font size").press("Enter");
await expect(page.getByLabel("Code font size")).toHaveValue(String(codeFontSize));
await expectStoredCodeFontSize(page, codeFontSize);
}
async function expectStoredCodeFontSize(page: Page, codeFontSize: number): Promise<void> {
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 returnToWorkspaceChanges(page: Page): Promise<void> {
await page.getByTestId("settings-back-to-workspace").click();
await waitForWorkspaceTabsVisible(page);
await openChangesInVisibleExplorer(page);
await expectExpandedMountedTabDiff(page);
}
async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
const lastRowIndex = await page.getByTestId("diff-file-0-body").evaluate((root) => {
const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-testid^="diff-code-row-"]'));
if (rows.length === 0) {
throw new Error("No unwrapped code rows are mounted");
}
return Math.max(
...rows.map((row) => Number((row.getAttribute("data-testid") ?? "").slice(14))),
);
});
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
}
async function expectDiffCodeTextAlignedWithGutterText(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<void> {
const geometries = await readDiffTextGeometry(page, lines);
for (const geometry of geometries) {
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
}
}
async function expectHoverCommentButtonAlignedWithCodeLine(
page: Page,
line: { codeText: string; lineNumber: string },
): Promise<void> {
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
if (!target) {
throw new Error(`Could not find target line ${line.lineNumber}`);
}
await page.getByTestId(`diff-code-row-${target.index}`).hover();
const geometry = await page
.getByTestId(`diff-gutter-action-${target.index}`)
.evaluate((action, expectedCodeCenterY) => {
const rect = action.getBoundingClientRect();
return {
actionCenterY: rect.top + rect.height / 2,
codeCenterY: expectedCodeCenterY,
};
}, target.codeCenterY);
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
}
async function readDiffTextGeometry(
page: Page,
lines: { codeText: string; lineNumber: string }[],
): Promise<
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
> {
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
if (!root) {
throw new Error("Changes panel is not mounted");
}
const readIndexedElements = (prefix: string) =>
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
(element) => {
const testId = element.getAttribute("data-testid") ?? "";
return { index: Number(testId.slice(prefix.length)), element };
},
);
const gutterTexts = readIndexedElements("diff-gutter-text-");
const codeTexts = readIndexedElements("diff-code-text-");
return targets.map((target) => {
const gutter = gutterTexts.find(
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
);
if (!gutter) {
throw new Error(`Could not find gutter line ${target.lineNumber}`);
}
const code = codeTexts.find(
({ index, element }) =>
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
);
if (!code) {
throw new Error(`Could not find code row ${target.codeText}`);
}
const codeRect = code.element.getBoundingClientRect();
const gutterRect = gutter.element.getBoundingClientRect();
return {
index: gutter.index,
codeText: target.codeText,
codeTop: codeRect.top,
gutterTop: gutterRect.top,
codeCenterY: codeRect.top + codeRect.height / 2,
};
});
}, lines);
}