mirror of
https://github.com/getpaseo/paseo.git
synced 2026-09-14 20:36:44 +08:00
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:
+28
-1
@@ -124,8 +124,13 @@ quietly relying on:
|
||||
renders inside its host, not necessarily at window origin. Position anchored
|
||||
content relative to the host: `anchorRect - hostRect`. This is what
|
||||
`measureFloatingPanelPortalHost()` is for.
|
||||
- **React context.** `@gorhom/portal` is not a React portal — a real one keeps
|
||||
context, this one does not. It stores the element and the host renders it, so
|
||||
context resolves at the _host's_ position. Everything provided between
|
||||
`PortalProvider` in `app/_layout.tsx` and your sheet is invisible inside it.
|
||||
This is why app-wide providers wrap `PortalProvider` rather than the reverse.
|
||||
|
||||
The fix for transforms is Gotcha 3.
|
||||
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
|
||||
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
|
||||
|
||||
@@ -236,6 +241,28 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
|
||||
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
||||
track phase.
|
||||
|
||||
## Gotcha 7 — A sheet cannot read context from its call site
|
||||
|
||||
React cannot copy contexts reflectively, so the only way across the teleport in
|
||||
Gotcha 2 is to render the providers a second time, with values captured on the
|
||||
near side where they are still readable. `IsolatedBottomSheetModal` takes a
|
||||
`contextBridge` for exactly that:
|
||||
|
||||
```tsx
|
||||
const contextBridge = useCallback<ContextBridge>(
|
||||
(content) => <ThingContext.Provider value={thing}>{content}</ThingContext.Provider>,
|
||||
[thing],
|
||||
);
|
||||
```
|
||||
|
||||
The prop is **required**, and `null` is a real answer. A sheet whose content
|
||||
needs nothing local should have to say so, because the failure mode is silent
|
||||
until someone adds a `useContext` deep inside and it throws on device only —
|
||||
never on web, where the desktop path uses a real portal. `menu-surface.tsx`
|
||||
bridges the menu's two contexts; the rest pass `null`.
|
||||
|
||||
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
|
||||
@@ -23,6 +23,9 @@ sheets the same way a phone does.
|
||||
flipping every menu in the app to sheets at once is not a change anyone can review. Opt a menu in
|
||||
when you have actually looked at it on a phone.
|
||||
|
||||
`ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press.
|
||||
Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead.
|
||||
|
||||
## Pages
|
||||
|
||||
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
|
||||
@@ -114,6 +117,10 @@ its own.
|
||||
identity changes — a pushed page taller than the one it replaced is clipped without that.
|
||||
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
|
||||
rarely the height of the page before it.
|
||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
|
||||
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
|
||||
[floating-panels.md](floating-panels.md).
|
||||
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
|
||||
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
|
||||
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -99,11 +99,8 @@ test.describe("Host appearance", () => {
|
||||
await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
|
||||
});
|
||||
|
||||
test("long host names leave room for the workspace title and actions", async ({
|
||||
page,
|
||||
twoHostSidebar,
|
||||
}) => {
|
||||
const hostName = "A very long remote build host name that must not take over the sidebar";
|
||||
test("host names use the available metadata width", async ({ page, twoHostSidebar }) => {
|
||||
const hostName = "Developer MacBook Pro.local";
|
||||
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
@@ -113,25 +110,18 @@ test.describe("Host appearance", () => {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
|
||||
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
|
||||
await expect(badge).toHaveText(hostName);
|
||||
await row.hover();
|
||||
|
||||
const title = row.getByText("Secondary workspace", { exact: true });
|
||||
const kebab = page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`);
|
||||
await expect(kebab).toBeVisible();
|
||||
const [badgeBox, titleBox, rowBox, kebabBox] = await Promise.all([
|
||||
badge.boundingBox(),
|
||||
title.boundingBox(),
|
||||
row.boundingBox(),
|
||||
kebab.boundingBox(),
|
||||
]);
|
||||
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
|
||||
|
||||
expect(badgeBox).not.toBeNull();
|
||||
expect(titleBox).not.toBeNull();
|
||||
expect(rowBox).not.toBeNull();
|
||||
expect(kebabBox).not.toBeNull();
|
||||
await expect(badge).toHaveCSS("flex-shrink", "0");
|
||||
expect(titleBox!.width).toBeGreaterThan(0);
|
||||
expect(kebabBox!.x + kebabBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||
await expect(badge).toHaveCSS("flex-shrink", "1");
|
||||
expect(badgeBox!.width).toBeGreaterThan(96);
|
||||
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
|
||||
clientWidth: label.clientWidth,
|
||||
scrollWidth: label.scrollWidth,
|
||||
}));
|
||||
expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth);
|
||||
});
|
||||
|
||||
test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
async function paintedSvgRight(locator: Locator): Promise<number> {
|
||||
return locator.evaluateAll((svgs) => {
|
||||
let right = Number.NEGATIVE_INFINITY;
|
||||
for (const element of svgs) {
|
||||
const svg = element as SVGSVGElement;
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const viewBox = svg.viewBox.baseVal;
|
||||
if (svgRect.right <= 0 || svgRect.left >= window.innerWidth || viewBox.width === 0) continue;
|
||||
const scaleX = svgRect.width / viewBox.width;
|
||||
for (const child of svg.querySelectorAll<SVGGraphicsElement>(
|
||||
"path, circle, ellipse, line, polyline, polygon, rect",
|
||||
)) {
|
||||
const box = child.getBBox();
|
||||
const strokeWidth = Number.parseFloat(getComputedStyle(child).strokeWidth) || 0;
|
||||
const localRight = box.x + box.width + strokeWidth / 2;
|
||||
right = Math.max(right, svgRect.left + (localRight - viewBox.x) * scaleX);
|
||||
}
|
||||
}
|
||||
if (!Number.isFinite(right)) throw new Error("SVG has no measurable painted children");
|
||||
return right;
|
||||
});
|
||||
}
|
||||
|
||||
async function closeMenuSheet(page: Page): Promise<void> {
|
||||
const backdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
await backdrop.click({ position: { x: 12, y: 12 } });
|
||||
await expect(backdrop).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
const closeGlyphRight = await paintedSvgRight(page.getByTestId("sidebar-close").locator("svg"));
|
||||
const trailingRail = await paintedSvgRight(
|
||||
page.getByTestId("sidebar-display-preferences-menu").locator("svg"),
|
||||
);
|
||||
expect(closeGlyphRight).toBeCloseTo(trailingRail, 0);
|
||||
|
||||
await page
|
||||
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("Project actions", { exact: true })).toBeVisible();
|
||||
await closeMenuSheet(page);
|
||||
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await workspaceRow.hover();
|
||||
await workspaceRow
|
||||
.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${seeded.workspaceId}`)
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { test, expect, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher";
|
||||
@@ -25,6 +26,35 @@ function projectNewWorktreeIcon(page: Page, projectKey: string) {
|
||||
return page.getByTestId(`sidebar-project-new-worktree-${projectEquivalenceViewKey(projectKey)}`);
|
||||
}
|
||||
|
||||
async function paintedSvgRight(locator: Locator): Promise<number> {
|
||||
return locator.evaluate((svg) => {
|
||||
let right = Number.NEGATIVE_INFINITY;
|
||||
for (const child of svg.querySelectorAll<SVGGraphicsElement>(
|
||||
"path, circle, ellipse, line, polyline, polygon, rect",
|
||||
)) {
|
||||
const box = child.getBBox();
|
||||
const matrix = child.getScreenCTM();
|
||||
if (!matrix) continue;
|
||||
const geometryRight = new DOMPoint(box.x + box.width, box.y).matrixTransform(matrix).x;
|
||||
const strokeWidth = Number.parseFloat(getComputedStyle(child).strokeWidth) || 0;
|
||||
const scaleX = Math.hypot(matrix.a, matrix.b);
|
||||
right = Math.max(right, geometryRight + (strokeWidth * scaleX) / 2);
|
||||
}
|
||||
if (!Number.isFinite(right)) throw new Error("SVG has no measurable painted children");
|
||||
return right;
|
||||
});
|
||||
}
|
||||
|
||||
async function paintedTextRight(locator: Locator): Promise<number> {
|
||||
return locator.evaluate((label) => {
|
||||
const context = document.createElement("canvas").getContext("2d");
|
||||
if (!context) throw new Error("Canvas text measurement is unavailable");
|
||||
context.font = getComputedStyle(label).font;
|
||||
const metrics = context.measureText(label.textContent ?? "");
|
||||
return label.getBoundingClientRect().left + metrics.actualBoundingBoxRight;
|
||||
});
|
||||
}
|
||||
|
||||
async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> {
|
||||
const created = await seeded.client.createWorkspace({
|
||||
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId },
|
||||
@@ -86,6 +116,53 @@ test.describe("Model B sidebar shape", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("sidebar trailing glyphs share the workspace content rail", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "model-b-trailing-rail-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-show").click();
|
||||
await page.getByTestId("sidebar-workspace-trailing-timestamp").click();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
const project = projectRow(page, seeded.projectKey);
|
||||
const workspace = workspaceRow(page, seeded.workspaceId);
|
||||
const displayPreferencesGlyph = page
|
||||
.getByTestId("sidebar-display-preferences-menu")
|
||||
.locator("svg");
|
||||
|
||||
await project.hover();
|
||||
const projectKebabGlyph = page
|
||||
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
|
||||
.locator("svg");
|
||||
await expect(projectKebabGlyph).toBeVisible();
|
||||
const projectKebabRight = await paintedSvgRight(projectKebabGlyph);
|
||||
|
||||
await workspace.hover();
|
||||
const workspaceKebabGlyph = page
|
||||
.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${seeded.workspaceId}`)
|
||||
.locator("svg");
|
||||
const timestamp = workspace.getByTestId("sidebar-workspace-timestamp");
|
||||
await expect(workspaceKebabGlyph).toBeVisible();
|
||||
await expect(timestamp).toBeVisible();
|
||||
|
||||
const rail = await paintedTextRight(timestamp);
|
||||
for (const glyphRight of await Promise.all([
|
||||
paintedSvgRight(displayPreferencesGlyph),
|
||||
Promise.resolve(projectKebabRight),
|
||||
paintedSvgRight(workspaceKebabGlyph),
|
||||
])) {
|
||||
expect(glyphRight).toBeCloseTo(rail, 0);
|
||||
}
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("no tab, agent, or terminal ever renders as a sidebar row", async ({ page }) => {
|
||||
const mock = await seedMockAgentWorkspace({
|
||||
repoPrefix: "model-b-leaf-",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-explorer-toggle\""
|
||||
wait "id=\"explorer-tab-files\"" 10000
|
||||
press "id=\"explorer-tab-files\""
|
||||
wait "id=\"file-explorer-row-0\"" 10000
|
||||
longpress "id=\"file-explorer-row-0\"" 600
|
||||
wait "id=\"file-explorer-row-0-context-menu-content\"" 10000
|
||||
is visible "text=\"Copy path\""
|
||||
close
|
||||
@@ -940,9 +940,7 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<SidebarCalloutProvider>
|
||||
<ToastProvider>
|
||||
<ProvidersWrapper>{children}</ProvidersWrapper>
|
||||
</ToastProvider>
|
||||
<ProvidersWrapper>{children}</ProvidersWrapper>
|
||||
</SidebarCalloutProvider>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
);
|
||||
@@ -960,9 +958,11 @@ function RootProviders({ children }: { children: ReactNode }) {
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
<ToastProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</ToastProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
|
||||
@@ -592,6 +592,7 @@ export function CommandCenter() {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={COMMAND_CENTER_SNAP_POINTS}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -77,6 +78,7 @@ function HomeIcon({ size }: CommandCenterIconProps) {
|
||||
export function CommandCenterRootActions() {
|
||||
const { t } = useTranslation();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
|
||||
@@ -87,8 +89,8 @@ export function CommandCenterRootActions() {
|
||||
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
|
||||
[],
|
||||
);
|
||||
const actions = useMemo<CommandCenterContribution[]>(
|
||||
() => [
|
||||
const actions = useMemo<CommandCenterContribution[]>(() => {
|
||||
const availableActions: CommandCenterContribution[] = [
|
||||
{
|
||||
id: "add-project",
|
||||
group: "actions",
|
||||
@@ -204,7 +206,10 @@ export function CommandCenterRootActions() {
|
||||
undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (shortcutsAvailable) {
|
||||
availableActions.push({
|
||||
id: "keyboard-shortcuts",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
@@ -221,20 +226,22 @@ export function CommandCenterRootActions() {
|
||||
resolveShortcutKeysForAction("show-shortcuts", overrides, shortcutPlatform) ??
|
||||
undefined,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
homeRoute,
|
||||
openAddProject,
|
||||
overrides,
|
||||
schedulesRoute,
|
||||
sessionsRoute,
|
||||
setShortcutsDialogOpen,
|
||||
settingsRoute,
|
||||
shortcutPlatform,
|
||||
t,
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return availableActions;
|
||||
}, [
|
||||
homeRoute,
|
||||
openAddProject,
|
||||
overrides,
|
||||
schedulesRoute,
|
||||
sessionsRoute,
|
||||
setShortcutsDialogOpen,
|
||||
settingsRoute,
|
||||
shortcutPlatform,
|
||||
shortcutsAvailable,
|
||||
t,
|
||||
]);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
return null;
|
||||
|
||||
@@ -674,6 +674,7 @@ export function AdaptiveModalSheet({
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={resolvedSnapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -67,6 +67,7 @@ import {
|
||||
type ProjectPickerOption,
|
||||
} from "@/components/project-picker-options";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
@@ -287,6 +288,9 @@ function FlowRow({ option, active }: { option: FlowRowOption; active: boolean })
|
||||
}
|
||||
|
||||
function FlowHint({ keys, action }: { keys: string[]; action: string }) {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
if (!shortcutsAvailable) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.footerHint}>
|
||||
<Shortcut keys={keys} textStyle={styles.footerKeyText} />
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
@@ -20,9 +20,14 @@ import {
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
useOverlayFlatListScrollbar,
|
||||
type OverlayFlatListScrollbar,
|
||||
} from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type {
|
||||
AgentFileExplorerState,
|
||||
@@ -174,12 +179,7 @@ function TreeRowItem({
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
onPress={handlePress}
|
||||
style={pressableStyle}
|
||||
testID={testID}
|
||||
>
|
||||
<ContextMenuTrigger onPress={handlePress} style={pressableStyle} testID={testID}>
|
||||
<TreeIndentGuides depth={depth} />
|
||||
<View ref={dragSourceRef} style={styles.entryInfo}>
|
||||
<View style={styles.entryIcon}>
|
||||
@@ -226,6 +226,7 @@ export function FileExplorerPane({
|
||||
onAddToChat,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const workspaceStateKey = useMemo(
|
||||
@@ -278,6 +279,7 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
|
||||
const scrollbar = useOverlayFlatListScrollbar(treeListRef, { enabled: !isCompact });
|
||||
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
@@ -518,6 +520,7 @@ export function FileExplorerPane({
|
||||
currentSortLabel={currentSortLabel}
|
||||
isRefreshFetching={isRefreshFetching}
|
||||
treeListRef={treeListRef}
|
||||
scrollbar={scrollbar}
|
||||
renderTreeRow={renderTreeRow}
|
||||
handleSortCycle={handleSortCycle}
|
||||
handleToggleHiddenFiles={handleToggleHiddenFiles}
|
||||
@@ -539,6 +542,7 @@ interface FileExplorerPaneContentProps {
|
||||
currentSortLabel: string;
|
||||
isRefreshFetching: boolean;
|
||||
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
|
||||
scrollbar: OverlayFlatListScrollbar;
|
||||
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
|
||||
handleSortCycle: () => void;
|
||||
handleToggleHiddenFiles: () => void;
|
||||
@@ -560,6 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
currentSortLabel,
|
||||
isRefreshFetching,
|
||||
treeListRef,
|
||||
scrollbar,
|
||||
renderTreeRow,
|
||||
handleSortCycle,
|
||||
handleToggleHiddenFiles,
|
||||
@@ -682,12 +687,17 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
keyExtractor={treeRowKeyExtractor}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
showsVerticalScrollIndicator
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!scrollbar.enabled}
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
)}
|
||||
{treeRows.length > 0 ? scrollbar.overlay : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1055,7 +1065,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
},
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
|
||||
@@ -988,11 +988,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]), so the
|
||||
// title lands at spacing[2] left to align with project icons, and the trailing
|
||||
// pill sits flush with the list edge on the right.
|
||||
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
|
||||
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
|
||||
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: 0,
|
||||
paddingRight: 4,
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
@@ -1030,7 +1030,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
mobileCloseButton: {
|
||||
marginRight: theme.spacing[4],
|
||||
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
|
||||
// This optical inset puts their painted right edges on the same sidebar rail.
|
||||
marginRight: theme.spacing[2] + 1.5,
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -495,7 +495,7 @@ function ProjectKebabMenu({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={projectKebabStyle}
|
||||
@@ -505,7 +505,7 @@ function ProjectKebabMenu({
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<DropdownMenuContent align="end" width={220} sheetTitle={t("sidebar.project.actions.menu")}>
|
||||
<ProjectMenuItems
|
||||
surface="dropdown"
|
||||
projectViewKey={projectViewKey}
|
||||
@@ -2541,6 +2541,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
// MoreVertical paints only around the center of its 14px SVG. Keep the 24px controls,
|
||||
// but pull their painted edge through the unused view-box space onto the row rail.
|
||||
marginRight: -6,
|
||||
},
|
||||
projectKebabButton: {
|
||||
width: 24,
|
||||
|
||||
@@ -15,9 +15,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -78,11 +77,10 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const { t } = useTranslation();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
|
||||
const [open, setOpen] = useState(false);
|
||||
const showKeyboardShortcuts = !isNative && !isCompactLayout;
|
||||
const version = formatVersionWithPrefix(resolveAppVersion());
|
||||
const hosts = useHosts();
|
||||
|
||||
@@ -128,7 +126,7 @@ export function SidebarHelpMenu() {
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
|
||||
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
|
||||
{showKeyboardShortcuts ? (
|
||||
{shortcutsAvailable ? (
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-shortcuts"
|
||||
leading={shortcutsLeadingIcon}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -110,7 +110,7 @@ function SidebarWorkspaceMenuItems({
|
||||
}: SidebarWorkspaceMenuItemsProps & { surface: MenuSurface }): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
|
||||
@@ -205,7 +205,7 @@ export function SidebarWorkspaceMenu({
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={triggerStyle}
|
||||
@@ -215,7 +215,7 @@ export function SidebarWorkspaceMenu({
|
||||
>
|
||||
{renderTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
<DropdownMenuContent align="end" width={260} sheetTitle={t("sidebar.workspace.actions.menu")}>
|
||||
<SidebarWorkspaceMenuItems
|
||||
surface="dropdown"
|
||||
workspaceKey={workspaceKey}
|
||||
@@ -339,6 +339,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
|
||||
// pull the painted dots through that unused view-box space onto the trailing-content rail.
|
||||
marginRight: -7,
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -31,9 +31,6 @@ export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./scr
|
||||
*/
|
||||
const META_ICON_SIZE = 12;
|
||||
|
||||
/** Carried over from the host pill: a host can carry an arbitrary custom label. */
|
||||
const MAX_HOST_LABEL_WIDTH = 96;
|
||||
|
||||
const ThemedServer = withUnistyles(Server);
|
||||
const ThemedExternalLink = withUnistyles(ExternalLink);
|
||||
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
|
||||
@@ -283,16 +280,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
// A host label is arbitrary user text and the only item on the line that can be long, so
|
||||
// it is the one that gets capped and truncated. The change request, CI and service items
|
||||
// are bounded by their own content and must never be the thing that gives way.
|
||||
// The host is the only unbounded item, so it consumes the remaining width and gives way
|
||||
// before the bounded change request, CI, and service items.
|
||||
hostItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
flexShrink: 0,
|
||||
maxWidth: MAX_HOST_LABEL_WIDTH,
|
||||
flexShrink: 1,
|
||||
},
|
||||
itemPressed: {
|
||||
opacity: 0.82,
|
||||
|
||||
@@ -62,7 +62,7 @@ export function SidebarWorkspaceTrailingContent({
|
||||
function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) {
|
||||
const label = useCompactTimeAgo(enteredAt);
|
||||
return (
|
||||
<Text style={styles.timestamp} numberOfLines={1}>
|
||||
<Text style={styles.timestamp} numberOfLines={1} testID="sidebar-workspace-timestamp">
|
||||
{label}
|
||||
</Text>
|
||||
);
|
||||
|
||||
@@ -114,6 +114,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
{children}
|
||||
<IsolatedBottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -12,6 +12,12 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
// drift apart.
|
||||
export const TREE_INDENT_PER_LEVEL = 16;
|
||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||
/**
|
||||
* Trailing glyph rail shared with the explorer X and Changes options chevron.
|
||||
* The extra 2px is optical: text ink ends inside its layout box, while the
|
||||
* header icons' strokes extend to theirs.
|
||||
*/
|
||||
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
|
||||
|
||||
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
||||
* in the Changes tree so their indentation can't drift apart. */
|
||||
@@ -52,7 +58,13 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
|
||||
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
|
||||
export function TreeChevron({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<View style={expanded ? [styles.chevron, styles.chevronExpanded] : styles.chevron}>
|
||||
<View
|
||||
style={
|
||||
expanded
|
||||
? [styles.chevron, styles.chevronExpanded]
|
||||
: [styles.chevron, styles.chevronCollapsed]
|
||||
}
|
||||
>
|
||||
<ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} />
|
||||
</View>
|
||||
);
|
||||
@@ -73,7 +85,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Lucide's right-pointing path occupies the middle of its square view box.
|
||||
// Move the painted edge—not the 16px layout slot—onto the tree's glyph rail.
|
||||
chevronCollapsed: {
|
||||
left: -4,
|
||||
},
|
||||
chevronExpanded: {
|
||||
left: -2,
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -994,6 +994,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={props.bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={props.snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { useCallback, type PropsWithChildren, type ReactElement, type Ref } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
type ComponentProps,
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import {
|
||||
Platform,
|
||||
Pressable,
|
||||
@@ -31,13 +37,23 @@ import {
|
||||
* docs/menus.md. Only the way it opens is different from `dropdown-menu.tsx`.
|
||||
*/
|
||||
|
||||
export { MenuRoot as ContextMenu };
|
||||
export { MenuItem as ContextMenuItem };
|
||||
export { MenuLabel as ContextMenuLabel };
|
||||
export { MenuSeparator as ContextMenuSeparator };
|
||||
export { MenuHint as ContextMenuHint };
|
||||
export type { ActionStatus } from "@/components/ui/menu";
|
||||
|
||||
/**
|
||||
* Context menus use the mobile menu convention by default: long press opens a bottom sheet on a
|
||||
* compact layout, while right click opens an anchored popover on a wide layout.
|
||||
*/
|
||||
export function ContextMenu({
|
||||
compactMode = "sheet",
|
||||
...props
|
||||
}: ComponentProps<typeof MenuRoot>): ReactElement {
|
||||
return <MenuRoot {...props} compactMode={compactMode} />;
|
||||
}
|
||||
|
||||
export function ContextMenuContent(props: MenuSurfaceProps): ReactElement | null {
|
||||
return <MenuSurface {...props} />;
|
||||
}
|
||||
@@ -85,7 +101,7 @@ export function ContextMenuTrigger({
|
||||
disabled,
|
||||
style,
|
||||
enabled = true,
|
||||
enabledOnMobile = false,
|
||||
enabledOnMobile = true,
|
||||
enabledOnWeb = true,
|
||||
longPressDelayMs,
|
||||
triggerRef,
|
||||
|
||||
@@ -1,34 +1,75 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act } from "react";
|
||||
import React, { act, createContext, useContext, useSyncExternalStore } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* A faithful stand-in for `@gorhom/portal`: it renders *nothing* where it sits and hands its
|
||||
* element to a host somewhere else in the tree, which is what makes the real thing lose context.
|
||||
* A passthrough fake would keep context and quietly pass every test in this file — which is
|
||||
* exactly what happened, and why the menu sheet shipped broken.
|
||||
*/
|
||||
const portalStore = vi.hoisted(() => {
|
||||
const listeners = new Set<() => void>();
|
||||
let node: React.ReactNode = null;
|
||||
return {
|
||||
put(next: React.ReactNode) {
|
||||
node = next;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
read: () => node,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
reset() {
|
||||
node = null;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@gorhom/portal", () => ({
|
||||
Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) =>
|
||||
React.createElement("div", { "data-portal-host": hostName }, children),
|
||||
PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }),
|
||||
Portal: ({ children }: { children?: React.ReactNode }) => {
|
||||
React.useEffect(() => {
|
||||
portalStore.put(children);
|
||||
}, [children]);
|
||||
return null;
|
||||
},
|
||||
PortalHost: () => React.createElement("div", { "data-host": true }),
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", () => ({
|
||||
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
|
||||
BottomSheetModal: React.forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
stackBehavior,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
stackBehavior?: string;
|
||||
},
|
||||
_ref,
|
||||
) => React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
|
||||
),
|
||||
}));
|
||||
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
const { Portal } = await import("@gorhom/portal");
|
||||
return {
|
||||
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
|
||||
BottomSheetModal: React.forwardRef(
|
||||
({ children, stackBehavior }: { children?: React.ReactNode; stackBehavior?: string }, _ref) =>
|
||||
React.createElement(
|
||||
Portal,
|
||||
null,
|
||||
React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
import { IsolatedBottomSheetModal } from ".";
|
||||
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
|
||||
|
||||
/** Renders whatever the portal is currently holding, from its own place in the tree. */
|
||||
function PortalHostProbe() {
|
||||
const node = useSyncExternalStore(portalStore.subscribe, portalStore.read, portalStore.read);
|
||||
return <div data-portal-host>{node}</div>;
|
||||
}
|
||||
|
||||
const LabelContext = createContext("unbridged");
|
||||
|
||||
function Label() {
|
||||
return <div data-label={useContext(LabelContext)} />;
|
||||
}
|
||||
|
||||
describe("IsolatedBottomSheetModal presentation", () => {
|
||||
let container: HTMLDivElement;
|
||||
@@ -43,34 +84,94 @@ describe("IsolatedBottomSheetModal presentation", () => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
portalStore.reset();
|
||||
});
|
||||
|
||||
function stackBehaviors(): (string | null)[] {
|
||||
return Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
|
||||
node.getAttribute("data-stack-behavior"),
|
||||
);
|
||||
}
|
||||
|
||||
it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<IsolatedBottomSheetModal>Settings</IsolatedBottomSheetModal>
|
||||
<IsolatedBottomSheetModal>Diagnostic</IsolatedBottomSheetModal>
|
||||
<IsolatedBottomSheetModal contextBridge={null}>Settings</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(stackBehaviors()).toEqual(["push"]);
|
||||
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
|
||||
node.getAttribute("data-stack-behavior"),
|
||||
),
|
||||
).toEqual(["push", "push"]);
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<IsolatedBottomSheetModal contextBridge={null}>Diagnostic</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(stackBehaviors()).toEqual(["push"]);
|
||||
});
|
||||
|
||||
it("only replaces when the callsite asks for replacement", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<IsolatedBottomSheetModal presentation="replace">Selector</IsolatedBottomSheetModal>,
|
||||
<>
|
||||
<IsolatedBottomSheetModal presentation="replace" contextBridge={null}>
|
||||
Selector
|
||||
</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"),
|
||||
).toBe("replace");
|
||||
expect(stackBehaviors()).toEqual(["replace"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IsolatedBottomSheetModal context bridging", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
portalStore.reset();
|
||||
});
|
||||
|
||||
function renderSheet(contextBridge: ContextBridge | null) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<LabelContext.Provider value="from callsite">
|
||||
<IsolatedBottomSheetModal contextBridge={contextBridge}>
|
||||
<Label />
|
||||
</IsolatedBottomSheetModal>
|
||||
</LabelContext.Provider>
|
||||
{/* Outside the provider, standing in for the app-root portal host. */}
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
return container.querySelector("[data-label]")?.getAttribute("data-label");
|
||||
}
|
||||
|
||||
it("loses callsite context without a bridge", () => {
|
||||
expect(renderSheet(null)).toBe("unbridged");
|
||||
});
|
||||
|
||||
it("carries callsite context across the portal when bridged", () => {
|
||||
const bridge: ContextBridge = (children) => (
|
||||
<LabelContext.Provider value="from callsite">{children}</LabelContext.Provider>
|
||||
);
|
||||
expect(renderSheet(bridge)).toBe("from callsite");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import React from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ElementRef } from "react";
|
||||
import type { ElementRef, ReactNode } from "react";
|
||||
import {
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
@@ -12,11 +12,42 @@ import {
|
||||
|
||||
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
|
||||
|
||||
/**
|
||||
* Re-establishes React context on the far side of the portal.
|
||||
*
|
||||
* `@gorhom/portal` is not a React portal. It stores the element and a host elsewhere in the tree
|
||||
* renders it, so context resolves at the *host's* position: everything provided between
|
||||
* `PortalProvider` (see `app/_layout.tsx`) and this sheet is invisible to its content. React
|
||||
* cannot copy contexts reflectively, so the only way across is to render the providers again —
|
||||
* with values captured out here, where they are still readable.
|
||||
*
|
||||
* Write it as a closure over what you already have:
|
||||
*
|
||||
* ```tsx
|
||||
* const contextBridge = useCallback<ContextBridge>(
|
||||
* (content) => <ThingContext.Provider value={thing}>{content}</ThingContext.Provider>,
|
||||
* [thing],
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export type ContextBridge = (children: ReactNode) => ReactNode;
|
||||
|
||||
type IsolatedBottomSheetModalProps = Omit<
|
||||
BottomSheetModalProps,
|
||||
"enableDismissOnClose" | "stackBehavior"
|
||||
"enableDismissOnClose" | "stackBehavior" | "children"
|
||||
> & {
|
||||
/**
|
||||
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
|
||||
* would have to reach around it.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
presentation?: "push" | "replace";
|
||||
/**
|
||||
* Required, and `null` is a real answer: a sheet that needs nothing from its call site should
|
||||
* have to say so. The failure it prevents is invisible until someone adds a `useContext` deep
|
||||
* inside the sheet and it throws on device only.
|
||||
*/
|
||||
contextBridge: ContextBridge | null;
|
||||
};
|
||||
|
||||
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
|
||||
@@ -25,7 +56,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
IsolatedBottomSheetModalRef,
|
||||
IsolatedBottomSheetModalProps
|
||||
>(function IsolatedBottomSheetModal(props, ref) {
|
||||
const { children, presentation = "push", ...bottomSheetProps } = props;
|
||||
const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props;
|
||||
const modal = (
|
||||
<GorhomBottomSheetModal
|
||||
{...bottomSheetProps}
|
||||
@@ -33,7 +64,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
enableDismissOnClose
|
||||
stackBehavior={presentation}
|
||||
>
|
||||
{children}
|
||||
{contextBridge ? contextBridge(children) : children}
|
||||
</GorhomBottomSheetModal>
|
||||
);
|
||||
|
||||
|
||||
@@ -17,9 +17,10 @@ import { ChevronLeft } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
type ContextBridge,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useMenuContext, MenuDepthProvider } from "./menu-context";
|
||||
import { useMenuContext, MenuContextProvider, MenuDepthProvider } from "./menu-context";
|
||||
import { currentPageId, isSubPageOpen } from "./menu-navigation";
|
||||
import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
|
||||
import type { Alignment, Placement } from "./menu-anchor";
|
||||
@@ -327,40 +328,51 @@ function MenuSheetSurface({
|
||||
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
|
||||
const depth = menu.path.length;
|
||||
|
||||
// The sheet's content is teleported out of this subtree, so both menu contexts have to be
|
||||
// rebuilt on the other side. Providing them around the modal instead would put them on the
|
||||
// wrong side of the portal and every item inside would throw. See `ContextBridge`.
|
||||
const contextBridge = useCallback<ContextBridge>(
|
||||
(content) => (
|
||||
<MenuContextProvider value={menu}>
|
||||
<MenuSurfaceContext.Provider value={surfaceValue}>{content}</MenuSurfaceContext.Provider>
|
||||
</MenuContextProvider>
|
||||
),
|
||||
[menu, surfaceValue],
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuSurfaceContext.Provider value={surfaceValue}>
|
||||
<ThemedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
|
||||
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
|
||||
enableDynamicSizing
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
<ThemedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={contextBridge}
|
||||
// Content-sized rather than fixed snap points: a pushed page is rarely the same height
|
||||
// as the page it replaced, and a fixed sheet would either clip it or leave dead space.
|
||||
enableDynamicSizing
|
||||
onChange={handleSheetChange}
|
||||
onDismiss={handleSheetDismiss}
|
||||
backdropComponent={renderBackdrop}
|
||||
enablePanDownToClose
|
||||
keyboardBehavior="extend"
|
||||
keyboardBlurBehavior="restore"
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.sheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
<BottomSheetScrollView
|
||||
contentContainerStyle={styles.sheetScrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
testID={testID ? `${testID}-content` : undefined}
|
||||
>
|
||||
{openPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
|
||||
<MenuDepthProvider value={depth}>{openPage.content}</MenuDepthProvider>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
|
||||
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</ThemedBottomSheetModal>
|
||||
</MenuSurfaceContext.Provider>
|
||||
{openPage ? (
|
||||
<>
|
||||
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
|
||||
<MenuDepthProvider value={depth}>{openPage.content}</MenuDepthProvider>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
|
||||
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
|
||||
</>
|
||||
)}
|
||||
</BottomSheetScrollView>
|
||||
</ThemedBottomSheetModal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry";
|
||||
|
||||
describe("computeOverlayScrollbarGeometry", () => {
|
||||
it("hides the thumb when the content fits", () => {
|
||||
expect(
|
||||
computeOverlayScrollbarGeometry({ viewportSize: 500, contentSize: 500, offset: 0 }),
|
||||
).toEqual({
|
||||
isVisible: false,
|
||||
maxScrollOffset: 0,
|
||||
thumbSize: 0,
|
||||
thumbOffset: 0,
|
||||
maxThumbOffset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("places a proportional thumb for overflowing content", () => {
|
||||
expect(
|
||||
computeOverlayScrollbarGeometry({ viewportSize: 500, contentSize: 2000, offset: 375 }),
|
||||
).toEqual({
|
||||
isVisible: true,
|
||||
maxScrollOffset: 1500,
|
||||
thumbSize: 125,
|
||||
thumbOffset: 93.75,
|
||||
maxThumbOffset: 375,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrollOffsetFromThumbDrag", () => {
|
||||
it("maps thumb travel to the scroll range and clamps it", () => {
|
||||
expect(
|
||||
scrollOffsetFromThumbDrag({
|
||||
startOffset: 250,
|
||||
dragDelta: 50,
|
||||
maxScrollOffset: 1000,
|
||||
maxThumbOffset: 200,
|
||||
}),
|
||||
).toBe(500);
|
||||
expect(
|
||||
scrollOffsetFromThumbDrag({
|
||||
startOffset: 900,
|
||||
dragDelta: 1000,
|
||||
maxScrollOffset: 1000,
|
||||
maxThumbOffset: 200,
|
||||
}),
|
||||
).toBe(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
const DEFAULT_MIN_THUMB_SIZE = 36;
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export interface OverlayScrollbarGeometryInput {
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
offset: number;
|
||||
minThumbSize?: number;
|
||||
}
|
||||
|
||||
export interface OverlayScrollbarGeometry {
|
||||
isVisible: boolean;
|
||||
maxScrollOffset: number;
|
||||
thumbSize: number;
|
||||
thumbOffset: number;
|
||||
maxThumbOffset: number;
|
||||
}
|
||||
|
||||
export function computeOverlayScrollbarGeometry(
|
||||
input: OverlayScrollbarGeometryInput,
|
||||
): OverlayScrollbarGeometry {
|
||||
const viewportSize = Number.isFinite(input.viewportSize) ? Math.max(0, input.viewportSize) : 0;
|
||||
const contentSize = Number.isFinite(input.contentSize) ? Math.max(0, input.contentSize) : 0;
|
||||
const minThumbSize = Number.isFinite(input.minThumbSize)
|
||||
? Math.max(0, input.minThumbSize ?? DEFAULT_MIN_THUMB_SIZE)
|
||||
: DEFAULT_MIN_THUMB_SIZE;
|
||||
const maxScrollOffset = Math.max(0, contentSize - viewportSize);
|
||||
|
||||
if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) {
|
||||
return {
|
||||
isVisible: false,
|
||||
maxScrollOffset: 0,
|
||||
thumbSize: 0,
|
||||
thumbOffset: 0,
|
||||
maxThumbOffset: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const thumbSize = clamp((viewportSize * viewportSize) / contentSize, minThumbSize, viewportSize);
|
||||
const maxThumbOffset = Math.max(0, viewportSize - thumbSize);
|
||||
const thumbOffset = (clamp(input.offset, 0, maxScrollOffset) / maxScrollOffset) * maxThumbOffset;
|
||||
|
||||
return { isVisible: true, maxScrollOffset, thumbSize, thumbOffset, maxThumbOffset };
|
||||
}
|
||||
|
||||
export function scrollOffsetFromThumbDrag(input: {
|
||||
startOffset: number;
|
||||
dragDelta: number;
|
||||
maxScrollOffset: number;
|
||||
maxThumbOffset: number;
|
||||
}): number {
|
||||
if (input.maxScrollOffset <= 0 || input.maxThumbOffset <= 0) {
|
||||
return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset));
|
||||
}
|
||||
return clamp(
|
||||
input.startOffset + input.dragDelta * (input.maxScrollOffset / input.maxThumbOffset),
|
||||
0,
|
||||
input.maxScrollOffset,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import type {
|
||||
FlatList,
|
||||
LayoutChangeEvent,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
} from "react-native";
|
||||
|
||||
export interface OverlayFlatListScrollbar {
|
||||
enabled: boolean;
|
||||
onContentSizeChange: (width: number, height: number) => void;
|
||||
onLayout: (event: LayoutChangeEvent) => void;
|
||||
onScroll: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
||||
overlay: ReactNode;
|
||||
}
|
||||
|
||||
const ignoreContentSize = () => {};
|
||||
const ignoreLayout = (_event: LayoutChangeEvent) => {};
|
||||
const ignoreScroll = (_event: NativeSyntheticEvent<NativeScrollEvent>) => {};
|
||||
|
||||
export function useOverlayFlatListScrollbar<ItemT>(
|
||||
_listRef: RefObject<FlatList<ItemT> | null>,
|
||||
_options: { enabled: boolean },
|
||||
): OverlayFlatListScrollbar {
|
||||
return {
|
||||
enabled: false,
|
||||
onContentSizeChange: ignoreContentSize,
|
||||
onLayout: ignoreLayout,
|
||||
onScroll: ignoreScroll,
|
||||
overlay: null,
|
||||
};
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
FlatList,
|
||||
View,
|
||||
type LayoutChangeEvent,
|
||||
type NativeScrollEvent,
|
||||
type NativeSyntheticEvent,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry";
|
||||
import type { OverlayFlatListScrollbar } from "./use-overlay-flat-list-scrollbar";
|
||||
|
||||
const THUMB_WIDTH = WEB_SCROLLBAR_SIZE_PX - 4;
|
||||
const THUMB_OPACITY = 0.62;
|
||||
const GRAB_WIDTH = 12;
|
||||
|
||||
interface ScrollbarMetrics {
|
||||
offset: number;
|
||||
viewportSize: number;
|
||||
contentSize: number;
|
||||
}
|
||||
|
||||
interface PointerLikeEvent {
|
||||
clientY?: number;
|
||||
nativeEvent?: { clientY?: number; preventDefault?: () => void };
|
||||
preventDefault?: () => void;
|
||||
stopPropagation?: () => void;
|
||||
}
|
||||
|
||||
function readClientY(event: PointerLikeEvent): number | null {
|
||||
const value = event.nativeEvent?.clientY ?? event.clientY;
|
||||
return typeof value === "number" ? value : null;
|
||||
}
|
||||
|
||||
function OverlayScrollbar({
|
||||
listRef,
|
||||
metrics,
|
||||
}: {
|
||||
listRef: RefObject<FlatList<unknown> | null>;
|
||||
metrics: ScrollbarMetrics;
|
||||
}) {
|
||||
const geometry = useMemo(() => computeOverlayScrollbarGeometry(metrics), [metrics]);
|
||||
const geometryRef = useRef(geometry);
|
||||
const dragStartClientYRef = useRef(0);
|
||||
const dragStartOffsetRef = useRef(0);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
geometryRef.current = geometry;
|
||||
}, [geometry]);
|
||||
|
||||
const scrollFromDrag = useCallback(
|
||||
(dragDelta: number) => {
|
||||
const current = geometryRef.current;
|
||||
listRef.current?.scrollToOffset({
|
||||
offset: scrollOffsetFromThumbDrag({
|
||||
startOffset: dragStartOffsetRef.current,
|
||||
dragDelta,
|
||||
maxScrollOffset: current.maxScrollOffset,
|
||||
maxThumbOffset: current.maxThumbOffset,
|
||||
}),
|
||||
animated: false,
|
||||
});
|
||||
},
|
||||
[listRef],
|
||||
);
|
||||
|
||||
const startDrag = useCallback(
|
||||
(event: PointerLikeEvent) => {
|
||||
const clientY = readClientY(event);
|
||||
if (clientY === null) return;
|
||||
event.preventDefault?.();
|
||||
event.stopPropagation?.();
|
||||
event.nativeEvent?.preventDefault?.();
|
||||
dragStartClientYRef.current = clientY;
|
||||
dragStartOffsetRef.current = metrics.offset;
|
||||
setIsDragging(true);
|
||||
},
|
||||
[metrics.offset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
scrollFromDrag(event.clientY - dragStartClientYRef.current);
|
||||
};
|
||||
const stopDragging = () => setIsDragging(false);
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", stopDragging);
|
||||
window.addEventListener("pointercancel", stopDragging);
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", stopDragging);
|
||||
window.removeEventListener("pointercancel", stopDragging);
|
||||
};
|
||||
}, [isDragging, scrollFromDrag]);
|
||||
|
||||
const thumbRegionStyle = useMemo(
|
||||
() => [
|
||||
styles.thumbRegion,
|
||||
inlineUnistylesStyle({
|
||||
height: geometry.thumbSize,
|
||||
transform: [{ translateY: geometry.thumbOffset }],
|
||||
cursor: isDragging ? "grabbing" : "grab",
|
||||
} as unknown as ViewStyle),
|
||||
],
|
||||
[geometry.thumbOffset, geometry.thumbSize, isDragging],
|
||||
);
|
||||
|
||||
if (!geometry.isVisible) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.overlay} pointerEvents="box-none" testID="workspace-overlay-scrollbar">
|
||||
<View
|
||||
style={thumbRegionStyle}
|
||||
{...({ onPointerDown: startDrag } as object)}
|
||||
testID="workspace-overlay-scrollbar-grab"
|
||||
>
|
||||
<View
|
||||
style={styles.thumb}
|
||||
pointerEvents="none"
|
||||
testID="workspace-overlay-scrollbar-thumb"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function useOverlayFlatListScrollbar<ItemT>(
|
||||
listRef: RefObject<FlatList<ItemT> | null>,
|
||||
options: { enabled: boolean },
|
||||
): OverlayFlatListScrollbar {
|
||||
const [metrics, setMetrics] = useState<ScrollbarMetrics>({
|
||||
offset: 0,
|
||||
viewportSize: 0,
|
||||
contentSize: 0,
|
||||
});
|
||||
|
||||
const onScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
setMetrics((previous) => ({
|
||||
...previous,
|
||||
offset: Math.max(0, event.nativeEvent.contentOffset.y),
|
||||
viewportSize: Math.max(0, event.nativeEvent.layoutMeasurement.height),
|
||||
contentSize: Math.max(0, event.nativeEvent.contentSize.height),
|
||||
}));
|
||||
}, []);
|
||||
const onLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
setMetrics((previous) => ({
|
||||
...previous,
|
||||
viewportSize: Math.max(0, event.nativeEvent.layout.height),
|
||||
}));
|
||||
}, []);
|
||||
const onContentSizeChange = useCallback((_width: number, height: number) => {
|
||||
setMetrics((previous) => ({ ...previous, contentSize: Math.max(0, height) }));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
enabled: options.enabled,
|
||||
onContentSizeChange,
|
||||
onLayout,
|
||||
onScroll,
|
||||
overlay: options.enabled ? (
|
||||
<OverlayScrollbar
|
||||
listRef={listRef as RefObject<FlatList<unknown> | null>}
|
||||
metrics={metrics}
|
||||
/>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
overlay: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: WEB_SCROLLBAR_SIZE_PX,
|
||||
zIndex: 10,
|
||||
},
|
||||
thumbRegion: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
right: -(GRAB_WIDTH - WEB_SCROLLBAR_SIZE_PX) / 2,
|
||||
width: GRAB_WIDTH,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
touchAction: "none",
|
||||
userSelect: "none",
|
||||
},
|
||||
thumb: {
|
||||
width: THUMB_WIDTH,
|
||||
height: "100%",
|
||||
borderRadius: theme.borderRadius.full,
|
||||
backgroundColor: theme.colors.scrollbarHandle,
|
||||
opacity: THUMB_OPACITY,
|
||||
},
|
||||
}));
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useMemo, type ReactElement } from "react";
|
||||
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
|
||||
@@ -14,7 +15,8 @@ export function Shortcut({
|
||||
chord?: ShortcutKey[][];
|
||||
style?: StyleProp<ViewStyle>;
|
||||
textStyle?: StyleProp<TextStyle>;
|
||||
}): ReactElement {
|
||||
}): ReactElement | null {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const displayChord = chord ?? (keys ? [keys] : []);
|
||||
const shortcutOs = getShortcutOs();
|
||||
const singleCombo = displayChord[0];
|
||||
@@ -23,6 +25,10 @@ export function Shortcut({
|
||||
const textCombinedStyle = useMemo(() => [styles.text, textStyle], [textStyle]);
|
||||
const sequenceStyle = useMemo(() => [styles.sequence, style], [style]);
|
||||
|
||||
if (!shortcutsAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!singleCombo) {
|
||||
return <View style={style} />;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
import { type Theme } from "@/styles/theme";
|
||||
@@ -103,7 +104,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
folderRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
|
||||
@@ -48,6 +48,7 @@ import { DiffFolderRow } from "@/git/diff-folder-row";
|
||||
import {
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
} from "@/components/tree-primitives";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
@@ -92,6 +93,7 @@ import { useCheckoutGitActionsStore } from "@/git/actions-store";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar";
|
||||
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
import { usePanelStore } from "@/stores/panel-store";
|
||||
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
@@ -125,8 +127,11 @@ export function resolveDiffLayout(
|
||||
return canUseSplitLayout ? layout : "unified";
|
||||
}
|
||||
|
||||
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) {
|
||||
return [styles.fileHeader, pressed && styles.fileHeaderPressed];
|
||||
function fileHeaderPressableStyle({
|
||||
hovered,
|
||||
pressed,
|
||||
}: PressableStateCallbackType & { hovered?: boolean }) {
|
||||
return [styles.fileHeader, (Boolean(hovered) || pressed) && styles.fileHeaderActive];
|
||||
}
|
||||
|
||||
interface HighlightedTextProps {
|
||||
@@ -972,6 +977,10 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleLongPress = useCallback(() => {
|
||||
pressHandledRef.current = true;
|
||||
}, []);
|
||||
|
||||
const handlePressOut = useCallback(
|
||||
(event: { nativeEvent: { pageX: number; pageY: number } }) => {
|
||||
if (
|
||||
@@ -1051,13 +1060,13 @@ const DiffFileHeader = memo(function DiffFileHeader({
|
||||
} else {
|
||||
trigger = (
|
||||
<ContextMenuTrigger
|
||||
enabledOnMobile={false}
|
||||
testID={testID ? `${testID}-toggle` : undefined}
|
||||
style={headerPressableStyle}
|
||||
// Android: prevent parent pan/scroll gestures from canceling the tap release.
|
||||
cancelable={false}
|
||||
onPressIn={handlePressIn}
|
||||
onPressOut={handlePressOut}
|
||||
onLongPress={handleLongPress}
|
||||
onPress={toggleExpanded}
|
||||
>
|
||||
{headerContent}
|
||||
@@ -1831,6 +1840,7 @@ interface SharedDiffViewProps {
|
||||
}
|
||||
|
||||
export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences;
|
||||
const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
|
||||
const typographyKey = [monoFontFamily, codeFontSize, diffBodyLineHeight].join(":");
|
||||
@@ -1876,6 +1886,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
[allFolderPathSet, collapsedFolders],
|
||||
);
|
||||
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
|
||||
const scrollbar = useOverlayFlatListScrollbar(diffListRef, { enabled: !isCompact });
|
||||
const { onLayout: updateScrollbarLayout, onScroll: updateScrollbarOffset } = scrollbar;
|
||||
const consumedFocusRequestRef = useRef<string | null>(null);
|
||||
const pendingFocusRequestRef = useRef<string | null>(null);
|
||||
const diffListScrollOffsetRef = useRef(0);
|
||||
@@ -2025,17 +2037,25 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
[getBodyHeightKey, scheduleHeightVersionUpdate],
|
||||
);
|
||||
|
||||
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
}, []);
|
||||
const handleDiffListScroll = useCallback(
|
||||
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
|
||||
updateScrollbarOffset(event);
|
||||
},
|
||||
[updateScrollbarOffset],
|
||||
);
|
||||
|
||||
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
}, []);
|
||||
const handleDiffListLayout = useCallback(
|
||||
(event: LayoutChangeEvent) => {
|
||||
const height = event.nativeEvent.layout.height;
|
||||
if (!Number.isFinite(height) || height <= 0) {
|
||||
return;
|
||||
}
|
||||
diffListViewportHeightRef.current = height;
|
||||
updateScrollbarLayout(event);
|
||||
},
|
||||
[updateScrollbarLayout],
|
||||
);
|
||||
|
||||
const computeItemOffset = useCallback(
|
||||
(predicate: (item: DiffFlatItem) => boolean): number | null => {
|
||||
@@ -2269,26 +2289,30 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
|
||||
);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
ref={diffListRef}
|
||||
data={flatItems}
|
||||
renderItem={renderFlatItem}
|
||||
keyExtractor={flatKeyExtractor}
|
||||
getItemLayout={getFlatItemLayout}
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
extraData={flatExtraData}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="git-diff-scroll"
|
||||
onLayout={handleDiffListLayout}
|
||||
onScroll={handleDiffListScroll}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator
|
||||
removeClippedSubviews={false}
|
||||
initialNumToRender={12}
|
||||
maxToRenderPerBatch={12}
|
||||
windowSize={10}
|
||||
/>
|
||||
<View style={styles.scrollContainer}>
|
||||
<FlatList
|
||||
ref={diffListRef}
|
||||
data={flatItems}
|
||||
renderItem={renderFlatItem}
|
||||
keyExtractor={flatKeyExtractor}
|
||||
getItemLayout={getFlatItemLayout}
|
||||
stickyHeaderIndices={stickyHeaderIndices}
|
||||
extraData={flatExtraData}
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
testID="git-diff-scroll"
|
||||
onLayout={handleDiffListLayout}
|
||||
onScroll={handleDiffListScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!scrollbar.enabled}
|
||||
removeClippedSubviews={false}
|
||||
initialNumToRender={12}
|
||||
maxToRenderPerBatch={12}
|
||||
windowSize={10}
|
||||
/>
|
||||
{scrollbar.overlay}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3050,6 +3074,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContainer: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
position: "relative",
|
||||
},
|
||||
contentContainer: {
|
||||
paddingBottom: theme.spacing[8],
|
||||
},
|
||||
@@ -3110,15 +3139,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingLeft: theme.spacing[3],
|
||||
paddingRight: theme.spacing[3],
|
||||
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
gap: theme.spacing[1],
|
||||
minWidth: 0,
|
||||
zIndex: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
fileHeaderPressed: {
|
||||
opacity: 0.7,
|
||||
fileHeaderActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
},
|
||||
fileHeaderLeft: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -29,6 +29,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
|
||||
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
|
||||
import {
|
||||
@@ -59,6 +60,7 @@ export function useKeyboardShortcuts({
|
||||
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
|
||||
const shortcutsAvailable = keyboardShortcutsAvailable({ isNative, isCompact: isMobile });
|
||||
const isDesktopApp = getIsElectronRuntime();
|
||||
const isMac = getShortcutOs() === "mac";
|
||||
const chordStateRef = useRef<ChordState>({
|
||||
@@ -73,7 +75,7 @@ export function useKeyboardShortcuts({
|
||||
const publishBrowserShortcutPolicy = useCallback(
|
||||
(chordState?: ChordState) => {
|
||||
const policy =
|
||||
enabled && !isMobile
|
||||
enabled && shortcutsAvailable
|
||||
? buildBrowserKeyboardPolicy({
|
||||
bindings,
|
||||
chordState,
|
||||
@@ -83,7 +85,7 @@ export function useKeyboardShortcuts({
|
||||
: { menuPrefixes: [], prefixes: [] };
|
||||
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
|
||||
},
|
||||
[bindings, enabled, isDesktopApp, isMac, isMobile],
|
||||
[bindings, enabled, isDesktopApp, isMac, shortcutsAvailable],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,8 +104,7 @@ export function useKeyboardShortcuts({
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (isNative) return;
|
||||
if (isMobile) return;
|
||||
if (!shortcutsAvailable) return;
|
||||
|
||||
// Only the modifier that actually performs the workspace-index jump on this
|
||||
// runtime should reveal the sidebar number badges (Alt on web, Cmd on
|
||||
@@ -390,6 +391,7 @@ export function useKeyboardShortcuts({
|
||||
publishBrowserShortcutPolicy,
|
||||
resetModifiers,
|
||||
router,
|
||||
shortcutsAvailable,
|
||||
toggleAgentList,
|
||||
toggleBothSidebars,
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { keyboardShortcutsAvailable } from "./availability";
|
||||
|
||||
describe("keyboardShortcutsAvailable", () => {
|
||||
it("matches the environments where the shortcut dispatcher runs", () => {
|
||||
expect(keyboardShortcutsAvailable({ isNative: false, isCompact: false })).toBe(true);
|
||||
expect(keyboardShortcutsAvailable({ isNative: false, isCompact: true })).toBe(false);
|
||||
expect(keyboardShortcutsAvailable({ isNative: true, isCompact: false })).toBe(false);
|
||||
expect(keyboardShortcutsAvailable({ isNative: true, isCompact: true })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
|
||||
interface KeyboardShortcutEnvironment {
|
||||
isNative: boolean;
|
||||
isCompact: boolean;
|
||||
}
|
||||
|
||||
export function keyboardShortcutsAvailable({
|
||||
isNative: native,
|
||||
isCompact,
|
||||
}: KeyboardShortcutEnvironment): boolean {
|
||||
return !native && !isCompact;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcutsAvailable(): boolean {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
return keyboardShortcutsAvailable({ isNative, isCompact });
|
||||
}
|
||||
@@ -93,7 +93,7 @@ vi.mock("react-native-unistyles", () => ({
|
||||
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
|
||||
},
|
||||
withUnistyles: <T,>(component: T) => component,
|
||||
useUnistyles: () => ({ theme }),
|
||||
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
|
||||
}));
|
||||
|
||||
vi.mock("@/constants/platform", () => ({
|
||||
|
||||
Reference in New Issue
Block a user