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
|
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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+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 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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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),
|
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", () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user