fix(app): polish workspace navigation surfaces

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