fix(app): polish workspace navigation surfaces
Use mobile action sheets and long-press file menus while preserving provider context across sheet portals. Keep sidebar and file-pane glyphs on shared visual rails, avoid scrollbar layout shifts, and hide keyboard affordances where shortcuts are unavailable.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { type Page } from "@playwright/test";
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
@@ -25,6 +25,103 @@ const cleanupTasks: CleanupTask[] = [];
|
||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences";
|
||||
|
||||
interface HorizontalInkBounds {
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
async function readSvgInkBounds(svgLocator: Locator): Promise<HorizontalInkBounds> {
|
||||
return svgLocator.evaluate((svg) => {
|
||||
const graphics = Array.from(svg.querySelectorAll<SVGGraphicsElement>("path, line, polyline"));
|
||||
const bounds = graphics.map((graphic) => {
|
||||
const box = graphic.getBBox();
|
||||
const matrix = graphic.getScreenCTM();
|
||||
if (!matrix) {
|
||||
throw new Error("SVG glyph has no screen transform");
|
||||
}
|
||||
const strokeInset = Number.parseFloat(getComputedStyle(graphic).strokeWidth) / 2 || 0;
|
||||
const corners = [
|
||||
new DOMPoint(box.x - strokeInset, box.y - strokeInset),
|
||||
new DOMPoint(box.x + box.width + strokeInset, box.y - strokeInset),
|
||||
new DOMPoint(box.x - strokeInset, box.y + box.height + strokeInset),
|
||||
new DOMPoint(box.x + box.width + strokeInset, box.y + box.height + strokeInset),
|
||||
].map((point) => point.matrixTransform(matrix));
|
||||
return {
|
||||
left: Math.min(...corners.map((point) => point.x)),
|
||||
right: Math.max(...corners.map((point) => point.x)),
|
||||
};
|
||||
});
|
||||
return {
|
||||
left: Math.min(...bounds.map((bound) => bound.left)),
|
||||
right: Math.max(...bounds.map((bound) => bound.right)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readTextInkBounds(
|
||||
container: Locator,
|
||||
edge: "first" | "last" = "first",
|
||||
): Promise<HorizontalInkBounds> {
|
||||
return container.evaluate((root, requestedEdge) => {
|
||||
const textElements = [root, ...Array.from(root.querySelectorAll("*"))].filter((element) =>
|
||||
Array.from(element.childNodes).some(
|
||||
(node) => node.nodeType === Node.TEXT_NODE && Boolean(node.textContent?.trim()),
|
||||
),
|
||||
);
|
||||
const element = textElements[requestedEdge === "first" ? 0 : textElements.length - 1];
|
||||
const text = Array.from(element.childNodes)
|
||||
.filter((node) => node.nodeType === Node.TEXT_NODE)
|
||||
.map((node) => node.textContent ?? "")
|
||||
.join("")
|
||||
.trim();
|
||||
const style = getComputedStyle(element);
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context || !text) {
|
||||
throw new Error("Text glyph could not be measured");
|
||||
}
|
||||
context.font = style.font;
|
||||
const metrics = context.measureText(text);
|
||||
const origin = element.getBoundingClientRect().left;
|
||||
return {
|
||||
left: origin - metrics.actualBoundingBoxLeft,
|
||||
right: origin + metrics.actualBoundingBoxRight,
|
||||
};
|
||||
}, edge);
|
||||
}
|
||||
|
||||
async function readScrollbarGutter(scrollContainer: Locator): Promise<number> {
|
||||
return scrollContainer.evaluate((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
return htmlElement.offsetWidth - htmlElement.clientWidth;
|
||||
});
|
||||
}
|
||||
|
||||
async function dragOverlayScrollbarDown(page: Page, scrollContainer: Locator): Promise<void> {
|
||||
const thumb = page.getByTestId("workspace-overlay-scrollbar-grab");
|
||||
const thumbBounds = await thumb.boundingBox();
|
||||
expect(thumbBounds).not.toBeNull();
|
||||
const initialOffset = await scrollContainer.evaluate((element) => element.scrollTop);
|
||||
await page.mouse.move(
|
||||
thumbBounds!.x + thumbBounds!.width / 2,
|
||||
thumbBounds!.y + thumbBounds!.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
thumbBounds!.x + thumbBounds!.width / 2,
|
||||
thumbBounds!.y + thumbBounds!.height / 2 + 40,
|
||||
{ steps: 4 },
|
||||
);
|
||||
await page.mouse.up();
|
||||
await expect
|
||||
.poll(() => scrollContainer.evaluate((element) => element.scrollTop))
|
||||
.toBeGreaterThan(initialOffset);
|
||||
}
|
||||
|
||||
function expectSameRail(actual: number, expected: number): void {
|
||||
expect(Math.abs(actual - expected)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
interface UseMountedTabSetInput {
|
||||
@@ -370,21 +467,62 @@ test("workspace file panes keep their controls on shared alignment rails", async
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
const diffScroll = page.getByTestId("git-diff-scroll");
|
||||
await diffScroll.evaluate((element) => {
|
||||
element.style.scrollbarGutter = "stable";
|
||||
});
|
||||
expect(await readScrollbarGutter(diffScroll)).toBe(0);
|
||||
const overlayScrollbarBounds = await page
|
||||
.getByTestId("workspace-overlay-scrollbar")
|
||||
.boundingBox();
|
||||
const overlayThumbBounds = await page
|
||||
.getByTestId("workspace-overlay-scrollbar-thumb")
|
||||
.boundingBox();
|
||||
expect(overlayScrollbarBounds?.width).toBe(8);
|
||||
expect(overlayThumbBounds?.width).toBe(4);
|
||||
const flatFileStat = await readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last");
|
||||
await page.getByTestId("changes-toggle-view-mode").click();
|
||||
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
|
||||
|
||||
await expect(page.getByTestId("diff-file-0-actions")).toHaveCount(0);
|
||||
|
||||
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);
|
||||
const folderRow = page.getByTestId("diff-folder-src-toggle");
|
||||
const fileRow = page.getByTestId("diff-file-0-toggle");
|
||||
await folderRow.hover();
|
||||
const folderHoverColor = await folderRow.evaluate((row) => getComputedStyle(row).backgroundColor);
|
||||
await fileRow.hover();
|
||||
const fileHoverColor = await fileRow.evaluate((row) => getComputedStyle(row).backgroundColor);
|
||||
expect(fileHoverColor).toBe(folderHoverColor);
|
||||
|
||||
const [folderStat, fileStat, optionsChevron, explorerCloseIcon, diffModeLabel] =
|
||||
await Promise.all([
|
||||
readTextInkBounds(page.getByTestId("diff-folder-src-stat"), "last"),
|
||||
readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last"),
|
||||
readSvgInkBounds(page.getByTestId("changes-options-menu").locator("svg")),
|
||||
readSvgInkBounds(page.getByTestId("explorer-close").locator("svg")),
|
||||
readTextInkBounds(page.getByTestId("changes-diff-status-trigger")),
|
||||
]);
|
||||
const expandedFolderChevron = await readSvgInkBounds(folderRow.locator("svg"));
|
||||
expectSameRail(folderStat.right, fileStat.right);
|
||||
expectSameRail(flatFileStat.right, fileStat.right);
|
||||
expectSameRail(fileStat.right, explorerCloseIcon.right);
|
||||
expectSameRail(fileStat.right, optionsChevron.right);
|
||||
expectSameRail(optionsChevron.right, explorerCloseIcon.right);
|
||||
expectSameRail(expandedFolderChevron.left, diffModeLabel.left);
|
||||
|
||||
await folderRow.click();
|
||||
const collapsedFolderChevron = await readSvgInkBounds(folderRow.locator("svg"));
|
||||
expectSameRail(collapsedFolderChevron.left, diffModeLabel.left);
|
||||
await folderRow.click();
|
||||
await dragOverlayScrollbarDown(page, diffScroll);
|
||||
|
||||
await page.getByTestId("explorer-tab-files").click();
|
||||
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
|
||||
const filesScroll = page.getByTestId("file-explorer-tree-scroll");
|
||||
await filesScroll.evaluate((element) => {
|
||||
element.style.scrollbarGutter = "stable";
|
||||
});
|
||||
expect(await readScrollbarGutter(filesScroll)).toBe(0);
|
||||
|
||||
await expect(page.getByTestId("file-explorer-row-0-actions")).toHaveCount(0);
|
||||
const fileExplorerRow = page.getByTestId("file-explorer-row-0");
|
||||
@@ -399,16 +537,14 @@ test("workspace file panes keep their controls on shared alignment rails", async
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
|
||||
page.getByTestId("files-sort-label").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
|
||||
readTextInkBounds(page.getByTestId("files-sort-label")),
|
||||
readSvgInkBounds(page.getByTestId("file-explorer-row-0").locator("svg").first()),
|
||||
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);
|
||||
expectSameRail(firstRowIcon.left, sortLabel.left);
|
||||
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
|
||||
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user