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
+28 -1
View File
@@ -124,8 +124,13 @@ quietly relying on:
renders inside its host, not necessarily at window origin. Position anchored renders inside its host, not necessarily at window origin. Position anchored
content relative to the host: `anchorRect - hostRect`. This is what content relative to the host: `anchorRect - hostRect`. This is what
`measureFloatingPanelPortalHost()` is for. `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` ## 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 another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
track phase. 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 ## Recipe for a new anchored panel
Before you write a new one, ask: Before you write a new one, ask:
+7
View File
@@ -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 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. 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 ## Pages
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id` 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. 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 - **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it. 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 - **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. 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 - 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 { unlink, writeFile } from "node:fs/promises";
import path from "node:path"; 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 { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes";
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import { getServerId } from "../support/helpers/server-id"; import { getServerId } from "../support/helpers/server-id";
@@ -25,6 +25,103 @@ const cleanupTasks: CleanupTask[] = [];
const APP_SETTINGS_KEY = "@paseo:app-settings"; const APP_SETTINGS_KEY = "@paseo:app-settings";
const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences"; 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"; const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react";
interface UseMountedTabSetInput { interface UseMountedTabSetInput {
@@ -370,21 +467,62 @@ test("workspace file panes keep their controls on shared alignment rails", async
const workspace = await createWorkspaceWithMountedTabDiff(); const workspace = await createWorkspaceWithMountedTabDiff();
await openWorkspaceChanges(page, workspace); 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 page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible(); await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0-actions")).toHaveCount(0); await expect(page.getByTestId("diff-file-0-actions")).toHaveCount(0);
const [folderStat, fileStat] = await Promise.all([ const folderRow = page.getByTestId("diff-folder-src-toggle");
page.getByTestId("diff-folder-src-stat").boundingBox(), const fileRow = page.getByTestId("diff-file-0-toggle");
page.getByTestId("diff-file-0-stat").boundingBox(), await folderRow.hover();
]); const folderHoverColor = await folderRow.evaluate((row) => getComputedStyle(row).backgroundColor);
expect(folderStat).not.toBeNull(); await fileRow.hover();
expect(fileStat).not.toBeNull(); const fileHoverColor = await fileRow.evaluate((row) => getComputedStyle(row).backgroundColor);
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0); 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 page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible(); 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); await expect(page.getByTestId("file-explorer-row-0-actions")).toHaveCount(0);
const fileExplorerRow = page.getByTestId("file-explorer-row-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"); await page.keyboard.press("Escape");
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([ const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(), readTextInkBounds(page.getByTestId("files-sort-label")),
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(), readSvgInkBounds(page.getByTestId("file-explorer-row-0").locator("svg").first()),
page.getByTestId("file-explorer-tree-scroll").boundingBox(), page.getByTestId("file-explorer-tree-scroll").boundingBox(),
page.getByTestId("file-explorer-row-0").boundingBox(), page.getByTestId("file-explorer-row-0").boundingBox(),
]); ]);
expect(sortLabel).not.toBeNull();
expect(firstRowIcon).not.toBeNull();
expect(treeBounds).not.toBeNull(); expect(treeBounds).not.toBeNull();
expect(rowBounds).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).toBeCloseTo(treeBounds!.x, 0);
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 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" }); await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
}); });
test("long host names leave room for the workspace title and actions", async ({ test("host names use the available metadata width", async ({ page, twoHostSidebar }) => {
page, const hostName = "Developer MacBook Pro.local";
twoHostSidebar,
}) => {
const hostName = "A very long remote build host name that must not take over the sidebar";
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`; const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
@@ -113,25 +110,18 @@ test.describe("Host appearance", () => {
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`); const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`); const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
await expect(badge).toHaveText(hostName); await expect(badge).toHaveText(hostName);
await row.hover(); const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
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(),
]);
expect(badgeBox).not.toBeNull(); expect(badgeBox).not.toBeNull();
expect(titleBox).not.toBeNull();
expect(rowBox).not.toBeNull(); expect(rowBox).not.toBeNull();
expect(kebabBox).not.toBeNull(); await expect(badge).toHaveCSS("flex-shrink", "1");
await expect(badge).toHaveCSS("flex-shrink", "0"); expect(badgeBox!.width).toBeGreaterThan(96);
expect(titleBox!.width).toBeGreaterThan(0); expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
expect(kebabBox!.x + kebabBox!.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 }) => { 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 { test, expect, type Page } from "../support/fixtures";
import { gotoAppShell } from "../support/helpers/app"; import { gotoAppShell } from "../support/helpers/app";
import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher"; 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)}`); 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> { async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise<string> {
const created = await seeded.client.createWorkspace({ const created = await seeded.client.createWorkspace({
source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId }, 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 }) => { test("no tab, agent, or terminal ever renders as a sidebar row", async ({ page }) => {
const mock = await seedMockAgentWorkspace({ const mock = await seedMockAgentWorkspace({
repoPrefix: "model-b-leaf-", 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
+6 -6
View File
@@ -940,9 +940,7 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
<HostRuntimeBootstrapProvider> <HostRuntimeBootstrapProvider>
<PushNotificationRouter /> <PushNotificationRouter />
<SidebarCalloutProvider> <SidebarCalloutProvider>
<ToastProvider> <ProvidersWrapper>{children}</ProvidersWrapper>
<ProvidersWrapper>{children}</ProvidersWrapper>
</ToastProvider>
</SidebarCalloutProvider> </SidebarCalloutProvider>
</HostRuntimeBootstrapProvider> </HostRuntimeBootstrapProvider>
); );
@@ -960,9 +958,11 @@ function RootProviders({ children }: { children: ReactNode }) {
<WindowChromeProvider> <WindowChromeProvider>
<KeyboardProvider> <KeyboardProvider>
<KeyboardShiftProvider> <KeyboardShiftProvider>
<PortalProvider> <ToastProvider>
<BottomSheetModalProvider>{children}</BottomSheetModalProvider> <PortalProvider>
</PortalProvider> <BottomSheetModalProvider>{children}</BottomSheetModalProvider>
</PortalProvider>
</ToastProvider>
</KeyboardShiftProvider> </KeyboardShiftProvider>
</KeyboardProvider> </KeyboardProvider>
</WindowChromeProvider> </WindowChromeProvider>
@@ -592,6 +592,7 @@ export function CommandCenter() {
return ( return (
<IsolatedBottomSheetModal <IsolatedBottomSheetModal
ref={sheetRef} ref={sheetRef}
contextBridge={null}
snapPoints={COMMAND_CENTER_SNAP_POINTS} snapPoints={COMMAND_CENTER_SNAP_POINTS}
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
@@ -15,6 +15,7 @@ import { getIsElectronRuntime } from "@/constants/layout";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher"; import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts"; import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
@@ -77,6 +78,7 @@ function HomeIcon({ size }: CommandCenterIconProps) {
export function CommandCenterRootActions() { export function CommandCenterRootActions() {
const { t } = useTranslation(); const { t } = useTranslation();
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAddProject = useOpenAddProject(); const openAddProject = useOpenAddProject();
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []); const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []); const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
@@ -87,8 +89,8 @@ export function CommandCenterRootActions() {
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }), () => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
[], [],
); );
const actions = useMemo<CommandCenterContribution[]>( const actions = useMemo<CommandCenterContribution[]>(() => {
() => [ const availableActions: CommandCenterContribution[] = [
{ {
id: "add-project", id: "add-project",
group: "actions", group: "actions",
@@ -204,7 +206,10 @@ export function CommandCenterRootActions() {
undefined, undefined,
}, },
}, },
{ ];
if (shortcutsAvailable) {
availableActions.push({
id: "keyboard-shortcuts", id: "keyboard-shortcuts",
group: "actions", group: "actions",
groupRank: 0, groupRank: 0,
@@ -221,20 +226,22 @@ export function CommandCenterRootActions() {
resolveShortcutKeysForAction("show-shortcuts", overrides, shortcutPlatform) ?? resolveShortcutKeysForAction("show-shortcuts", overrides, shortcutPlatform) ??
undefined, undefined,
}, },
}, });
], }
[
homeRoute, return availableActions;
openAddProject, }, [
overrides, homeRoute,
schedulesRoute, openAddProject,
sessionsRoute, overrides,
setShortcutsDialogOpen, schedulesRoute,
settingsRoute, sessionsRoute,
shortcutPlatform, setShortcutsDialogOpen,
t, settingsRoute,
], shortcutPlatform,
); shortcutsAvailable,
t,
]);
useCommandCenterActions({ sourceId: "root", enabled: true, actions }); useCommandCenterActions({ sourceId: "root", enabled: true, actions });
return null; return null;
@@ -674,6 +674,7 @@ export function AdaptiveModalSheet({
return ( return (
<IsolatedBottomSheetModal <IsolatedBottomSheetModal
ref={sheetRef} ref={sheetRef}
contextBridge={null}
snapPoints={resolvedSnapPoints} snapPoints={resolvedSnapPoints}
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
@@ -67,6 +67,7 @@ import {
type ProjectPickerOption, type ProjectPickerOption,
} from "@/components/project-picker-options"; } from "@/components/project-picker-options";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { getIsElectronRuntime } from "@/constants/layout"; import { getIsElectronRuntime } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { pickDirectory } from "@/desktop/pick-directory"; 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 }) { function FlowHint({ keys, action }: { keys: string[]; action: string }) {
const shortcutsAvailable = useKeyboardShortcutsAvailable();
if (!shortcutsAvailable) return null;
return ( return (
<View style={styles.footerHint}> <View style={styles.footerHint}>
<Shortcut keys={keys} textStyle={styles.footerKeyText} /> <Shortcut keys={keys} textStyle={styles.footerKeyText} />
@@ -12,7 +12,7 @@ import {
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; 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 * as Clipboard from "expo-clipboard";
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native"; import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
import { MaterialFileIcon } from "@/components/material-file-icon"; import { MaterialFileIcon } from "@/components/material-file-icon";
@@ -20,9 +20,14 @@ import {
TreeChevron, TreeChevron,
TreeIndentGuides, TreeIndentGuides,
treeRowPaddingLeft, treeRowPaddingLeft,
WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives"; } from "@/components/tree-primitives";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; 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 { Theme } from "@/styles/theme";
import type { import type {
AgentFileExplorerState, AgentFileExplorerState,
@@ -174,12 +179,7 @@ function TreeRowItem({
return ( return (
<ContextMenu> <ContextMenu>
<ContextMenuTrigger <ContextMenuTrigger onPress={handlePress} style={pressableStyle} testID={testID}>
enabledOnMobile={false}
onPress={handlePress}
style={pressableStyle}
testID={testID}
>
<TreeIndentGuides depth={depth} /> <TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}> <View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryIcon}> <View style={styles.entryIcon}>
@@ -226,6 +226,7 @@ export function FileExplorerPane({
onAddToChat, onAddToChat,
}: FileExplorerPaneProps) { }: FileExplorerPaneProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
const workspaceStateKey = useMemo( const workspaceStateKey = useMemo(
@@ -278,6 +279,7 @@ export function FileExplorerPane({
); );
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null); const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
const scrollbar = useOverlayFlatListScrollbar(treeListRef, { enabled: !isCompact });
const hasInitializedRef = useRef(false); const hasInitializedRef = useRef(false);
@@ -518,6 +520,7 @@ export function FileExplorerPane({
currentSortLabel={currentSortLabel} currentSortLabel={currentSortLabel}
isRefreshFetching={isRefreshFetching} isRefreshFetching={isRefreshFetching}
treeListRef={treeListRef} treeListRef={treeListRef}
scrollbar={scrollbar}
renderTreeRow={renderTreeRow} renderTreeRow={renderTreeRow}
handleSortCycle={handleSortCycle} handleSortCycle={handleSortCycle}
handleToggleHiddenFiles={handleToggleHiddenFiles} handleToggleHiddenFiles={handleToggleHiddenFiles}
@@ -539,6 +542,7 @@ interface FileExplorerPaneContentProps {
currentSortLabel: string; currentSortLabel: string;
isRefreshFetching: boolean; isRefreshFetching: boolean;
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>; treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
scrollbar: OverlayFlatListScrollbar;
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement; renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
handleSortCycle: () => void; handleSortCycle: () => void;
handleToggleHiddenFiles: () => void; handleToggleHiddenFiles: () => void;
@@ -560,6 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
currentSortLabel, currentSortLabel,
isRefreshFetching, isRefreshFetching,
treeListRef, treeListRef,
scrollbar,
renderTreeRow, renderTreeRow,
handleSortCycle, handleSortCycle,
handleToggleHiddenFiles, handleToggleHiddenFiles,
@@ -682,12 +687,17 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
keyExtractor={treeRowKeyExtractor} keyExtractor={treeRowKeyExtractor}
testID="file-explorer-tree-scroll" testID="file-explorer-tree-scroll"
contentContainerStyle={styles.entriesContent} contentContainerStyle={styles.entriesContent}
showsVerticalScrollIndicator onLayout={scrollbar.onLayout}
onScroll={scrollbar.onScroll}
onContentSizeChange={scrollbar.onContentSizeChange}
scrollEventThrottle={16}
showsVerticalScrollIndicator={!scrollbar.enabled}
initialNumToRender={24} initialNumToRender={24}
maxToRenderPerBatch={40} maxToRenderPerBatch={40}
windowSize={12} windowSize={12}
/> />
)} )}
{treeRows.length > 0 ? scrollbar.overlay : null}
</View> </View>
); );
} }
@@ -1055,7 +1065,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
paddingRight: theme.spacing[3], paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
}, },
entryRowActive: { entryRowActive: {
backgroundColor: theme.colors.surfaceSidebarHover, backgroundColor: theme.colors.surfaceSidebarHover,
+7 -5
View File
@@ -988,11 +988,11 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
justifyContent: "space-between", justifyContent: "space-between",
gap: theme.spacing[2], gap: theme.spacing[2],
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]), so the // Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
// title lands at spacing[2] left to align with project icons, and the trailing // lands at spacing[2] left to align with project icons. Settings2's painted path stops
// pill sits flush with the list edge on the right. // inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
paddingLeft: theme.spacing[2], paddingLeft: theme.spacing[2],
paddingRight: 0, paddingRight: 4,
paddingTop: theme.spacing[1], paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[1], paddingBottom: theme.spacing[1],
}, },
@@ -1030,7 +1030,9 @@ const styles = StyleSheet.create((theme) => ({
pointerEvents: "box-none", pointerEvents: "box-none",
}, },
mobileCloseButton: { 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, width: 32,
height: 32, height: 32,
alignItems: "center", alignItems: "center",
@@ -495,7 +495,7 @@ function ProjectKebabMenu({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<DropdownMenu> <DropdownMenu compactMode="sheet">
<DropdownMenuTrigger <DropdownMenuTrigger
hitSlop={8} hitSlop={8}
style={projectKebabStyle} style={projectKebabStyle}
@@ -505,7 +505,7 @@ function ProjectKebabMenu({
> >
{renderKebabTriggerIcon} {renderKebabTriggerIcon}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}> <DropdownMenuContent align="end" width={220} sheetTitle={t("sidebar.project.actions.menu")}>
<ProjectMenuItems <ProjectMenuItems
surface="dropdown" surface="dropdown"
projectViewKey={projectViewKey} projectViewKey={projectViewKey}
@@ -2541,6 +2541,9 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
gap: 2, gap: 2,
flexShrink: 0, 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: { projectKebabButton: {
width: 24, width: 24,
@@ -15,9 +15,8 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { useAppDiagnosticStore } from "@/diagnostics/store"; import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
@@ -78,11 +77,10 @@ function HostVersionHint({ host }: { host: HostProfile }) {
export function SidebarHelpMenu() { export function SidebarHelpMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
const isCompactLayout = useIsCompactFormFactor(); const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen); const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const showKeyboardShortcuts = !isNative && !isCompactLayout;
const version = formatVersionWithPrefix(resolveAppVersion()); const version = formatVersionWithPrefix(resolveAppVersion());
const hosts = useHosts(); const hosts = useHosts();
@@ -128,7 +126,7 @@ export function SidebarHelpMenu() {
</Tooltip> </Tooltip>
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu"> <DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel> <DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
{showKeyboardShortcuts ? ( {shortcutsAvailable ? (
<DropdownMenuItem <DropdownMenuItem
testID="sidebar-help-shortcuts" testID="sidebar-help-shortcuts"
leading={shortcutsLeadingIcon} leading={shortcutsLeadingIcon}
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { type PressableStateCallbackType } from "react-native"; import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native"; 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 { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
@@ -110,7 +110,7 @@ function SidebarWorkspaceMenuItems({
}: SidebarWorkspaceMenuItemsProps & { surface: MenuSurface }): ReactNode { }: SidebarWorkspaceMenuItemsProps & { surface: MenuSurface }): ReactNode {
const { t } = useTranslation(); const { t } = useTranslation();
const archiveTrailing = useMemo( const archiveTrailing = useMemo(
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null), () => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
[archiveShortcutKeys], [archiveShortcutKeys],
); );
@@ -205,7 +205,7 @@ export function SidebarWorkspaceMenu({
}: SidebarWorkspaceMenuProps) { }: SidebarWorkspaceMenuProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<DropdownMenu> <DropdownMenu compactMode="sheet">
<DropdownMenuTrigger <DropdownMenuTrigger
hitSlop={8} hitSlop={8}
style={triggerStyle} style={triggerStyle}
@@ -215,7 +215,7 @@ export function SidebarWorkspaceMenu({
> >
{renderTriggerIcon} {renderTriggerIcon}
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" width={260}> <DropdownMenuContent align="end" width={260} sheetTitle={t("sidebar.workspace.actions.menu")}>
<SidebarWorkspaceMenuItems <SidebarWorkspaceMenuItems
surface="dropdown" surface="dropdown"
workspaceKey={workspaceKey} workspaceKey={workspaceKey}
@@ -339,6 +339,9 @@ const styles = StyleSheet.create((theme) => ({
padding: 2, padding: 2,
borderRadius: 4, borderRadius: 4,
marginLeft: 2, 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: { triggerHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
@@ -31,9 +31,6 @@ export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./scr
*/ */
const META_ICON_SIZE = 12; 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 ThemedServer = withUnistyles(Server);
const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedExternalLink = withUnistyles(ExternalLink);
const ThemedGitPullRequest = withUnistyles(GitPullRequest); const ThemedGitPullRequest = withUnistyles(GitPullRequest);
@@ -283,16 +280,14 @@ const styles = StyleSheet.create((theme) => ({
minWidth: 0, minWidth: 0,
flexShrink: 0, flexShrink: 0,
}, },
// A host label is arbitrary user text and the only item on the line that can be long, so // The host is the only unbounded item, so it consumes the remaining width and gives way
// it is the one that gets capped and truncated. The change request, CI and service items // before the bounded change request, CI, and service items.
// are bounded by their own content and must never be the thing that gives way.
hostItem: { hostItem: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: 3, gap: 3,
minWidth: 0, minWidth: 0,
flexShrink: 0, flexShrink: 1,
maxWidth: MAX_HOST_LABEL_WIDTH,
}, },
itemPressed: { itemPressed: {
opacity: 0.82, opacity: 0.82,
@@ -62,7 +62,7 @@ export function SidebarWorkspaceTrailingContent({
function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) { function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) {
const label = useCompactTimeAgo(enteredAt); const label = useCompactTimeAgo(enteredAt);
return ( return (
<Text style={styles.timestamp} numberOfLines={1}> <Text style={styles.timestamp} numberOfLines={1} testID="sidebar-workspace-timestamp">
{label} {label}
</Text> </Text>
); );
@@ -114,6 +114,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
{children} {children}
<IsolatedBottomSheetModal <IsolatedBottomSheetModal
ref={bottomSheetRef} ref={bottomSheetRef}
contextBridge={null}
snapPoints={snapPoints} snapPoints={snapPoints}
index={0} index={0}
enableDynamicSizing={false} enableDynamicSizing={false}
@@ -12,6 +12,12 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
// drift apart. // drift apart.
export const TREE_INDENT_PER_LEVEL = 16; export const TREE_INDENT_PER_LEVEL = 16;
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5]; 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 /** 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. */ * 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). */ /** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
export function TreeChevron({ expanded }: { expanded: boolean }) { export function TreeChevron({ expanded }: { expanded: boolean }) {
return ( 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} /> <ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} />
</View> </View>
); );
@@ -73,7 +85,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
justifyContent: "center", justifyContent: "center",
flexShrink: 0, 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: { chevronExpanded: {
left: -2,
transform: [{ rotate: "90deg" }], transform: [{ rotate: "90deg" }],
}, },
})); }));
@@ -994,6 +994,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
return ( return (
<IsolatedBottomSheetModal <IsolatedBottomSheetModal
ref={props.bottomSheetRef} ref={props.bottomSheetRef}
contextBridge={null}
snapPoints={props.snapPoints} snapPoints={props.snapPoints}
index={0} index={0}
enableDynamicSizing={false} 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 { import {
Platform, Platform,
Pressable, Pressable,
@@ -31,13 +37,23 @@ import {
* docs/menus.md. Only the way it opens is different from `dropdown-menu.tsx`. * docs/menus.md. Only the way it opens is different from `dropdown-menu.tsx`.
*/ */
export { MenuRoot as ContextMenu };
export { MenuItem as ContextMenuItem }; export { MenuItem as ContextMenuItem };
export { MenuLabel as ContextMenuLabel }; export { MenuLabel as ContextMenuLabel };
export { MenuSeparator as ContextMenuSeparator }; export { MenuSeparator as ContextMenuSeparator };
export { MenuHint as ContextMenuHint }; export { MenuHint as ContextMenuHint };
export type { ActionStatus } from "@/components/ui/menu"; 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 { export function ContextMenuContent(props: MenuSurfaceProps): ReactElement | null {
return <MenuSurface {...props} />; return <MenuSurface {...props} />;
} }
@@ -85,7 +101,7 @@ export function ContextMenuTrigger({
disabled, disabled,
style, style,
enabled = true, enabled = true,
enabledOnMobile = false, enabledOnMobile = true,
enabledOnWeb = true, enabledOnWeb = true,
longPressDelayMs, longPressDelayMs,
triggerRef, triggerRef,
@@ -1,34 +1,75 @@
/** /**
* @vitest-environment jsdom * @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 { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; 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", () => ({ vi.mock("@gorhom/portal", () => ({
Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) => Portal: ({ children }: { children?: React.ReactNode }) => {
React.createElement("div", { "data-portal-host": hostName }, children), React.useEffect(() => {
PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }), portalStore.put(children);
}, [children]);
return null;
},
PortalHost: () => React.createElement("div", { "data-host": true }),
})); }));
vi.mock("@gorhom/bottom-sheet", () => ({ vi.mock("@gorhom/bottom-sheet", async () => {
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) => const { Portal } = await import("@gorhom/portal");
React.createElement("div", { "data-bottom-sheet-provider": true }, children), return {
BottomSheetModal: React.forwardRef( BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
( React.createElement("div", { "data-bottom-sheet-provider": true }, children),
{ BottomSheetModal: React.forwardRef(
children, ({ children, stackBehavior }: { children?: React.ReactNode; stackBehavior?: string }, _ref) =>
stackBehavior, React.createElement(
}: { Portal,
children?: React.ReactNode; null,
stackBehavior?: string; React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
}, ),
_ref, ),
) => 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", () => { describe("IsolatedBottomSheetModal presentation", () => {
let container: HTMLDivElement; let container: HTMLDivElement;
@@ -43,34 +84,94 @@ describe("IsolatedBottomSheetModal presentation", () => {
afterEach(() => { afterEach(() => {
act(() => root.unmount()); act(() => root.unmount());
container.remove(); 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", () => { it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => {
act(() => { act(() => {
root.render( root.render(
<> <>
<IsolatedBottomSheetModal>Settings</IsolatedBottomSheetModal> <IsolatedBottomSheetModal contextBridge={null}>Settings</IsolatedBottomSheetModal>
<IsolatedBottomSheetModal>Diagnostic</IsolatedBottomSheetModal> <PortalHostProbe />
</>, </>,
); );
}); });
expect(stackBehaviors()).toEqual(["push"]);
expect( act(() => {
Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) => root.render(
node.getAttribute("data-stack-behavior"), <>
), <IsolatedBottomSheetModal contextBridge={null}>Diagnostic</IsolatedBottomSheetModal>
).toEqual(["push", "push"]); <PortalHostProbe />
</>,
);
});
expect(stackBehaviors()).toEqual(["push"]);
}); });
it("only replaces when the callsite asks for replacement", () => { it("only replaces when the callsite asks for replacement", () => {
act(() => { act(() => {
root.render( root.render(
<IsolatedBottomSheetModal presentation="replace">Selector</IsolatedBottomSheetModal>, <>
<IsolatedBottomSheetModal presentation="replace" contextBridge={null}>
Selector
</IsolatedBottomSheetModal>
<PortalHostProbe />
</>,
); );
}); });
expect( expect(stackBehaviors()).toEqual(["replace"]);
container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"), });
).toBe("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"; } from "@gorhom/bottom-sheet";
import React from "react"; import React from "react";
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
import type { ElementRef } from "react"; import type { ElementRef, ReactNode } from "react";
import { import {
type BottomSheetController, type BottomSheetController,
createBottomSheetVisibilityTracker, createBottomSheetVisibilityTracker,
@@ -12,11 +12,42 @@ import {
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>; 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< type IsolatedBottomSheetModalProps = Omit<
BottomSheetModalProps, 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"; 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; export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
@@ -25,7 +56,7 @@ export const IsolatedBottomSheetModal = forwardRef<
IsolatedBottomSheetModalRef, IsolatedBottomSheetModalRef,
IsolatedBottomSheetModalProps IsolatedBottomSheetModalProps
>(function IsolatedBottomSheetModal(props, ref) { >(function IsolatedBottomSheetModal(props, ref) {
const { children, presentation = "push", ...bottomSheetProps } = props; const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props;
const modal = ( const modal = (
<GorhomBottomSheetModal <GorhomBottomSheetModal
{...bottomSheetProps} {...bottomSheetProps}
@@ -33,7 +64,7 @@ export const IsolatedBottomSheetModal = forwardRef<
enableDismissOnClose enableDismissOnClose
stackBehavior={presentation} stackBehavior={presentation}
> >
{children} {contextBridge ? contextBridge(children) : children}
</GorhomBottomSheetModal> </GorhomBottomSheetModal>
); );
@@ -17,9 +17,10 @@ import { ChevronLeft } from "lucide-react-native";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
type ContextBridge,
} from "@/components/ui/isolated-bottom-sheet-modal"; } from "@/components/ui/isolated-bottom-sheet-modal";
import type { Theme } from "@/styles/theme"; 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 { currentPageId, isSubPageOpen } from "./menu-navigation";
import { AnchoredSurface, MenuOverlay } from "./menu-overlay"; import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
import type { Alignment, Placement } from "./menu-anchor"; import type { Alignment, Placement } from "./menu-anchor";
@@ -327,40 +328,51 @@ function MenuSheetSurface({
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
const depth = menu.path.length; 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 ( return (
<MenuSurfaceContext.Provider value={surfaceValue}> <ThemedBottomSheetModal
<ThemedBottomSheetModal ref={sheetRef}
ref={sheetRef} contextBridge={contextBridge}
// Content-sized rather than fixed snap points: a pushed page is rarely the same height // 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. // as the page it replaced, and a fixed sheet would either clip it or leave dead space.
enableDynamicSizing enableDynamicSizing
onChange={handleSheetChange} onChange={handleSheetChange}
onDismiss={handleSheetDismiss} onDismiss={handleSheetDismiss}
backdropComponent={renderBackdrop} backdropComponent={renderBackdrop}
enablePanDownToClose enablePanDownToClose
keyboardBehavior="extend" keyboardBehavior="extend"
keyboardBlurBehavior="restore" keyboardBlurBehavior="restore"
>
<BottomSheetScrollView
contentContainerStyle={styles.sheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID={testID ? `${testID}-content` : undefined}
> >
<BottomSheetScrollView {openPage ? (
contentContainerStyle={styles.sheetScrollContent} <>
keyboardShouldPersistTaps="handled" <MenuSheetHeader title={openPage.title} onBack={menu.goBack} />
showsVerticalScrollIndicator={false} <MenuDepthProvider value={depth}>{openPage.content}</MenuDepthProvider>
testID={testID ? `${testID}-content` : undefined} </>
> ) : (
{openPage ? ( <>
<> {sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
<MenuSheetHeader title={openPage.title} onBack={menu.goBack} /> <MenuDepthProvider value={0}>{children}</MenuDepthProvider>
<MenuDepthProvider value={depth}>{openPage.content}</MenuDepthProvider> </>
</> )}
) : ( </BottomSheetScrollView>
<> </ThemedBottomSheetModal>
{sheetTitle ? <MenuSheetHeader title={sheetTitle} onBack={null} /> : null}
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
</>
)}
</BottomSheetScrollView>
</ThemedBottomSheetModal>
</MenuSurfaceContext.Provider>
); );
} }
@@ -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,
};
}
@@ -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,
},
}));
+7 -1
View File
@@ -1,6 +1,7 @@
import React, { useMemo, type ReactElement } from "react"; import React, { useMemo, type ReactElement } from "react";
import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut";
import { getShortcutOs } from "@/utils/shortcut-platform"; import { getShortcutOs } from "@/utils/shortcut-platform";
@@ -14,7 +15,8 @@ export function Shortcut({
chord?: ShortcutKey[][]; chord?: ShortcutKey[][];
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
textStyle?: StyleProp<TextStyle>; textStyle?: StyleProp<TextStyle>;
}): ReactElement { }): ReactElement | null {
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const displayChord = chord ?? (keys ? [keys] : []); const displayChord = chord ?? (keys ? [keys] : []);
const shortcutOs = getShortcutOs(); const shortcutOs = getShortcutOs();
const singleCombo = displayChord[0]; const singleCombo = displayChord[0];
@@ -23,6 +25,10 @@ export function Shortcut({
const textCombinedStyle = useMemo(() => [styles.text, textStyle], [textStyle]); const textCombinedStyle = useMemo(() => [styles.text, textStyle], [textStyle]);
const sequenceStyle = useMemo(() => [styles.sequence, style], [style]); const sequenceStyle = useMemo(() => [styles.sequence, style], [style]);
if (!shortcutsAvailable) {
return null;
}
if (!singleCombo) { if (!singleCombo) {
return <View style={style} />; return <View style={style} />;
} }
+2 -1
View File
@@ -12,6 +12,7 @@ import {
TreeChevron, TreeChevron,
TreeIndentGuides, TreeIndentGuides,
treeRowPaddingLeft, treeRowPaddingLeft,
WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives"; } from "@/components/tree-primitives";
import { type Theme } from "@/styles/theme"; import { type Theme } from "@/styles/theme";
@@ -103,7 +104,7 @@ const styles = StyleSheet.create((theme: Theme) => ({
folderRow: { folderRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingRight: theme.spacing[3], paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
gap: theme.spacing[1], gap: theme.spacing[1],
minWidth: 0, minWidth: 0,
+65 -36
View File
@@ -48,6 +48,7 @@ import { DiffFolderRow } from "@/git/diff-folder-row";
import { import {
TreeIndentGuides, TreeIndentGuides,
treeRowPaddingLeft, treeRowPaddingLeft,
WORKSPACE_FILE_ROW_TRAILING_PADDING,
WORKSPACE_FILE_ROW_VERTICAL_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives"; } from "@/components/tree-primitives";
import { MaterialFileIcon } from "@/components/material-file-icon"; import { MaterialFileIcon } from "@/components/material-file-icon";
@@ -92,6 +93,7 @@ import { useCheckoutGitActionsStore } from "@/git/actions-store";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { LoadingSpinner } from "@/components/ui/loading-spinner"; 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 { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import { usePanelStore } from "@/stores/panel-store"; import { usePanelStore } from "@/stores/panel-store";
import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
@@ -125,8 +127,11 @@ export function resolveDiffLayout(
return canUseSplitLayout ? layout : "unified"; return canUseSplitLayout ? layout : "unified";
} }
function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) { function fileHeaderPressableStyle({
return [styles.fileHeader, pressed && styles.fileHeaderPressed]; hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [styles.fileHeader, (Boolean(hovered) || pressed) && styles.fileHeaderActive];
} }
interface HighlightedTextProps { interface HighlightedTextProps {
@@ -972,6 +977,10 @@ const DiffFileHeader = memo(function DiffFileHeader({
}; };
}, []); }, []);
const handleLongPress = useCallback(() => {
pressHandledRef.current = true;
}, []);
const handlePressOut = useCallback( const handlePressOut = useCallback(
(event: { nativeEvent: { pageX: number; pageY: number } }) => { (event: { nativeEvent: { pageX: number; pageY: number } }) => {
if ( if (
@@ -1051,13 +1060,13 @@ const DiffFileHeader = memo(function DiffFileHeader({
} else { } else {
trigger = ( trigger = (
<ContextMenuTrigger <ContextMenuTrigger
enabledOnMobile={false}
testID={testID ? `${testID}-toggle` : undefined} testID={testID ? `${testID}-toggle` : undefined}
style={headerPressableStyle} style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release. // Android: prevent parent pan/scroll gestures from canceling the tap release.
cancelable={false} cancelable={false}
onPressIn={handlePressIn} onPressIn={handlePressIn}
onPressOut={handlePressOut} onPressOut={handlePressOut}
onLongPress={handleLongPress}
onPress={toggleExpanded} onPress={toggleExpanded}
> >
{headerContent} {headerContent}
@@ -1831,6 +1840,7 @@ interface SharedDiffViewProps {
} }
export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) { export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) {
const isCompact = useIsCompactFormFactor();
const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences;
const diffBodyLineHeight = Math.round(codeFontSize * 1.5); const diffBodyLineHeight = Math.round(codeFontSize * 1.5);
const typographyKey = [monoFontFamily, codeFontSize, diffBodyLineHeight].join(":"); const typographyKey = [monoFontFamily, codeFontSize, diffBodyLineHeight].join(":");
@@ -1876,6 +1886,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
[allFolderPathSet, collapsedFolders], [allFolderPathSet, collapsedFolders],
); );
const diffListRef = useRef<FlatList<DiffFlatItem>>(null); 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 consumedFocusRequestRef = useRef<string | null>(null);
const pendingFocusRequestRef = useRef<string | null>(null); const pendingFocusRequestRef = useRef<string | null>(null);
const diffListScrollOffsetRef = useRef(0); const diffListScrollOffsetRef = useRef(0);
@@ -2025,17 +2037,25 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
[getBodyHeightKey, scheduleHeightVersionUpdate], [getBodyHeightKey, scheduleHeightVersionUpdate],
); );
const handleDiffListScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { const handleDiffListScroll = useCallback(
diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; (event: NativeSyntheticEvent<NativeScrollEvent>) => {
}, []); diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
updateScrollbarOffset(event);
},
[updateScrollbarOffset],
);
const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => { const handleDiffListLayout = useCallback(
const height = event.nativeEvent.layout.height; (event: LayoutChangeEvent) => {
if (!Number.isFinite(height) || height <= 0) { const height = event.nativeEvent.layout.height;
return; if (!Number.isFinite(height) || height <= 0) {
} return;
diffListViewportHeightRef.current = height; }
}, []); diffListViewportHeightRef.current = height;
updateScrollbarLayout(event);
},
[updateScrollbarLayout],
);
const computeItemOffset = useCallback( const computeItemOffset = useCallback(
(predicate: (item: DiffFlatItem) => boolean): number | null => { (predicate: (item: DiffFlatItem) => boolean): number | null => {
@@ -2269,26 +2289,30 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi
); );
return ( return (
<FlatList <View style={styles.scrollContainer}>
ref={diffListRef} <FlatList
data={flatItems} ref={diffListRef}
renderItem={renderFlatItem} data={flatItems}
keyExtractor={flatKeyExtractor} renderItem={renderFlatItem}
getItemLayout={getFlatItemLayout} keyExtractor={flatKeyExtractor}
stickyHeaderIndices={stickyHeaderIndices} getItemLayout={getFlatItemLayout}
extraData={flatExtraData} stickyHeaderIndices={stickyHeaderIndices}
style={styles.scrollView} extraData={flatExtraData}
contentContainerStyle={styles.contentContainer} style={styles.scrollView}
testID="git-diff-scroll" contentContainerStyle={styles.contentContainer}
onLayout={handleDiffListLayout} testID="git-diff-scroll"
onScroll={handleDiffListScroll} onLayout={handleDiffListLayout}
scrollEventThrottle={16} onScroll={handleDiffListScroll}
showsVerticalScrollIndicator onContentSizeChange={scrollbar.onContentSizeChange}
removeClippedSubviews={false} scrollEventThrottle={16}
initialNumToRender={12} showsVerticalScrollIndicator={!scrollbar.enabled}
maxToRenderPerBatch={12} removeClippedSubviews={false}
windowSize={10} initialNumToRender={12}
/> maxToRenderPerBatch={12}
windowSize={10}
/>
{scrollbar.overlay}
</View>
); );
} }
@@ -3050,6 +3074,11 @@ const styles = StyleSheet.create((theme) => ({
scrollView: { scrollView: {
flex: 1, flex: 1,
}, },
scrollContainer: {
flex: 1,
minHeight: 0,
position: "relative",
},
contentContainer: { contentContainer: {
paddingBottom: theme.spacing[8], paddingBottom: theme.spacing[8],
}, },
@@ -3110,15 +3139,15 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
paddingLeft: theme.spacing[3], paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[3], paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
gap: theme.spacing[1], gap: theme.spacing[1],
minWidth: 0, minWidth: 0,
zIndex: 2, zIndex: 2,
elevation: 2, elevation: 2,
}, },
fileHeaderPressed: { fileHeaderActive: {
opacity: 0.7, backgroundColor: theme.colors.surfaceSidebarHover,
}, },
fileHeaderLeft: { fileHeaderLeft: {
flexDirection: "row", flexDirection: "row",
@@ -29,6 +29,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform";
import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useOpenAddProject } from "@/hooks/use-open-add-project";
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import { keyboardShortcutsAvailable } from "@/keyboard/availability";
import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host";
import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime";
import { import {
@@ -59,6 +60,7 @@ export function useKeyboardShortcuts({
const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers);
const { overrides } = useKeyboardShortcutOverrides(); const { overrides } = useKeyboardShortcutOverrides();
const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]);
const shortcutsAvailable = keyboardShortcutsAvailable({ isNative, isCompact: isMobile });
const isDesktopApp = getIsElectronRuntime(); const isDesktopApp = getIsElectronRuntime();
const isMac = getShortcutOs() === "mac"; const isMac = getShortcutOs() === "mac";
const chordStateRef = useRef<ChordState>({ const chordStateRef = useRef<ChordState>({
@@ -73,7 +75,7 @@ export function useKeyboardShortcuts({
const publishBrowserShortcutPolicy = useCallback( const publishBrowserShortcutPolicy = useCallback(
(chordState?: ChordState) => { (chordState?: ChordState) => {
const policy = const policy =
enabled && !isMobile enabled && shortcutsAvailable
? buildBrowserKeyboardPolicy({ ? buildBrowserKeyboardPolicy({
bindings, bindings,
chordState, chordState,
@@ -83,7 +85,7 @@ export function useKeyboardShortcuts({
: { menuPrefixes: [], prefixes: [] }; : { menuPrefixes: [], prefixes: [] };
void getDesktopHost()?.browser?.setShortcutPolicy?.(policy); void getDesktopHost()?.browser?.setShortcutPolicy?.(policy);
}, },
[bindings, enabled, isDesktopApp, isMac, isMobile], [bindings, enabled, isDesktopApp, isMac, shortcutsAvailable],
); );
useEffect(() => { useEffect(() => {
@@ -102,8 +104,7 @@ export function useKeyboardShortcuts({
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
if (isNative) return; if (!shortcutsAvailable) return;
if (isMobile) return;
// Only the modifier that actually performs the workspace-index jump on this // Only the modifier that actually performs the workspace-index jump on this
// runtime should reveal the sidebar number badges (Alt on web, Cmd on // runtime should reveal the sidebar number badges (Alt on web, Cmd on
@@ -390,6 +391,7 @@ export function useKeyboardShortcuts({
publishBrowserShortcutPolicy, publishBrowserShortcutPolicy,
resetModifiers, resetModifiers,
router, router,
shortcutsAvailable,
toggleAgentList, toggleAgentList,
toggleBothSidebars, 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);
});
});
+19
View File
@@ -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 });
}
+1 -1
View File
@@ -93,7 +93,7 @@ vi.mock("react-native-unistyles", () => ({
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
}, },
withUnistyles: <T,>(component: T) => component, withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme }), useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
})); }));
vi.mock("@/constants/platform", () => ({ vi.mock("@/constants/platform", () => ({