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:
Mohamed Boudra
2026-08-03 21:40:21 +02:00
parent ab8151d33f
commit a361d1cb2f
37 changed files with 1153 additions and 208 deletions
@@ -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);
});