feat(app): sidebar display preferences, a shared menu engine, and a colour pass

Sidebar polish round.

Menus. `dropdown-menu` and `context-menu` were two copies of one engine that
differed only in how the trigger fires — identical from `computePosition` down —
and neither could nest. Both are now thin wrappers over
`components/ui/menu/`, which owns anchoring, a pure page-stack navigation model,
and one presentation fork: flyout on pointer, pushed pages in a sheet on compact.
All 31 call sites are unchanged. Items got a rounded inset fill, a visible
separator, and `selected` (a check) split from `active` (a fill).

Sidebar display preferences. The flat wall of four sections becomes one row per
decision with its value, and per-feature toggles over what a workspace row shows:
host, pull request, checks, scripts. Each is independent — a ticked toggle that
draws nothing is a lie. `display-preferences/` hides that grouping lives in
zustand while title source and row items are app settings.

Timestamps. "Last activity" now ticks itself. The label rounds to the minute and
never shows seconds; past a week it becomes a date. The timer lives in the leaf
`<Text>`, at most one per resolution tier shared by every row, and a tick only
re-renders when the string actually changed — so a memoized row that legitimately
never re-renders still stays current.

Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as
the muted tier, lightness and hue untouched. File icons are pulled toward neutral
in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a
tree where the icon is the only colour on the row, and ours is not. New/deleted
files use the forge square-plus/square-minus glyphs at muted weight instead of
tinted pills. Project icons round a little more; uploaded icons never do.

The project status badge is a knockout again: it fills with the colour of the row
behind it, so the ring reads as a gap in the icon rather than a white halo.

File rows. The always-visible kebab is gone from the file explorer and diff tree
in favour of right-click, which also reclaims the reserved action slot.
This commit is contained in:
Mohamed Boudra
2026-08-03 18:58:06 +02:00
parent 777fe968f5
commit ab8151d33f
81 changed files with 5457 additions and 2903 deletions
+2 -2
View File
@@ -36,11 +36,11 @@ const styles = StyleSheet.create((theme) => ({
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffStatAddition,
color: theme.colors.statusMutedSuccess,
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffStatDeletion,
color: theme.colors.statusMutedDanger,
},
}));
@@ -1,30 +1,15 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
import { type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
Copy,
Download,
FileText,
MessageSquarePlus,
MoreVertical,
type LucideIcon,
} from "lucide-react-native";
import { withUnistyles } from "react-native-unistyles";
import { Copy, Download, FileText, MessageSquarePlus, type LucideIcon } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
} from "@/components/ui/context-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedMoreVertical = withUnistyles(MoreVertical);
/** Width occupied by a file action trigger, including its visual padding. */
export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
interface FileAction {
key: string;
label: string;
@@ -33,7 +18,7 @@ interface FileAction {
testID?: string;
}
interface FileActionsMenuProps {
interface FileActionsContextMenuContentProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
@@ -42,32 +27,14 @@ interface FileActionsMenuProps {
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
open?: boolean;
onOpenChange?: (open: boolean) => void;
hitSlop?: number;
accessibilityLabel: string;
testIDPrefix?: string;
}
// The menu lives inside pressable rows (diff header, explorer entry); stop the
// press so opening it doesn't also trigger the row.
function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
event.stopPropagation?.();
}
function triggerStyle({
hovered,
pressed,
open,
}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
}
/**
* Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
* git diff pane so both surfaces share action availability, ordering, and chrome.
* Shared context-menu content for per-file actions. The file explorer tree and git diff pane
* own their row triggers while sharing action availability, ordering, and chrome here.
*/
export function FileActionsMenu({
export function FileActionsContextMenuContent({
fileKind,
fileExists = true,
onOpenFile,
@@ -75,12 +42,8 @@ export function FileActionsMenu({
onDownload,
onAddToChat,
header,
open,
onOpenChange,
hitSlop = 12,
accessibilityLabel,
testIDPrefix,
}: FileActionsMenuProps): ReactElement | null {
}: FileActionsContextMenuContentProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo<FileAction[]>(() => {
const availableFile = fileKind === "file" && fileExists;
@@ -126,28 +89,21 @@ export function FileActionsMenu({
return null;
}
return (
<DropdownMenu open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger
hitSlop={hitSlop}
onPressIn={stopTriggerPropagation}
style={triggerStyle}
accessibilityLabel={accessibilityLabel}
testID={testIDPrefix ? `${testIDPrefix}-actions` : undefined}
>
<ThemedMoreVertical size={ICON_SIZE.sm} uniProps={foregroundMutedColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220}>
{header ? (
<>
{header}
<DropdownMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</DropdownMenuContent>
</DropdownMenu>
<ContextMenuContent
align="start"
width={220}
testID={testIDPrefix ? `${testIDPrefix}-context-menu` : undefined}
>
{header ? (
<>
{header}
<ContextMenuSeparator />
</>
) : null}
{actions.map((action) => (
<FileActionMenuItem key={action.key} action={action} />
))}
</ContextMenuContent>
);
}
@@ -159,27 +115,8 @@ function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
[ThemedIcon],
);
return (
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
<ContextMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
{action.label}
</DropdownMenuItem>
</ContextMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
// The hover box comes from padding, but an equal negative vertical margin
// cancels its height contribution so the trigger overlaps the row's natural
// line height instead of growing it. The comfortable tap target is `hitSlop`,
// never padding.
padding: theme.spacing[1],
width: FILE_ACTIONS_MENU_WIDTH,
marginVertical: -theme.spacing[1],
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
triggerActive: {
backgroundColor: theme.colors.surface2,
},
}));
@@ -0,0 +1,43 @@
import type { ReactElement } from "react";
import { withUnistyles } from "react-native-unistyles";
import { SquareMinus, SquarePlus } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { Theme } from "@/styles/theme";
const ThemedSquarePlus = withUnistyles(SquarePlus);
const ThemedSquareMinus = withUnistyles(SquareMinus);
const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
const ICON_SIZE = 14;
/**
* Whether a file was added or removed outright, shown beside its diff stat.
*
* Square-plus / square-minus because that is what every forge uses for the same fact, so it needs
* no label — the shape is the word. It sits at the same muted weight as the "+12 −3" next to it:
* these are footnotes on a file header, not the diff body where colour carries the line-by-line
* signal.
*/
export function FileChangeIcon({ change }: { change: "added" | "deleted" }): ReactElement {
const { t } = useTranslation();
if (change === "added") {
return (
<ThemedSquarePlus
size={ICON_SIZE}
uniProps={successMapping}
accessibilityLabel={t("workspace.git.diff.newFile")}
/>
);
}
return (
<ThemedSquareMinus
size={ICON_SIZE}
uniProps={dangerMapping}
accessibilityLabel={t("workspace.git.diff.deletedFile")}
/>
);
}
@@ -30,7 +30,8 @@ import type {
ExplorerEntry,
} from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
import { FileActionsMenu } from "@/components/file-actions-menu";
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
@@ -172,34 +173,40 @@ function TreeRowItem({
);
return (
<Pressable onPress={handlePress} style={pressableStyle} testID={testID}>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={16} />;
}
if (loading) {
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
}
return <TreeChevron expanded={isExpanded} />;
})()}
<ContextMenu>
<ContextMenuTrigger
enabledOnMobile={false}
onPress={handlePress}
style={pressableStyle}
testID={testID}
>
<TreeIndentGuides depth={depth} />
<View ref={dragSourceRef} style={styles.entryInfo}>
<View style={styles.entryIcon}>
{(() => {
if (!isDirectory) {
return <MaterialFileIcon fileName={entry.name} size={16} />;
}
if (loading) {
return <ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />;
}
return <TreeChevron expanded={isExpanded} />;
})()}
</View>
<Text style={styles.entryName} numberOfLines={1}>
{entry.name}
</Text>
</View>
<Text style={styles.entryName} numberOfLines={1}>
{entry.name}
</Text>
</View>
<FileActionsMenu
</ContextMenuTrigger>
<FileActionsContextMenuContent
fileKind={entry.kind}
onCopyPath={handleCopy}
onDownload={handleDownload}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
header={metaHeader}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
</Pressable>
</ContextMenu>
);
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { getFileIconSvg } from "./file-icon-svg";
import { getRawFileIconSvg } from "./material-file-icons";
function hexColors(svg: string): string[] {
return svg.match(/#[0-9a-fA-F]{6}/g) ?? [];
}
/** How far a colour is from grey, measured crudely enough not to re-derive the implementation. */
function channelSpread(hex: string): number {
const [r, g, b] = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
return Math.max(r, g, b) - Math.min(r, g, b);
}
describe("getFileIconSvg", () => {
it("keeps the vendor geometry and only rewrites colours", () => {
const raw = getRawFileIconSvg("index.ts");
const toned = getFileIconSvg("index.ts");
expect(toned).not.toBe(raw);
expect(toned.replace(/#[0-9a-fA-F]{6}/g, "#")).toBe(raw.replace(/#[0-9a-fA-F]{6}/g, "#"));
});
it("pulls every colour toward neutral", () => {
const spread = (svg: string) => hexColors(svg).map(channelSpread);
for (const file of ["main.ts", "app.jsx", "style.css", "data.json", "notes.md"]) {
const before = spread(getRawFileIconSvg(file));
const after = spread(getFileIconSvg(file));
expect(after).toHaveLength(before.length);
after.forEach((value, index) => expect(value).toBeLessThan(before[index]));
}
});
it("leaves non-colour attributes alone", () => {
const toned = getFileIconSvg("Main.kt");
expect(toned).toContain('fill="url(#a)"');
});
it("returns the same string for repeated lookups", () => {
expect(getFileIconSvg("a.ts")).toBe(getFileIconSvg("b.ts"));
});
it("falls back to the default icon for unknown extensions", () => {
expect(getFileIconSvg("thing.qqq")).toBe(getFileIconSvg("thing"));
});
});
@@ -0,0 +1,39 @@
import { getRawFileIconSvg } from "@/components/material-file-icons";
import { desaturateHexColor } from "@/utils/color";
/**
* File icons, toned to sit in our UI.
*
* material-icon-theme's palette is built for VS Code's file tree, where the icon is the only
* colour on the row. Ours share a row with status dots, diff stats, and check badges, and a
* column of them at full chroma is the loudest thing in the panel. Every colour in the vendor
* SVG is pulled toward neutral at the same perceived lightness, so hue still tells you what the
* file is — that is the icon's whole job — without the row competing for attention.
*
* One knob. If icons read as too grey or too loud, move this and nothing else; per-icon
* overrides would put us back to hand-picking 53 colours.
*/
const ICON_CHROMA = 0.65;
/** Anything hex-shaped. `desaturateHexColor` returns lengths it does not own untouched. */
const HEX_COLOR_PATTERN = /#[0-9a-fA-F]+/g;
const toned = new Map<string, string>();
function toneSvg(svg: string): string {
return svg.replace(HEX_COLOR_PATTERN, (hex) => desaturateHexColor(hex, ICON_CHROMA));
}
/**
* The SVG for a file, ready to render. Results are cached by the vendor string, so the whole
* table costs at most one pass per icon for the life of the process.
*/
export function getFileIconSvg(fileName: string): string {
const raw = getRawFileIconSvg(fileName);
const cached = toned.get(raw);
if (cached !== undefined) return cached;
const next = toneSvg(raw);
toned.set(raw, next);
return next;
}
+1 -1
View File
@@ -28,7 +28,7 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { Shortcut } from "@/components/ui/shortcut";
@@ -1,6 +1,6 @@
import type { ComponentType } from "react";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import { getFileIconSvg } from "@/components/file-icon-svg";
import type { PanelIconProps } from "@/panels/panel-registry";
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
@@ -1,4 +1,8 @@
// Auto-generated from material-icon-theme. Do not edit manually.
// Vendor icon table, transcribed from material-icon-theme. The SVGs are copied verbatim,
// including their colours — see file-icon-svg.ts for the module that tones them down for our
// surfaces. Keeping this file a faithful copy is what makes re-copying an icon a mechanical edit.
//
// See docs/file-icons.md for how to add one.
const SVG_ICONS: Record<string, string> = {
_default: `<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="m8.668 6h3.6641l-3.6641-3.668v3.668m-4.668-4.668h5.332l4 4v8c0 0.73828-0.59375 1.3359-1.332 1.3359h-8c-0.73828 0-1.332-0.59766-1.332-1.3359v-10.664c0-0.74219 0.59375-1.3359 1.332-1.3359m3.332 1.3359h-3.332v10.664h8v-6h-4.668z" fill="#90a4ae" /></svg>`,
@@ -125,7 +129,7 @@ const EXTENSION_TO_ICON: Record<string, string> = {
zig: "zig",
};
export function getFileIconSvg(fileName: string): string {
export function getRawFileIconSvg(fileName: string): string {
const ext = getExtension(fileName);
if (ext) {
const iconName = EXTENSION_TO_ICON[ext];
@@ -162,8 +162,7 @@ export function ProjectEditSheet({
iconDataUri={state.previewDataUri}
initial={projectInitial(snapshot.projectName)}
projectViewKey={projectViewKey}
imageStyle={styles.preview}
fallbackStyle={styles.previewFallback}
size={40}
textStyle={styles.previewText}
/>
<Button
@@ -273,18 +272,6 @@ const styles = StyleSheet.create((theme) => ({
flexWrap: "wrap",
gap: theme.spacing[2],
},
preview: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
},
previewFallback: {
width: 40,
height: 40,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
previewText: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
@@ -1,44 +1,62 @@
import { useMemo } from "react";
import {
Image,
type ImageStyle,
type StyleProp,
Text,
type TextStyle,
View,
type ViewStyle,
} from "react-native";
import { Image, type StyleProp, Text, type TextStyle, View } from "react-native";
import { deriveIdentityColorName, identityColor } from "@/styles/identity-colors";
const WHITE_TEXT = { color: "#ffffff" } as const;
const FALLBACK_LAYOUT = { alignItems: "center", justifyContent: "center" } as const;
/**
* Corner radius of the *generated* project icon — the colored square with an initial — as a
* fraction of the box, so it reads as the same shape at 16pt in the sidebar and 40pt in the edit
* sheet. Fixed tokens did not give that: the radius scale is coarse at the bottom, so small icons
* landed on 2pt and looked square while large ones were visibly rounder.
*
* This is ours to shape. A **user-uploaded icon is never rounded** — it is someone's mark, and
* clipping its corners distorts branding we don't own. A square logo stays square, a round one is
* already round.
*/
const RADIUS_RATIO = 0.25;
export function projectIconRadius(size: number): number {
return Math.round(size * RADIUS_RATIO);
}
/**
* A project's icon: its chosen image, or a colored square carrying its initial.
*
* Geometry lives here, not at the call site. It used to be five copies of the same
* width/height/radius/centering block, which is how the radius drifted apart in the first
* place — pass a `size` and the shape follows.
*/
export function ProjectIconView({
iconDataUri,
initial,
projectViewKey,
imageStyle,
fallbackStyle,
size,
textStyle,
}: {
iconDataUri: string | null;
initial: string;
projectViewKey: string;
imageStyle: StyleProp<ImageStyle>;
fallbackStyle: StyleProp<ViewStyle>;
size: number;
textStyle: StyleProp<TextStyle>;
}) {
const imageSource = useMemo(() => ({ uri: iconDataUri ?? "" }), [iconDataUri]);
// The uploaded image is sized but never clipped — see projectIconRadius.
const box = useMemo(() => ({ width: size, height: size }), [size]);
const fallbackStyles = useMemo(
() => [
fallbackStyle,
box,
{ borderRadius: projectIconRadius(size) },
FALLBACK_LAYOUT,
{ backgroundColor: identityColor(deriveIdentityColorName(projectViewKey)) },
],
[fallbackStyle, projectViewKey],
[box, size, projectViewKey],
);
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
if (iconDataUri) {
return <Image source={imageSource} style={imageStyle} />;
return <Image source={imageSource} style={box} />;
}
return (
<View style={fallbackStyles}>
@@ -1,6 +1,9 @@
import { useEffect, useMemo, useRef } from "react";
import { Animated, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { projectIconRadius } from "@/components/project-icon-view";
const PROJECT_ICON_SIZE = 16;
const SECTION_OPACITIES: readonly number[] = [1, 0.7, 0.4];
const SECTION_KEYS = SECTION_OPACITIES.map((_, i) => `skeleton-section-${i}`);
@@ -116,9 +119,10 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface2,
},
projectIcon: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
width: PROJECT_ICON_SIZE,
height: PROJECT_ICON_SIZE,
// Matches the real icon it stands in for, so the skeleton doesn't change shape on load.
borderRadius: projectIconRadius(PROJECT_ICON_SIZE),
backgroundColor: theme.colors.surface2,
},
sectionTitle: {
@@ -33,9 +33,9 @@ import {
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { type GestureType } from "react-native-gesture-handler";
import * as Clipboard from "expo-clipboard";
import { DiffStat } from "@/components/diff-stat";
import {
ExternalLink,
GitPullRequest,
@@ -111,6 +111,11 @@ import {
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -142,6 +147,7 @@ import { getDesktopHost } from "@/desktop/host";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { useLocalDaemonServerId, useLocalDaemonServerIdState } from "@/hooks/use-is-local-daemon";
import { selectHostBadges, type HostBadgeModel } from "@/hosts/appearance";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
const workspaceKeyExtractor = (workspace: SidebarWorkspacePlacement) => workspace.workspaceKey;
@@ -163,13 +169,13 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const redColorMapping = (theme: Theme) => ({
color: theme.colors.palette.red[500],
color: theme.colors.statusMutedDanger,
});
const greenColorMapping = (theme: Theme) => ({
color: theme.colors.palette.green[500],
color: theme.colors.statusMutedSuccess,
});
const purpleColorMapping = (theme: Theme) => ({
color: theme.colors.palette.purple[500],
color: theme.colors.statusMutedMerged,
});
function getPrIconUniMapping(state: PrHint["state"]) {
@@ -353,7 +359,6 @@ export function PrBadge({ hint, style }: { hint: PrHint; style?: StyleProp<ViewS
<ThemedGitPullRequest size={12} uniProps={iconUniProps} />
)}
<Text style={textStyle} numberOfLines={1}>
{presentation.numberPrefix}
{hint.number}
</Text>
</Pressable>
@@ -642,34 +647,32 @@ function WorkspaceRowRightGroup({
}) {
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
const { t } = useTranslation();
const trailing = useSidebarWorkspaceTrailing();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
showDiffStat,
showTrailing,
showKebab: showKebabInSlot,
showScrim,
renderSlot,
reserveSlotWidth,
} = resolveTrailingActionVisibility({
hasDiffStat: Boolean(workspace.diffStat),
workspace,
trailing,
hasArchiveAction: Boolean(onArchive),
isHovered,
isTouchPlatform,
showShortcut,
});
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
{onArchive ? (
@@ -928,6 +931,7 @@ function ProjectHeaderRow({
iconDataUri={iconDataUri}
statusBucket={statusBucket}
projectViewKey={project.viewKey}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
chevron={chevron}
showChevron={isHovered && chevron !== null}
isArchiving={isArchiving}
@@ -1078,14 +1082,7 @@ function WorkspaceRowInner({
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
const workspaceRowStyle = getProjectWorkspaceRowStyle({
isDragging,
selected,
@@ -1133,7 +1130,8 @@ function WorkspaceRowInner({
hostBadge={hostBadge}
leadingProjectName={leadingProjectName}
leadingProjectIconDataUri={leadingProjectIconDataUri}
scriptIconKind={scriptIconKind}
scriptSummary={scriptSummary}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -1892,15 +1890,20 @@ export function SidebarWorkspaceList({
const hosts = useHosts();
const localServerId = useLocalDaemonServerId();
const localDaemon = useLocalDaemonServerIdState();
const rowItems = useSidebarRowItems();
// Host badge visibility is a lattice, not three competing switches: this gate is the global
// "off", `shouldShowSidebarHostLabels` is the automatic "there is only one host so it says
// nothing", and each host's own `badgeDisplay` decides name vs icon vs hidden. Turning the
// item off here removes the badge everywhere; leaving it on defers to the per-host setting.
const hostBadgeByServerId = useMemo(
() =>
selectHostBadges({
hosts,
localServerId,
localHostResolutionPending: localDaemon.status !== "resolved",
enabled: shouldShowSidebarHostLabels(projects),
enabled: rowItems.host && shouldShowSidebarHostLabels(projects),
}),
[hosts, localDaemon.status, localServerId, projects],
[hosts, localDaemon.status, localServerId, projects, rowItems.host],
);
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
@@ -0,0 +1,406 @@
import { useCallback, useMemo, type ComponentType, type ReactElement, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
CircleCheck,
CircleDashed,
Clock,
Diff,
Folder,
GitBranch,
GitPullRequest,
Server,
Settings2,
SquareTerminal,
Type,
} from "lucide-react-native";
import {
MenuItem,
MenuRoot,
MenuSeparator,
MenuSubTrigger,
MenuSurface,
MenuTrigger,
type MenuPageDefinition,
} from "@/components/ui/menu";
import { HostStatusDot } from "@/components/host-status-dot";
import { isWeb } from "@/constants/platform";
import { useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
import type { SidebarGroupMode } from "@/stores/sidebar-view-store";
import type { WorkspaceTitleSource } from "@/hooks/use-settings";
import { useSidebarDisplayPreferences, type SidebarTrailingChoice } from "./model";
import { SIDEBAR_ROW_ITEMS, type SidebarRowItem } from "./row-items";
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const ThemedSettings2 = withUnistyles(Settings2);
/** Fits the item's 16pt leading slot with a hair of room, matching the trailing check. */
const OPTION_ICON_SIZE = 14;
const MENU_WIDTH = 232;
type OptionIcon = ComponentType<{
size: number;
uniProps: (theme: Theme) => { color: string };
}>;
// Options carry icons; the root rows deliberately do not. The root is four labels with their
// current values, and a column of icons there would be decoration competing with the values.
const GROUPING_ICONS: Record<SidebarGroupMode, OptionIcon> = {
project: withUnistyles(Folder),
status: withUnistyles(CircleDashed),
};
const TITLE_SOURCE_ICONS: Record<WorkspaceTitleSource, OptionIcon> = {
title: withUnistyles(Type),
branch: withUnistyles(GitBranch),
};
// The same marks these things carry on the workspace row itself, so the menu and the row it
// configures name each item the same way twice.
const ROW_ITEM_ICONS: Record<SidebarRowItem, OptionIcon> = {
host: withUnistyles(Server),
changeRequest: withUnistyles(GitPullRequest),
checks: withUnistyles(CircleCheck),
scripts: withUnistyles(SquareTerminal),
};
const TRAILING_ICONS: Record<SidebarTrailingChoice, OptionIcon> = {
diff: withUnistyles(Diff),
timestamp: withUnistyles(Clock),
};
const GROUPING_MODES: readonly SidebarGroupMode[] = ["project", "status"];
const TITLE_SOURCES: readonly WorkspaceTitleSource[] = ["title", "branch"];
const TRAILING_CHOICES: readonly SidebarTrailingChoice[] = ["diff", "timestamp"];
const GROUPING_LABEL_KEYS: Record<SidebarGroupMode, string> = {
project: "sidebar.display.grouping.project",
status: "sidebar.display.grouping.status",
};
const TITLE_SOURCE_LABEL_KEYS: Record<WorkspaceTitleSource, string> = {
title: "sidebar.display.titleSource.title",
branch: "sidebar.display.titleSource.branch",
};
const ROW_ITEM_LABEL_KEYS: Record<SidebarRowItem, string> = {
host: "sidebar.display.show.host",
changeRequest: "sidebar.display.show.changeRequest",
checks: "sidebar.display.show.checks",
scripts: "sidebar.display.show.scripts",
};
const TRAILING_LABEL_KEYS: Record<SidebarTrailingChoice, string> = {
diff: "sidebar.display.show.diff",
timestamp: "sidebar.display.show.timestamp",
};
/**
* What the sidebar shows and how it is arranged.
*
* The root is one row per decision with its current value; the options live a level down. The
* shape is deliberate — every option of every decision on one surface is what this menu used to
* be, and it grew a row for each host on top of that.
*/
export function SidebarDisplayPreferencesMenu(): ReactElement {
const { t } = useTranslation();
const preferences = useSidebarDisplayPreferences();
const hosts = useHosts();
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
hovered && styles.triggerHovered,
],
[],
);
const showHostFilter = hosts.length > 1;
const pages = useMemo<MenuPageDefinition[]>(() => {
const definitions: MenuPageDefinition[] = [
{
id: "grouping",
title: t("sidebar.display.grouping.label"),
content: (
<OptionList
values={GROUPING_MODES}
icons={GROUPING_ICONS}
labelKeys={GROUPING_LABEL_KEYS}
selectedValue={preferences.grouping}
onSelect={preferences.setGrouping}
testIDPrefix="sidebar-grouping"
/>
),
},
{
id: "titleSource",
title: t("sidebar.display.titleSource.label"),
content: (
<OptionList
values={TITLE_SOURCES}
icons={TITLE_SOURCE_ICONS}
labelKeys={TITLE_SOURCE_LABEL_KEYS}
selectedValue={preferences.titleSource}
onSelect={preferences.setTitleSource}
testIDPrefix="sidebar-workspace-title-source"
/>
),
},
{
id: "show",
title: t("sidebar.display.show.label"),
content: <ShowPage preferences={preferences} />,
},
];
if (showHostFilter) {
definitions.push({
id: "hostFilter",
title: t("sidebar.display.hostFilter.label"),
content: <HostFilterPage preferences={preferences} hosts={hosts} />,
});
}
return definitions;
}, [t, preferences, hosts, showHostFilter]);
return (
<MenuRoot compactMode="sheet">
<MenuTrigger
style={triggerStyle}
accessibilityRole={isWeb ? undefined : "button"}
accessibilityLabel={t("sidebar.display.trigger")}
testID="sidebar-display-preferences-menu"
>
<ThemedSettings2 size={14} uniProps={mutedIconMapping} />
</MenuTrigger>
<MenuSurface
align="end"
width={MENU_WIDTH}
pages={pages}
sheetTitle={t("sidebar.display.heading")}
testID="sidebar-display-preferences-content"
>
<MenuSubTrigger
id="grouping"
value={t(GROUPING_LABEL_KEYS[preferences.grouping])}
testID="sidebar-display-grouping"
>
{t("sidebar.display.grouping.label")}
</MenuSubTrigger>
<MenuSubTrigger
id="titleSource"
value={t(TITLE_SOURCE_LABEL_KEYS[preferences.titleSource])}
testID="sidebar-display-title-source"
>
{t("sidebar.display.titleSource.label")}
</MenuSubTrigger>
<MenuSubTrigger id="show" testID="sidebar-display-show">
{t("sidebar.display.show.label")}
</MenuSubTrigger>
{showHostFilter ? (
<>
<MenuSeparator />
{/* A filtered sidebar looks like workspaces went missing, so the branch says so
from the root rather than making you open it to find out. */}
<MenuSubTrigger
id="hostFilter"
indicator={preferences.hostFilters.length > 0}
testID="sidebar-display-host-filter"
>
{t("sidebar.display.hostFilter.label")}
</MenuSubTrigger>
</>
) : null}
</MenuSurface>
</MenuRoot>
);
}
type Preferences = ReturnType<typeof useSidebarDisplayPreferences>;
/** One option row: its mark on the left, its label, and a check when it is the chosen one. */
function OptionItem<Value extends string>({
value,
icon: Icon,
label,
selected,
closeOnSelect = true,
onSelect,
testID,
}: {
value: Value;
icon: OptionIcon;
label: string;
selected: boolean;
closeOnSelect?: boolean;
onSelect: (value: Value) => void;
testID: string;
}): ReactElement {
const handleSelect = useCallback(() => onSelect(value), [onSelect, value]);
const leading = useMemo(
() => <Icon size={OPTION_ICON_SIZE} uniProps={mutedIconMapping} />,
[Icon],
);
return (
<MenuItem
selected={selected}
leading={leading}
closeOnSelect={closeOnSelect}
onSelect={handleSelect}
testID={testID}
>
{label}
</MenuItem>
);
}
/** A page of mutually exclusive options — pick one and the menu closes. */
function OptionList<Value extends string>({
values,
icons,
labelKeys,
selectedValue,
onSelect,
testIDPrefix,
}: {
values: readonly Value[];
icons: Record<Value, OptionIcon>;
labelKeys: Record<Value, string>;
selectedValue: Value;
onSelect: (value: Value) => void;
testIDPrefix: string;
}): ReactNode {
const { t } = useTranslation();
return values.map((value) => (
<OptionItem
key={value}
value={value}
icon={icons[value]}
label={t(labelKeys[value])}
selected={value === selectedValue}
onSelect={onSelect}
testID={`${testIDPrefix}-${value}`}
/>
));
}
/**
* Two groups, split by the separator. Above it, what a row may say about a workspace — each one
* independent. Below it, the one thing the slot to the right of the title holds, so picking the
* one already showing empties the slot and gives the width back to the title.
*/
function ShowPage({ preferences }: { preferences: Preferences }): ReactElement {
const { t } = useTranslation();
return (
<>
{SIDEBAR_ROW_ITEMS.map((item) => (
<OptionItem
key={item}
value={item}
icon={ROW_ITEM_ICONS[item]}
label={t(ROW_ITEM_LABEL_KEYS[item])}
selected={preferences.rowItems[item]}
closeOnSelect={false}
onSelect={preferences.toggleRowItem}
testID={`sidebar-row-item-${item}`}
/>
))}
<MenuSeparator />
{TRAILING_CHOICES.map((choice) => (
<OptionItem
key={choice}
value={choice}
icon={TRAILING_ICONS[choice]}
label={t(TRAILING_LABEL_KEYS[choice])}
selected={preferences.trailing === choice}
closeOnSelect={false}
onSelect={preferences.toggleTrailing}
testID={`sidebar-workspace-trailing-${choice}`}
/>
))}
</>
);
}
function HostFilterPage({
preferences,
hosts,
}: {
preferences: Preferences;
hosts: ReturnType<typeof useHosts>;
}): ReactElement {
const { t } = useTranslation();
return (
<>
<MenuItem
selected={preferences.hostFilters.length === 0}
closeOnSelect={false}
onSelect={preferences.clearHostFilters}
testID="sidebar-host-filter-all"
>
{t("sidebar.display.hostFilter.all")}
</MenuItem>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
serverId={host.serverId}
label={host.label?.trim() || host.serverId}
selected={preferences.hostFilters.includes(host.serverId)}
onToggle={preferences.toggleHostFilter}
/>
))}
</>
);
}
/** The one option row whose mark is live state rather than an icon. */
function HostFilterItem({
serverId,
label,
selected,
onToggle,
}: {
serverId: string;
label: string;
selected: boolean;
onToggle: (serverId: string) => void;
}): ReactElement {
const handleSelect = useCallback(() => onToggle(serverId), [onToggle, serverId]);
const leading = useMemo(
() => (
<View testID={`sidebar-host-filter-status-${serverId}`}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId],
);
return (
<MenuItem
selected={selected}
closeOnSelect={false}
leading={leading}
onSelect={handleSelect}
testID={`sidebar-host-filter-${serverId}`}
>
{label}
</MenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
triggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
}));
@@ -0,0 +1,112 @@
import { useCallback, useMemo } from "react";
import {
useAppSettings,
type SidebarWorkspaceTrailing,
type WorkspaceTitleSource,
} from "@/hooks/use-settings";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
import { DEFAULT_SIDEBAR_ROW_ITEMS, type SidebarRowItem, type SidebarRowItems } from "./row-items";
/** The trailing slot holds one thing, so these are a choice rather than toggles. */
export type SidebarTrailingChoice = Exclude<SidebarWorkspaceTrailing, "none">;
export interface SidebarDisplayPreferences {
grouping: SidebarGroupMode;
setGrouping: (mode: SidebarGroupMode) => void;
titleSource: WorkspaceTitleSource;
setTitleSource: (source: WorkspaceTitleSource) => void;
rowItems: SidebarRowItems;
toggleRowItem: (item: SidebarRowItem) => void;
trailing: SidebarWorkspaceTrailing;
/** Picking the choice that is already showing clears the slot. */
toggleTrailing: (choice: SidebarTrailingChoice) => void;
hostFilters: readonly string[];
toggleHostFilter: (serverId: string) => void;
clearHostFilters: () => void;
}
/**
* Every decision the sidebar's display-preferences menu can make, behind one interface.
*
* Grouping and host filters live in a local zustand store while the title source and row items
* are synced app settings — a split that exists for good reasons (a filter is transient view
* state; a preference follows you) and that the menu has no business knowing about. Callers ask
* this for a value and set it; where it lands is this module's problem.
*/
export function useSidebarDisplayPreferences(): SidebarDisplayPreferences {
const grouping = useSidebarViewStore((state) => state.groupMode);
const setGrouping = useSidebarViewStore((state) => state.setGroupMode);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
const {
settings: { workspaceTitleSource, sidebarWorkspaceTrailing, sidebarRowItems },
updateSettings,
} = useAppSettings();
const setTitleSource = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
},
[updateSettings],
);
const toggleRowItem = useCallback(
(item: SidebarRowItem) => {
void updateSettings({
sidebarRowItems: { ...sidebarRowItems, [item]: !sidebarRowItems[item] },
});
},
[updateSettings, sidebarRowItems],
);
const toggleTrailing = useCallback(
(choice: SidebarTrailingChoice) => {
void updateSettings({
sidebarWorkspaceTrailing: sidebarWorkspaceTrailing === choice ? "none" : choice,
});
},
[updateSettings, sidebarWorkspaceTrailing],
);
return useMemo(
() => ({
grouping,
setGrouping,
titleSource: workspaceTitleSource,
setTitleSource,
rowItems: sidebarRowItems,
toggleRowItem,
trailing: sidebarWorkspaceTrailing,
toggleTrailing,
hostFilters,
toggleHostFilter,
clearHostFilters,
}),
[
grouping,
setGrouping,
workspaceTitleSource,
setTitleSource,
sidebarRowItems,
toggleRowItem,
sidebarWorkspaceTrailing,
toggleTrailing,
hostFilters,
toggleHostFilter,
clearHostFilters,
],
);
}
/**
* Just the row items, for the row renderers. They re-render per workspace, so they subscribe to
* the one field they use rather than to every preference in the menu.
*/
export function useSidebarRowItems(): SidebarRowItems {
const {
settings: { sidebarRowItems },
} = useAppSettings();
return sidebarRowItems ?? DEFAULT_SIDEBAR_ROW_ITEMS;
}
@@ -0,0 +1,44 @@
import { describe, expect, it } from "vitest";
import { DEFAULT_SIDEBAR_ROW_ITEMS, parseSidebarRowItems, SIDEBAR_ROW_ITEMS } from "./row-items";
describe("parseSidebarRowItems", () => {
it("shows everything by default", () => {
for (const item of SIDEBAR_ROW_ITEMS) {
expect(DEFAULT_SIDEBAR_ROW_ITEMS[item]).toBe(true);
}
});
it("applies stored overrides", () => {
expect(parseSidebarRowItems({ checks: false })).toEqual({
...DEFAULT_SIDEBAR_ROW_ITEMS,
checks: false,
});
});
it("leaves items absent from storage at their default", () => {
// A newly added item must ship visible rather than inheriting "missing means off".
expect(parseSidebarRowItems({ host: false })).toEqual({
...DEFAULT_SIDEBAR_ROW_ITEMS,
host: false,
});
});
it("ignores unknown keys and non-boolean values", () => {
expect(
parseSidebarRowItems({ checks: "yes", nonsense: false, scripts: null, host: false }),
).toEqual({ ...DEFAULT_SIDEBAR_ROW_ITEMS, host: false });
});
it.each([[null], [undefined], ["diff"], [42], [["checks"]]])(
"falls back to defaults for %s",
(value) => {
expect(parseSidebarRowItems(value)).toEqual(DEFAULT_SIDEBAR_ROW_ITEMS);
},
);
it("does not hand back the shared default object", () => {
const parsed = parseSidebarRowItems({ checks: false });
expect(parsed).not.toBe(DEFAULT_SIDEBAR_ROW_ITEMS);
expect(DEFAULT_SIDEBAR_ROW_ITEMS.checks).toBe(true);
});
});
@@ -0,0 +1,47 @@
/**
* Which facts a sidebar workspace row is allowed to show about a workspace.
*
* Each item is independent — this is not a mode. Turning one off removes it from every row and
* gives the space back to the title.
*
* Pure on purpose: `hooks/use-settings/storage.ts` validates the persisted value through
* `parseSidebarRowItems` rather than growing its own checks, so this module stays the one place
* that knows what an item is.
*/
export const SIDEBAR_ROW_ITEMS = ["host", "changeRequest", "checks", "scripts"] as const;
export type SidebarRowItem = (typeof SIDEBAR_ROW_ITEMS)[number];
export type SidebarRowItems = Record<SidebarRowItem, boolean>;
/**
* Everything on by default. A new item ships visible and users turn it off, which is also why
* the persisted shape is a record of overrides rather than a list of enabled items: an absent
* key has to mean "default", not "off".
*/
export const DEFAULT_SIDEBAR_ROW_ITEMS: SidebarRowItems = {
host: true,
changeRequest: true,
checks: true,
scripts: true,
};
function isSidebarRowItem(value: string): value is SidebarRowItem {
return (SIDEBAR_ROW_ITEMS as readonly string[]).includes(value);
}
export function parseSidebarRowItems(value: unknown): SidebarRowItems {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return DEFAULT_SIDEBAR_ROW_ITEMS;
}
const stored = value as Record<string, unknown>;
const result = { ...DEFAULT_SIDEBAR_ROW_ITEMS };
for (const [key, entry] of Object.entries(stored)) {
if (isSidebarRowItem(key) && typeof entry === "boolean") {
result[key] = entry;
}
}
return result;
}
@@ -1,116 +0,0 @@
import { Text, View, type TextStyle, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Server } from "lucide-react-native";
import { HOST_COLORS, type HostColor } from "@/hosts/appearance";
import { identityColor, identityTint } from "@/styles/identity-colors";
import type { Theme } from "@/styles/theme";
const ThemedServer = withUnistyles(Server);
const MAX_NAMED_BADGE_WIDTH = 96;
interface HostBadgeColorOverride {
badge: ViewStyle | null;
text: TextStyle | null;
}
function buildColorOverrides(): Record<HostColor, HostBadgeColorOverride> {
const byColor = {} as Record<HostColor, HostBadgeColorOverride>;
for (const color of HOST_COLORS) {
byColor[color] =
color === "none"
? { badge: null, text: null }
: {
badge: { backgroundColor: identityTint(color) },
text: { color: identityColor(color) },
};
}
return byColor;
}
// The identity table is theme-independent, so its icon mapping is fixed for the life of the
// module. It must not be rebuilt per render: a fresh `uniProps` identity makes withUnistyles
// re-subscribe every pass.
function buildIconMappings(): Record<HostColor, (theme: Theme) => { color: string }> {
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
for (const color of HOST_COLORS) {
byColor[color] =
color === "none"
? (theme: Theme) => ({ color: theme.colors.foregroundMuted })
: () => ({ color: identityColor(color) });
}
return byColor;
}
const COLOR_OVERRIDES = buildColorOverrides();
const ICON_MAPPINGS = buildIconMappings();
/**
* Which host a workspace lives on, as a pill after the title. Only rendered in multi-host
* setups — see selectHostBadges. It's a badge rather than a second line of text because the
* row has to stay scannable at one line per workspace.
*
*/
export function HostBadge({
serverId,
label,
color,
showLabel,
}: {
serverId: string;
label: string;
color: HostColor;
showLabel: boolean;
}) {
const override = COLOR_OVERRIDES[color];
return (
<View
style={[
hostBadgeStyles.badge,
showLabel ? null : hostBadgeStyles.badgeIconOnly,
override.badge,
]}
testID={`sidebar-host-badge-${serverId}`}
>
<ThemedServer size={9} uniProps={ICON_MAPPINGS[color]} />
{showLabel ? (
<Text style={[hostBadgeStyles.text, override.text]} numberOfLines={1}>
{label}
</Text>
) : null}
</View>
);
}
const hostBadgeStyles = StyleSheet.create((theme) => ({
// Sized off the title's 20pt line box so the pill never grows the row. A named badge must
// leave room for the workspace title and trailing actions, even when a host has an arbitrary
// custom label. The full host name remains in the row's accessible label and hover card.
badge: {
flexDirection: "row",
alignItems: "center",
gap: 3,
height: 16,
paddingLeft: 4,
paddingRight: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
maxWidth: MAX_NAMED_BADGE_WIDTH,
minWidth: 0,
flexShrink: 0,
},
// Icon-only drops the label, so the asymmetric padding that made room for text leaves a
// lopsided pill. Symmetric padding recenters the icon.
badgeIconOnly: {
paddingLeft: 4,
paddingRight: 4,
flexShrink: 0,
},
text: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
lineHeight: 14,
color: theme.colors.foregroundMuted,
flexShrink: 1,
minWidth: 0,
},
}));
@@ -4,7 +4,7 @@ import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
import { STATUS_BUCKET_LABELS } from "@/hooks/sidebar-status-view-model";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
getProjectStatusBadgeContent,
@@ -13,25 +13,25 @@ import {
} from "@/utils/project-status-badge-content";
import { projectIconPlaceholderLabelFromDisplayName } from "@/utils/project-display-name";
import { getStatusDotColor } from "@/utils/status-dot-color";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
// Every surfaced status shares one badge shell, so the badge never changes size or position
// between states. Only the thing inside it changes.
const STATUS_BADGE_SIZE = 14;
const STATUS_BADGE_BORDER_WIDTH = 1;
const STATUS_BADGE_SIZE = 12;
// How far the badge overhangs the icon's bottom-right corner. Well short of half the badge:
// centered on the corner it reads as hanging off the icon rather than sitting on it, and the
// sidebar row has no padding there to absorb the overhang.
const STATUS_BADGE_OFFSET = -5;
// Both glyphs must be EVEN. The shell's interior is 12pt (14 minus the 1pt ring on each side),
// so a centered glyph of size N sits at a (12 - N) / 2 offset — fractional for odd N, which the
// browser snaps to a device-pixel boundary and renders visibly off-center (an odd size measured
// 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps). Even sizes
// divide the interior into whole pixels and land dead center with no correction.
const STATUS_BADGE_OFFSET = -4;
// Both glyphs must be EVEN. A centered glyph of size N sits at a (12 - N) / 2 offset — fractional
// for odd N, which the browser snaps to a device-pixel boundary and renders visibly off-center (an
// odd size measured 1.5 device px right and down at 3x, ~3px of asymmetry between opposite gaps).
// Even sizes divide the shell into whole pixels and land dead center with no correction.
//
// Lucide's circle-alert paints ~83% of its nominal size, so an alert of 10 draws a ~8.3pt circle
// against the 8pt dot — the two states read as the same-diameter disc.
const STATUS_BADGE_DOT_SIZE = 8;
const STATUS_BADGE_ALERT_SIZE = 10;
// Lucide's circle-alert paints ~83% of its nominal size, so an alert of 8 draws a ~6.6pt circle
// against the 6pt dot — the two states read as the same-diameter disc. 8 is the closest even
// size to the exact match (6 / 0.83 = 7.2); 6 would undershoot the dot rather than meet it.
const STATUS_BADGE_DOT_SIZE = 6;
const STATUS_BADGE_ALERT_SIZE = 8;
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
@@ -55,6 +55,7 @@ export function ProjectLeadingVisual({
iconDataUri,
statusBucket,
projectViewKey,
backdrop,
chevron = null,
showChevron = false,
isArchiving = false,
@@ -64,6 +65,8 @@ export function ProjectLeadingVisual({
/** Aggregate status of the project's workspaces; null when it shouldn't be surfaced. */
statusBucket: SidebarStateBucket | null;
projectViewKey: string;
/** The row's current background, so the status badge can knock out of it. */
backdrop: SurfaceBackdrop;
chevron?: "expand" | "collapse" | null;
showChevron?: boolean;
isArchiving?: boolean;
@@ -90,6 +93,7 @@ export function ProjectLeadingVisual({
displayName={displayName}
projectViewKey={projectViewKey}
statusBucket={statusBucket}
backdrop={backdrop}
/>
);
}
@@ -104,6 +108,7 @@ export function ProjectStatusIndicator({
displayName,
projectViewKey,
statusBucket,
backdrop,
loading = false,
testID,
}: {
@@ -111,13 +116,15 @@ export function ProjectStatusIndicator({
displayName: string;
projectViewKey: string;
statusBucket: SidebarStateBucket | null;
/** The row's current background, so the status badge can knock out of it. */
backdrop: SurfaceBackdrop;
loading?: boolean;
testID?: string;
}) {
const placeholderInitial = projectIconPlaceholderLabelFromDisplayName(displayName)
.charAt(0)
.toUpperCase();
// A row that's still starting up and a row that's working are both "busy", and at 8pt
// A row that's still starting up and a row that's working are both "busy", and at this size
// there's no room to draw the difference — so `loading` just resolves to the running bucket
// and they share one badge.
const badgeBucket = loading ? "running" : statusBucket;
@@ -140,7 +147,11 @@ export function ProjectStatusIndicator({
projectViewKey={projectViewKey}
/>
{badgeContent === null || badgeBucket === null ? null : (
<ProjectStatusBadge content={badgeContent} statusBucket={badgeBucket} />
<ProjectStatusBadge
content={badgeContent}
statusBucket={badgeBucket}
backdrop={backdrop}
/>
)}
</View>
</View>
@@ -150,15 +161,17 @@ export function ProjectStatusIndicator({
function ProjectStatusBadge({
content,
statusBucket,
backdrop,
}: {
content: ProjectStatusBadgeContent;
statusBucket: SidebarStateBucket;
backdrop: SurfaceBackdrop;
}) {
return (
<View
role="status"
accessibilityLabel={STATUS_BUCKET_LABELS[statusBucket]}
style={styles.statusBadge}
style={[styles.statusBadge, getStatusBadgeBackdropStyle(backdrop)]}
testID="project-status-badge"
>
{content.kind === "alert" ? (
@@ -170,6 +183,17 @@ function ProjectStatusBadge({
);
}
function getStatusBadgeBackdropStyle(backdrop: SurfaceBackdrop): ViewStyle {
switch (backdrop) {
case "surfaceSidebarHover":
return styles.statusBadgeOnSidebarHover;
case "surface2":
return styles.statusBadgeOnSurface2;
default:
return styles.statusBadgeOnSidebar;
}
}
function ProjectStatusDot({ bucket }: { bucket: ProjectStatusBadgeDotBucket }) {
if (bucket !== "running") {
return <View testID="project-status-dot" style={getStatusDotColorStyle(bucket)} />;
@@ -191,8 +215,7 @@ function ProjectIcon({
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectViewKey={projectViewKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
size={ICON_SIZE.md}
textStyle={styles.projectIconFallbackText}
/>
);
@@ -245,24 +268,13 @@ const styles = StyleSheet.create((theme) => {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
projectIcon: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
},
projectIconFallback: {
width: "100%",
height: "100%",
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: {
fontSize: 9,
},
// The shell the alert and dot statuses share. It straddles the icon's bottom-right corner
// (half in, half out) so the lettered project box stays readable, and its ring is
// sidebar-colored so the badge separates from the icon underneath rather than blending in.
// (half in, half out) so the lettered project box stays readable. The shell is a knockout:
// it is filled with the colour of the row behind it, which is what makes the ring around the
// dot read as a gap in the icon rather than as a white halo drawn on top of it.
statusBadge: {
position: "absolute",
right: STATUS_BADGE_OFFSET,
@@ -270,13 +282,13 @@ const styles = StyleSheet.create((theme) => {
width: STATUS_BADGE_SIZE,
height: STATUS_BADGE_SIZE,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface0,
borderWidth: STATUS_BADGE_BORDER_WIDTH,
borderColor: theme.colors.surfaceSidebar,
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
},
statusBadgeOnSidebar: { backgroundColor: theme.colors.surfaceSidebar },
statusBadgeOnSidebarHover: { backgroundColor: theme.colors.surfaceSidebarHover },
statusBadgeOnSurface2: { backgroundColor: theme.colors.surface2 },
statusDotRunning: statusDot("running"),
statusDotFailed: statusDot("failed"),
statusDotAttention: statusDot("attention"),
@@ -1,220 +0,0 @@
import { useCallback, useMemo } from "react";
import { Text, View, type PressableStateCallbackType } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Settings2 } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { HostStatusDot } from "@/components/host-status-dot";
import { isWeb as platformIsWeb } from "@/constants/platform";
import { useAppSettings, type WorkspaceTitleSource } from "@/hooks/use-settings";
import { useHosts } from "@/runtime/host-runtime";
import { useSidebarViewStore, type SidebarGroupMode } from "@/stores/sidebar-view-store";
const ThemedSettings2 = withUnistyles(Settings2);
const filterColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const GROUP_MODE_ITEMS: Array<{ value: SidebarGroupMode; label: string }> = [
{ value: "project", label: "Project" },
{ value: "status", label: "Status" },
];
const WORKSPACE_TITLE_SOURCE_ITEMS: Array<{ value: WorkspaceTitleSource; label: string }> = [
{ value: "title", label: "Title" },
{ value: "branch", label: "Branch name" },
];
interface DisplayPreferenceOption<Value extends string> {
value: Value;
label: string;
}
export function SidebarDisplayPreferencesMenu() {
const groupMode = useSidebarViewStore((state) => state.groupMode);
const hostFilters = useSidebarViewStore((state) => state.hostFilters);
const setGroupMode = useSidebarViewStore((state) => state.setGroupMode);
const toggleHostFilter = useSidebarViewStore((state) => state.toggleHostFilter);
const clearHostFilters = useSidebarViewStore((state) => state.clearHostFilters);
const hosts = useHosts();
const {
settings: { workspaceTitleSource },
updateSettings,
} = useAppSettings();
const handleSelectMode = useCallback(
(mode: SidebarGroupMode) => {
setGroupMode(mode);
},
[setGroupMode],
);
const handleWorkspaceTitleSourceSelect = useCallback(
(source: WorkspaceTitleSource) => {
void updateSettings({ workspaceTitleSource: source });
},
[updateSettings],
);
const triggerStyle = useCallback(
({ hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.trigger,
hovered && styles.triggerHovered,
],
[],
);
const showHostFilter = hosts.length > 1;
const allHostsSelected = hostFilters.length === 0;
return (
<DropdownMenu>
<DropdownMenuTrigger
style={triggerStyle}
accessibilityRole={platformIsWeb ? undefined : "button"}
accessibilityLabel="Display preferences"
testID="sidebar-display-preferences-menu"
>
<ThemedSettings2 size={14} uniProps={filterColorMapping} />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" width={220} testID="sidebar-display-preferences-content">
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Group by</Text>
</View>
{GROUP_MODE_ITEMS.map((item) => (
<DisplayPreferenceMenuItem
key={item.value}
item={item}
isSelected={groupMode === item.value}
testIDPrefix="sidebar-grouping"
onSelect={handleSelectMode}
/>
))}
{showHostFilter ? (
<>
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Filter</Text>
</View>
<DropdownMenuItem
testID="sidebar-host-filter-all"
selected={allHostsSelected}
closeOnSelect={false}
onSelect={clearHostFilters}
>
All hosts
</DropdownMenuItem>
{hosts.map((host) => (
<HostFilterItem
key={host.serverId}
label={host.label?.trim() || host.serverId}
serverId={host.serverId}
selected={hostFilters.includes(host.serverId)}
onToggle={toggleHostFilter}
/>
))}
</>
) : null}
<DropdownMenuSeparator />
<View style={styles.menuHeader}>
<Text style={styles.menuHeaderLabel}>Workspace title</Text>
</View>
{WORKSPACE_TITLE_SOURCE_ITEMS.map((item) => (
<DisplayPreferenceMenuItem
key={item.value}
item={item}
isSelected={workspaceTitleSource === item.value}
testIDPrefix="sidebar-workspace-title-source"
onSelect={handleWorkspaceTitleSourceSelect}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
function DisplayPreferenceMenuItem<Value extends string>({
item,
isSelected,
testIDPrefix,
onSelect,
}: {
item: DisplayPreferenceOption<Value>;
isSelected: boolean;
testIDPrefix: string;
onSelect: (value: Value) => void;
}) {
const handleSelect = useCallback(() => onSelect(item.value), [item.value, onSelect]);
return (
<DropdownMenuItem
testID={`${testIDPrefix}-${item.value}`}
selected={isSelected}
onSelect={handleSelect}
>
<Text style={styles.optionLabel}>{item.label}</Text>
</DropdownMenuItem>
);
}
function HostFilterItem({
label,
serverId,
selected,
onToggle,
}: {
label: string;
serverId: string;
selected: boolean;
onToggle: (serverId: string) => void;
}) {
const handleSelect = useCallback(() => onToggle(serverId), [serverId, onToggle]);
const leading = useMemo(
() => (
<View testID={`sidebar-host-filter-status-${serverId}`}>
<HostStatusDot serverId={serverId} />
</View>
),
[serverId],
);
return (
<DropdownMenuItem
testID={`sidebar-host-filter-${serverId}`}
selected={selected}
closeOnSelect={false}
leading={leading}
onSelect={handleSelect}
>
{label}
</DropdownMenuItem>
);
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: 28,
height: 28,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.md,
},
triggerHovered: {
backgroundColor: theme.colors.surfaceSidebarHover,
},
menuHeader: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
menuHeaderLabel: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
},
optionLabel: {
fontSize: theme.fontSize.sm,
},
}));
@@ -0,0 +1,27 @@
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
/**
* Which surface a sidebar row is currently painting, so anything knocking out of it — the status
* badge on a project icon — can match.
*
* This restates the precedence of the row style arrays, which live in three separate renderers
* (`sidebar-workspace-list`, `sidebar-status-list`, `sidebar-workspace-row`). One copy of the rule
* here rather than three ternaries beside three stylesheets: drifting from them is how the badge
* ends up with a halo, and a halo in one grouping mode but not the other is worse than either.
*
* The pressed background is deliberately absent. It lives in a Pressable style callback and lasts
* as long as a finger is down, which is not long enough to see a mismatched ring.
*/
export function getSidebarRowBackdrop({
isDragging = false,
selected = false,
isHovered = false,
}: {
isDragging?: boolean;
selected?: boolean;
isHovered?: boolean;
}): SurfaceBackdrop {
if (isDragging) return "surface2";
if (selected || isHovered) return "surfaceSidebarHover";
return "surfaceSidebar";
}
@@ -19,7 +19,6 @@ import {
CircleDot,
CircleX,
} from "lucide-react-native";
import { DiffStat } from "@/components/diff-stat";
import { useToast } from "@/contexts/toast-context";
import { useMutation } from "@tanstack/react-query";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -40,7 +39,15 @@ import {
SidebarWorkspaceTrailingActionBase,
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
sidebarWorkspaceRowStyles,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
import {
SidebarWorkspaceContextMenu,
@@ -121,6 +128,7 @@ export function SidebarStatusWorkspaceList({
projectIconByProjectViewKey,
hostBadgeByServerId,
})}
inStatusGroup={false}
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
showShortcutBadge={showShortcutBadges}
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
@@ -399,6 +407,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
canPin,
onToggleWorkspacePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onWorkspacePress,
}: {
workspace: SidebarWorkspaceEntry;
@@ -410,6 +419,11 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
canPin: boolean;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
reserveIdleStatusIndicatorSpace?: boolean;
/**
* Whether the row sits under a status header, which is what it indents from. Pinned rows
* are a flat list under their own header and sit flush.
*/
inStatusGroup?: boolean;
onWorkspacePress?: () => void;
}) {
const activeWorkspaceSelection = useActiveWorkspaceSelection();
@@ -435,6 +449,7 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
canPin={canPin}
onToggleWorkspacePin={onToggleWorkspacePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
onPress={handlePress}
/>
);
@@ -451,6 +466,7 @@ function StatusWorkspaceRowWithMenu({
canPin,
onToggleWorkspacePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
onPress,
}: {
workspace: SidebarWorkspaceEntry;
@@ -463,6 +479,11 @@ function StatusWorkspaceRowWithMenu({
canPin: boolean;
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
reserveIdleStatusIndicatorSpace?: boolean;
/**
* Whether the row sits under a status header, which is what it indents from. Pinned rows
* are a flat list under their own header and sit flush.
*/
inStatusGroup?: boolean;
onPress: () => void;
}) {
const { t } = useTranslation();
@@ -584,6 +605,7 @@ function StatusWorkspaceRowWithMenu({
isPinned={isPinned}
onTogglePin={onTogglePin}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
inStatusGroup={inStatusGroup}
/>
<AdaptiveRenameModal
visible={isRenameOpen}
@@ -621,6 +643,7 @@ function StatusWorkspaceRowInner({
isPinned,
onTogglePin,
reserveIdleStatusIndicatorSpace = true,
inStatusGroup = true,
}: {
workspace: SidebarWorkspaceEntry;
hostBadge: HostBadgeModel | null;
@@ -643,18 +666,17 @@ function StatusWorkspaceRowInner({
isPinned?: boolean;
onTogglePin?: () => void;
reserveIdleStatusIndicatorSpace?: boolean;
/**
* Whether the row sits under a status header, which is what it indents from. Pinned rows
* are a flat list under their own header and sit flush.
*/
inStatusGroup?: boolean;
}) {
const isTouchPlatform = platformIsNative;
const trailing = useSidebarWorkspaceTrailing();
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
const accessibilityState = useMemo(() => ({ selected }), [selected]);
@@ -663,18 +685,24 @@ function StatusWorkspaceRowInner({
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
showDiffStat,
showTrailing,
showKebab: showKebabInSlot,
showScrim,
renderSlot,
reserveSlotWidth,
} = resolveTrailingActionVisibility({
hasDiffStat: Boolean(workspace.diffStat),
workspace,
trailing,
hasArchiveAction: Boolean(onArchive),
isHovered,
isTouchPlatform,
showShortcut,
});
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
const workspaceRowStyle = getStatusWorkspaceRowStyle({
selected,
isHovered,
inStatusGroup,
});
return (
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
<SidebarWorkspaceContextMenu
@@ -683,7 +711,7 @@ function StatusWorkspaceRowInner({
workspace={workspace}
leadingProjectName={projectName}
hostBadgeLabel={hostBadge?.label}
scriptIconKind={scriptIconKind}
scriptSummary={scriptSummary}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -709,19 +737,22 @@ function StatusWorkspaceRowInner({
hostBadge={hostBadge}
leadingProjectName={projectName}
leadingProjectIconDataUri={projectIconDataUri}
scriptIconKind={scriptIconKind}
scriptSummary={scriptSummary}
backdrop={getSidebarRowBackdrop({ selected, isHovered })}
isHovered={isHovered}
isLoading={isArchiving}
shortcutNumber={shortcutNumber}
showShortcutBadge={showShortcutBadge}
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
>
{shouldRenderActionSlot ? (
{renderSlot ? (
<StatusWorkspaceActionSlot
workspace={workspace}
showBase={showDiffStat}
trailing={trailing}
showBase={showTrailing}
showKebab={showKebabInSlot}
showScrim={showScrim}
reserveSlotWidth={reserveSlotWidth}
isPinned={isPinned}
onTogglePin={onTogglePin}
onCopyPath={onCopyPath}
@@ -746,9 +777,11 @@ function StatusWorkspaceRowInner({
function StatusWorkspaceActionSlot({
workspace,
trailing,
showBase,
showKebab,
showScrim,
reserveSlotWidth,
isPinned,
onTogglePin,
onCopyPath,
@@ -762,9 +795,11 @@ function StatusWorkspaceActionSlot({
archiveShortcutKeys,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
showBase: boolean;
showKebab: boolean;
showScrim: boolean;
reserveSlotWidth: boolean;
isPinned?: boolean;
onTogglePin?: () => void;
onCopyPath?: () => void;
@@ -778,14 +813,9 @@ function StatusWorkspaceActionSlot({
archiveShortcutKeys?: ShortcutKey[][] | null;
}) {
return (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showBase}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebab} scrim={showScrim}>
{showKebab && onArchive ? (
@@ -812,12 +842,15 @@ function StatusWorkspaceActionSlot({
function getStatusWorkspaceRowStyle({
selected,
isHovered,
inStatusGroup,
}: {
selected: boolean;
isHovered: boolean;
inStatusGroup: boolean;
}) {
return [
styles.workspaceRow,
inStatusGroup && sidebarWorkspaceRowStyles.rowIndented,
selected && styles.sidebarRowSelected,
isHovered && styles.workspaceRowHovered,
];
@@ -24,6 +24,7 @@ import {
import { Shortcut } from "@/components/ui/shortcut";
import { OpenInFileManagerMenuItem } from "@/workspace/open-in-file-manager/menu-item";
import { resolveSidebarWorkspaceAccessibilityLabel } from "@/components/sidebar/sidebar-workspace-title";
import type { WorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -248,7 +249,7 @@ export function SidebarWorkspaceContextMenu({
workspace,
leadingProjectName,
hostBadgeLabel,
scriptIconKind,
scriptSummary,
workspaceKey,
onCopyPath,
onCopyBranchName,
@@ -272,7 +273,7 @@ export function SidebarWorkspaceContextMenu({
workspace: SidebarWorkspaceEntry;
leadingProjectName?: string | null;
hostBadgeLabel?: string | null;
scriptIconKind?: "service" | "command" | null;
scriptSummary?: WorkspaceScriptSummary | null;
}
>) {
const {
@@ -291,7 +292,7 @@ export function SidebarWorkspaceContextMenu({
leadingProjectName,
hostBadgeLabel,
pullRequestLabel,
scriptLabel: scriptIconKind ? t("workspace.status.scriptsAvailable") : null,
scriptLabel: scriptSummary ? t("workspace.status.scriptsAvailable") : null,
});
return (
@@ -1,38 +1,27 @@
import { memo, useCallback, useId, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import {
Pressable,
Text,
View,
type GestureResponderEvent,
type PressableStateCallbackType,
type ViewStyle,
} from "react-native";
import { memo, useId, useMemo, useCallback, useState, type ReactNode } from "react";
import { Text, View, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import {
CircleAlert,
Folder,
FolderGit2,
GitPullRequest,
Globe,
Monitor,
SquareTerminal,
} from "lucide-react-native";
import { HostBadge } from "@/components/sidebar/host-badge";
import { CircleAlert, Folder, FolderGit2, Monitor } from "lucide-react-native";
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
import type { SurfaceBackdrop } from "@/styles/surface-backdrop";
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
import {
WorkspaceMetaRow,
type WorkspaceScriptSummary,
} from "@/components/sidebar/workspace-meta-row";
import { WorkspaceHoverCard } from "@/components/workspace-hover-card";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import {
hasSidebarWorkspaceTrailing,
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings";
import type { Theme } from "@/styles/theme";
import type { PrHint } from "@/git/use-pr-status-query";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor, isEmphasizedStatusDotBucket } from "@/utils/status-dot-color";
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { openExternalUrl } from "@/utils/open-external-url";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
// The scrim spans more than the kebab so the fade starts left of the diff stat. Solid from
@@ -40,6 +29,17 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba
const SCRIM_WIDTH = 48;
const SCRIM_SOLID_OFFSET = "55%";
/**
* How far a workspace row sits inside the status header above it. Only status grouping
* indents: a project row carries an icon and its workspaces line up under it already, but a
* status header is just a label, so its rows need the offset to read as belonging to it.
*
* It is row padding rather than a margin on the list, because the row's hover and selected
* backgrounds have to keep spanning the group's full width. Indenting the container instead
* pulls the highlight in with the content and the row stops lining up with its header.
*/
const WORKSPACE_ROW_INDENT = 12;
const DEFAULT_STATUS_DOT_SIZE = 7;
const EMPHASIZED_STATUS_DOT_SIZE = 9;
const DEFAULT_STATUS_DOT_OFFSET = 0;
@@ -47,18 +47,11 @@ const EMPHASIZED_STATUS_DOT_OFFSET = -1;
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const amberColorMapping = (theme: Theme) => ({ color: theme.colors.palette.amber[500] });
const blueColorMapping = (theme: Theme) => ({ color: theme.colors.palette.blue[500] });
const greenColorMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
const redColorMapping = (theme: Theme) => ({ color: theme.colors.palette.red[500] });
const purpleColorMapping = (theme: Theme) => ({ color: theme.colors.palette.purple[500] });
const ThemedGitPullRequest = withUnistyles(GitPullRequest);
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
/**
* react-native-svg's extractGradient reads stopColor off the child elements structurally,
@@ -87,18 +80,6 @@ const ThemedTrailingActionScrimSvg = withUnistyles(TrailingActionScrimSvg);
const scrimColorMapping = (theme: Theme) => ({ color: theme.colors.surfaceSidebarHover });
function pullRequestIconStyle({
hovered,
pressed,
}: PressableStateCallbackType & { hovered?: boolean }) {
return [
pullRequestIconStyles.pressable,
(Boolean(hovered) || pressed) && pullRequestIconStyles.pressableActive,
];
}
type SidebarWorkspaceScriptIconKind = "service" | "command";
export function SidebarWorkspaceRowFrame({
workspace,
isDragging = false,
@@ -150,7 +131,8 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
hostBadge,
leadingProjectName = null,
leadingProjectIconDataUri = null,
scriptIconKind = null,
scriptSummary = null,
backdrop,
isHovered,
isLoading,
isCreating = false,
@@ -164,7 +146,9 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
/** Hoisted rows use their project icon as the leading visual because no project row contains them. */
leadingProjectName?: string | null;
leadingProjectIconDataUri?: string | null;
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
scriptSummary?: WorkspaceScriptSummary | null;
/** The row's current background, so the project status badge can knock out of it. */
backdrop: SurfaceBackdrop;
isHovered: boolean;
isLoading: boolean;
isCreating?: boolean;
@@ -178,17 +162,13 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
settings: { workspaceTitleSource },
} = useAppSettings();
const workspaceLabel = resolveSidebarWorkspacePrimaryLabel({ workspace, workspaceTitleSource });
const hasTitleAccessory = Boolean(scriptIconKind || workspace.prHint || hostBadge);
const workspaceBranchTextStyle = useMemo(
() => [
styles.workspaceBranchText,
hasTitleAccessory
? styles.workspaceBranchTextWithAccessory
: styles.workspaceBranchTextFlexible,
isHovered && styles.workspaceBranchTextHovered,
isCreating && styles.workspaceBranchTextCreating,
],
[hasTitleAccessory, isHovered, isCreating],
[isHovered, isCreating],
);
return (
@@ -200,6 +180,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
displayName={leadingProjectName}
projectViewKey={workspace.projectViewKey}
statusBucket={workspace.statusBucket}
backdrop={backdrop}
loading={isLoading}
testID={`sidebar-row-project-icon-${workspace.workspaceKey}`}
/>
@@ -213,23 +194,16 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
)}
<View style={styles.workspaceContentColumn}>
<View style={styles.workspaceTitleRow}>
<View style={styles.workspaceTitleLeft}>
{workspace.prHint ? <PullRequestIcon hint={workspace.prHint} /> : null}
{hostBadge ? (
<HostBadge
serverId={hostBadge.serverId}
label={hostBadge.label}
color={hostBadge.color}
showLabel={hostBadge.showLabel}
/>
) : null}
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspaceLabel}
</Text>
{scriptIconKind ? <WorkspaceScriptIcon kind={scriptIconKind} /> : null}
</View>
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
{workspaceLabel}
</Text>
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
</View>
<WorkspaceMetaRow
hostBadge={hostBadge ?? null}
prHint={workspace.prHint}
scriptSummary={scriptSummary}
/>
</View>
</View>
{showShortcutBadge && shortcutNumber !== null ? (
@@ -241,22 +215,6 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
);
});
function WorkspaceScriptIcon({ kind }: { kind: SidebarWorkspaceScriptIconKind }) {
return (
<View
style={styles.workspaceTitleAccessory}
accessibilityLabel="Scripts available"
testID={kind === "service" ? "workspace-globe-icon" : "workspace-terminal-icon"}
>
{kind === "service" ? (
<ThemedGlobe size={12} uniProps={blueColorMapping} />
) : (
<ThemedSquareTerminal size={12} uniProps={blueColorMapping} />
)}
</View>
);
}
function WorkspaceStatusIndicator({
bucket,
workspaceKind,
@@ -368,47 +326,6 @@ function StatusDotOverlay({
return <View style={overlayStyle} />;
}
function PullRequestIcon({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
void openExternalUrl(hint.url);
},
[hint.url],
);
const presentation = getForgePresentation(normalizeForge(hint.forge));
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
style={pullRequestIconStyle}
>
<ThemedGitPullRequest size={12} uniProps={getPrIconUniMapping(hint.state)} />
</Pressable>
);
}
function getPrIconUniMapping(state: PrHint["state"]) {
switch (state) {
case "merged":
return purpleColorMapping;
case "open":
return greenColorMapping;
case "closed":
return redColorMapping;
}
}
function getStatusDotColorStyle(bucket: SidebarStateBucket) {
switch (bucket) {
case "needs_input":
@@ -424,19 +341,12 @@ function getStatusDotColorStyle(bucket: SidebarStateBucket) {
}
}
const pullRequestIconStyles = StyleSheet.create(() => ({
pressable: {
height: 20,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
pressableActive: {
opacity: 0.82,
},
}));
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
// Layered over the row's own padding rather than replacing it, so the indent stays one
// number here instead of being baked into a row style.
rowIndented: {
paddingLeft: theme.spacing[2] + WORKSPACE_ROW_INDENT,
},
rowRight: {
flexDirection: "row",
alignItems: "flex-start",
@@ -462,7 +372,16 @@ export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
lineHeight: 14,
},
hidden: { opacity: 0 },
// Stays position:relative at zero width so the absolutely-positioned kebab keeps
// anchoring to the same right edge whether or not the slot holds anything.
trailingActionSlot: {
position: "relative",
minHeight: 20,
flexShrink: 0,
alignItems: "flex-end",
justifyContent: "flex-start",
},
trailingActionSlotReserved: {
position: "relative",
minWidth: 18,
minHeight: 20,
@@ -498,30 +417,68 @@ export function SidebarWorkspaceShortcutBadge({ number }: { number: number }) {
* into each of them and immediately drifted — one call site kept hiding the diff after the
* others stopped.
*
* The diff survives the kebab on hover and fades under the scrim instead of blinking out.
* Touch has no hover, so its permanent kebab still hides the diff outright rather than
* scrimming an unhovered row whose background doesn't match the gradient.
* The trailing content survives the kebab on hover and fades under the scrim instead of
* blinking out. Touch has no hover, so its permanent kebab still hides the content outright
* rather than scrimming an unhovered row whose background doesn't match the gradient.
*/
export function resolveTrailingActionVisibility({
hasDiffStat,
workspace,
trailing,
hasArchiveAction,
isHovered,
isTouchPlatform,
showShortcut,
}: {
hasDiffStat: boolean;
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
hasArchiveAction: boolean;
isHovered: boolean;
isTouchPlatform: boolean;
showShortcut: boolean;
}): { showDiffStat: boolean; showKebab: boolean; showScrim: boolean } {
}): {
showTrailing: boolean;
showKebab: boolean;
showScrim: boolean;
renderSlot: boolean;
reserveSlotWidth: boolean;
} {
const hasTrailing = hasSidebarWorkspaceTrailing({ workspace, trailing });
const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut;
const showDiffStat = hasDiffStat && !showShortcut && (isHovered || !showKebab);
return { showDiffStat, showKebab, showScrim: showDiffStat && showKebab };
const showTrailing = hasTrailing && !showShortcut && (isHovered || !showKebab);
return {
showTrailing,
showKebab,
// The scrim paints the row's own hover background, so it can only be drawn on a hovered
// row — over an unhovered one the gradient fades to the wrong color. That is also why
// touch, which shows the kebab without ever hovering, never gets one.
showScrim: showKebab && isHovered,
renderSlot: hasArchiveAction || hasTrailing,
// The slot only holds width for something that permanently sits in it. Trailing content
// does; the kebab only does on touch, where there is no hover for it to appear on and so
// no scrim to let it overlay the title. Everywhere else the width goes back to the title
// and the kebab fades in over its tail.
reserveSlotWidth: hasTrailing || (hasArchiveAction && isTouchPlatform),
};
}
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
export function SidebarWorkspaceTrailingActionSlot({
reserveWidth,
children,
}: {
reserveWidth: boolean;
children: ReactNode;
}) {
return (
<View
style={
reserveWidth
? sidebarWorkspaceRowStyles.trailingActionSlotReserved
: sidebarWorkspaceRowStyles.trailingActionSlot
}
>
{children}
</View>
);
}
export function SidebarWorkspaceTrailingActionBase({
@@ -595,13 +552,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "space-between",
gap: theme.spacing[2],
},
workspaceTitleLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
shortcutBadgeOverlay: {
position: "absolute",
top: 1,
@@ -640,25 +590,16 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.foregroundExtraMuted,
opacity: 0.3,
},
// The title owns the first line outright now that the host, change request and CI moved
// to the meta row, so it takes the full width the trailing slot leaves behind.
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
fontWeight: "400",
lineHeight: 20,
opacity: 0.76,
minWidth: 0,
},
workspaceBranchTextFlexible: {
flex: 1,
},
workspaceBranchTextWithAccessory: {
flexShrink: 1,
},
workspaceTitleAccessory: {
height: 20,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
minWidth: 0,
},
workspaceBranchTextCreating: {
opacity: 0.92,
@@ -8,7 +8,6 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
import { DiffStat } from "@/components/diff-stat";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { useToast } from "@/contexts/toast-context";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
@@ -33,6 +32,13 @@ import {
SidebarWorkspaceTrailingActionOverlay,
SidebarWorkspaceTrailingActionSlot,
} from "@/components/sidebar/sidebar-workspace-row-content";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
import { selectWorkspaceScriptSummary } from "@/components/sidebar/workspace-meta-row";
import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
function noop() {}
@@ -257,6 +263,7 @@ function WorkspaceRowBody({
archiveShortcutKeys,
}: WorkspaceRowBodyProps) {
const isTouchPlatform = platformIsNative;
const trailing = useSidebarWorkspaceTrailing();
const draggable = Boolean(drag);
const interaction = useLongPressDragInteraction({
drag: drag ?? noop,
@@ -283,14 +290,7 @@ function WorkspaceRowBody({
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
const isDesktop = !isTouchPlatform;
const showScriptsIcon = isDesktop && workspace.hasRunningScripts;
const hasRunningService = workspace.scripts.some(
(s) => s.lifecycle === "running" && (s.type ?? "service") === "service",
);
let scriptIconKind: "service" | "command" | null = null;
if (showScriptsIcon) {
scriptIconKind = hasRunningService ? "service" : "command";
}
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
const workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
return (
<View
@@ -307,7 +307,7 @@ function WorkspaceRowBody({
onContextMenuOpenChange={onContextMenuOpenChange}
workspace={workspace}
hostBadgeLabel={hostBadge?.label}
scriptIconKind={scriptIconKind}
scriptSummary={scriptSummary}
workspaceKey={workspace.workspaceKey}
onCopyPath={onCopyPath}
onCopyBranchName={onCopyBranchName}
@@ -333,7 +333,8 @@ function WorkspaceRowBody({
<SidebarWorkspaceRowContent
workspace={workspace}
hostBadge={hostBadge}
scriptIconKind={scriptIconKind}
scriptSummary={scriptSummary}
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
isHovered={isHovered}
isLoading={isArchiving || isCreating}
isCreating={isCreating}
@@ -342,6 +343,7 @@ function WorkspaceRowBody({
>
<WorkspaceRowTrailingActions
workspace={workspace}
trailing={trailing}
isHovered={isHovered}
isTouchPlatform={isTouchPlatform}
isCreating={isCreating}
@@ -368,6 +370,7 @@ function WorkspaceRowBody({
function WorkspaceRowTrailingActions({
workspace,
trailing,
isHovered,
isTouchPlatform,
isCreating,
@@ -384,6 +387,7 @@ function WorkspaceRowTrailingActions({
onRename,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
isHovered: boolean;
isTouchPlatform: boolean;
isCreating: boolean;
@@ -402,32 +406,29 @@ function WorkspaceRowTrailingActions({
const { t } = useTranslation();
const showShortcut = showShortcutBadge && shortcutNumber !== null;
const {
showDiffStat,
showTrailing,
showKebab: showKebabInSlot,
showScrim,
renderSlot,
reserveSlotWidth,
} = resolveTrailingActionVisibility({
hasDiffStat: Boolean(workspace.diffStat),
workspace,
trailing,
hasArchiveAction: Boolean(onArchive),
isHovered,
isTouchPlatform,
showShortcut,
});
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
return (
<>
{isCreating ? (
<Text style={styles.workspaceCreatingText}>{t("sidebar.workspace.status.creating")}</Text>
) : null}
{shouldRenderActionSlot ? (
<SidebarWorkspaceTrailingActionSlot>
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
{workspace.diffStat ? (
<DiffStat
additions={workspace.diffStat.additions}
deletions={workspace.diffStat.deletions}
/>
) : null}
{renderSlot ? (
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
</SidebarWorkspaceTrailingActionBase>
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
{onArchive ? (
@@ -0,0 +1,108 @@
import Svg, { Circle, Path } from "react-native-svg";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { toFilledQuarters, type CheckSummary } from "./check-summary";
/**
* Geometry in a 24-unit box scaled to the icon size, so the stroke weight matches the
* lucide icons sitting next to it in the row. The outline radius leaves half the stroke
* inside the box on each side; the pie is inset far enough to read as a separate shape
* rather than a thick ring.
*/
const VIEW_BOX = 24;
const STROKE_WIDTH = 2;
const OUTLINE_RADIUS = (VIEW_BOX - STROKE_WIDTH) / 2;
const CENTER = VIEW_BOX / 2;
const PIE_RADIUS = 7;
const CHECK_PATH = "M8 12.5 L11 15.5 L16.5 9";
const CROSS_PATH = "M8.5 8.5 L15.5 15.5 M15.5 8.5 L8.5 15.5";
/**
* A change request's CI as one glyph: a check inside a circle when everything passed, a
* cross when anything failed, and a pie filling clockwise from twelve while the run is
* still going. The three share the circle so a row's CI always occupies the same shape and
* only its interior changes.
*/
function CheckIndicatorSvg({
summary,
size,
color,
}: {
summary: CheckSummary;
size: number;
color: string;
}) {
return (
<Svg width={size} height={size} viewBox={`0 0 ${VIEW_BOX} ${VIEW_BOX}`}>
<Circle
cx={CENTER}
cy={CENTER}
r={OUTLINE_RADIUS}
stroke={color}
strokeWidth={STROKE_WIDTH}
fill="none"
/>
{summary.state === "running" ? (
<Pie fraction={toFilledQuarters(summary)} color={color} />
) : (
<Path
d={summary.state === "passed" ? CHECK_PATH : CROSS_PATH}
stroke={color}
strokeWidth={STROKE_WIDTH}
strokeLinecap="round"
strokeLinejoin="round"
fill="none"
/>
)}
</Svg>
);
}
/**
* A filled wedge from the centre, starting at twelve o'clock and sweeping clockwise. A full
* circle is drawn as a circle rather than an arc: an arc whose start and end points
* coincide has nothing to sweep between and paints nothing at all.
*/
function Pie({ fraction, color }: { fraction: number; color: string }) {
if (fraction <= 0) return null;
if (fraction >= 1) {
return <Circle cx={CENTER} cy={CENTER} r={PIE_RADIUS} fill={color} />;
}
const angle = fraction * 2 * Math.PI;
const endX = CENTER + PIE_RADIUS * Math.sin(angle);
const endY = CENTER - PIE_RADIUS * Math.cos(angle);
const largeArc = fraction > 0.5 ? 1 : 0;
const d = [
`M ${CENTER} ${CENTER}`,
`L ${CENTER} ${CENTER - PIE_RADIUS}`,
`A ${PIE_RADIUS} ${PIE_RADIUS} 0 ${largeArc} 1 ${endX} ${endY}`,
"Z",
].join(" ");
return <Path d={d} fill={color} />;
}
const ThemedCheckIndicatorSvg = withUnistyles(CheckIndicatorSvg);
/**
* Passing CI is the uneventful case and asks nothing of the reader, so it gets the quiet
* tier — green enough to name the state, close enough to the subtitle's weight that a
* sidebar full of them doesn't bury the one red row. Failure and a run still deciding keep
* real colour, because those are the two that want something from you.
*/
const COLOR_MAPPINGS: Record<CheckSummary["state"], (theme: Theme) => { color: string }> = {
passed: (theme) => ({ color: theme.colors.statusMutedSuccess }),
failed: (theme) => ({ color: theme.colors.statusMutedDanger }),
running: (theme) => ({ color: theme.colors.statusMutedWarning }),
};
export function CheckIndicator({ summary, size }: { summary: CheckSummary; size: number }) {
return (
<ThemedCheckIndicatorSvg
summary={summary}
size={size}
uniProps={COLOR_MAPPINGS[summary.state]}
/>
);
}
@@ -0,0 +1,108 @@
import { describe, expect, it } from "vitest";
import type { PrHint } from "@/git/pr-hint";
import { selectCheckSummary, toFilledQuarters } from "./check-summary";
function hint(overrides: Partial<PrHint>): PrHint {
return {
url: "https://github.com/o/r/pull/1",
number: 1,
state: "open",
forge: "github",
...overrides,
};
}
function checks(...statuses: string[]): PrHint["checks"] {
return statuses.map((status, index) => ({ name: `check-${index}`, status, url: null }));
}
describe("selectCheckSummary", () => {
it("returns nothing without a pull request", () => {
expect(selectCheckSummary(null)).toBeNull();
});
it("returns nothing when the pull request reports no checks", () => {
expect(selectCheckSummary(hint({ checksStatus: "none" }))).toBeNull();
expect(selectCheckSummary(hint({ checks: [] }))).toBeNull();
});
it("passes once every check is terminal and none failed", () => {
expect(selectCheckSummary(hint({ checks: checks("success", "success", "skipped") }))).toEqual({
state: "passed",
completed: 3,
total: 3,
});
});
it("counts terminal checks while others are still pending", () => {
expect(
selectCheckSummary(hint({ checks: checks("success", "skipped", "pending", "pending") })),
).toEqual({ state: "running", completed: 2, total: 4 });
});
it("fails as soon as one check fails, even mid-run", () => {
expect(
selectCheckSummary(hint({ checks: checks("success", "failure", "pending", "pending") })),
).toEqual({ state: "failed", completed: 4, total: 4 });
});
it("treats cancelled checks as finished rather than failed", () => {
expect(selectCheckSummary(hint({ checks: checks("success", "cancelled") }))).toEqual({
state: "passed",
completed: 2,
total: 2,
});
});
it("treats an unrecognized status as still running", () => {
expect(selectCheckSummary(hint({ checks: checks("success", "queued") }))).toEqual({
state: "running",
completed: 1,
total: 2,
});
});
it("falls back to the summary status when individual checks are absent", () => {
expect(selectCheckSummary(hint({ checksStatus: "pending" }))).toEqual({
state: "running",
completed: 0,
total: 1,
});
expect(selectCheckSummary(hint({ checksStatus: "failure" }))).toEqual({
state: "failed",
completed: 1,
total: 1,
});
});
it("prefers individual checks over the summary status", () => {
expect(
selectCheckSummary(hint({ checks: checks("failure"), checksStatus: "success" })),
).toEqual({ state: "failed", completed: 1, total: 1 });
});
});
describe("toFilledQuarters", () => {
it("fills nothing before the first quarter completes", () => {
expect(toFilledQuarters({ state: "running", completed: 0, total: 8 })).toBe(0);
expect(toFilledQuarters({ state: "running", completed: 1, total: 8 })).toBe(0);
});
it("fills in quarter steps", () => {
expect(toFilledQuarters({ state: "running", completed: 2, total: 8 })).toBe(0.25);
expect(toFilledQuarters({ state: "running", completed: 5, total: 10 })).toBe(0.5);
expect(toFilledQuarters({ state: "running", completed: 6, total: 8 })).toBe(0.75);
});
it("rounds down so an unfinished run never reads as complete", () => {
expect(toFilledQuarters({ state: "running", completed: 9, total: 10 })).toBe(0.75);
});
it("closes the pie only when every check is in", () => {
expect(toFilledQuarters({ state: "passed", completed: 10, total: 10 })).toBe(1);
});
it("treats an empty run as empty rather than dividing by zero", () => {
expect(toFilledQuarters({ state: "running", completed: 0, total: 0 })).toBe(0);
});
});
@@ -0,0 +1,72 @@
import { mapCheckStatus } from "@/git/pull-request-panel/check-status";
import type { PrHint } from "@/git/pr-hint";
/**
* What the sidebar reports about a change request's CI, reduced to the three things a row
* can show. "running" carries a fraction so the indicator can fill as checks land.
*/
export type CheckSummaryState = "passed" | "failed" | "running";
export interface CheckSummary {
state: CheckSummaryState;
/** Checks that have reached a terminal status. Equals `total` when state is not "running". */
completed: number;
total: number;
}
/**
* Failure wins over progress. A run that is half done with one failure already needs the
* user to open the hover card, and a ring still filling says "wait" — so the moment any
* check fails the summary is "failed" and the fraction stops mattering.
*
* `checks` is the richer source and the only one that can produce a fraction. Forges that
* report a summary without the individual checks fall back to `checksStatus`, which
* degrades the ring to empty-or-full rather than dropping the indicator.
*/
export function selectCheckSummary(hint: PrHint | null): CheckSummary | null {
if (!hint) return null;
return summarizeChecks(hint.checks) ?? summarizeChecksStatus(hint.checksStatus);
}
/** The pie has four pieces, so a run reports progress in quarters. */
const PIE_PIECES = 4;
/**
* How much of the pie is filled, as a fraction, snapped down to a quarter. Rounding down
* rather than to nearest means the indicator never claims a check finished that hasn't —
* nine of ten reads as three quarters, and only the tenth closes the circle.
*/
export function toFilledQuarters(summary: CheckSummary): number {
if (summary.total === 0) return 0;
return Math.floor((summary.completed / summary.total) * PIE_PIECES) / PIE_PIECES;
}
function summarizeChecks(checks: PrHint["checks"]): CheckSummary | null {
if (!checks || checks.length === 0) return null;
let completed = 0;
let failed = false;
for (const check of checks) {
const status = mapCheckStatus(check.status);
if (status === "failure") failed = true;
if (status !== "pending") completed += 1;
}
const total = checks.length;
if (failed) return { state: "failed", completed: total, total };
if (completed === total) return { state: "passed", completed: total, total };
return { state: "running", completed, total };
}
function summarizeChecksStatus(checksStatus: PrHint["checksStatus"]): CheckSummary | null {
switch (checksStatus) {
case "success":
return { state: "passed", completed: 1, total: 1 };
case "failure":
return { state: "failed", completed: 1, total: 1 };
case "pending":
return { state: "running", completed: 0, total: 1 };
default:
return null;
}
}
@@ -0,0 +1,359 @@
import { Fragment, useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type GestureResponderEvent } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import {
ExternalLink,
GitMerge,
GitPullRequest,
GitPullRequestClosed,
Globe,
Server,
SquareTerminal,
} from "lucide-react-native";
import { HOST_COLORS, type HostBadgeModel, type HostColor } from "@/hosts/appearance";
import { identityColor } from "@/styles/identity-colors";
import type { PrHint } from "@/git/pr-hint";
import { getForgePresentation, normalizeForge } from "@/git/forge";
import { openExternalUrl } from "@/utils/open-external-url";
import { useSidebarRowItems } from "@/components/sidebar/display-preferences/model";
import type { Theme } from "@/styles/theme";
import { CheckIndicator } from "./check-indicator";
import type { CheckSummary, CheckSummaryState } from "./check-summary";
import { selectMetaRowItems, type MetaRowItem } from "./meta-items";
import type { WorkspaceScriptSummary } from "./script-summary";
export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./script-summary";
/**
* One size for every glyph on the line. The items are peers — host, change request, CI,
* running service — so a glyph that differs in size reads as a different rank.
*/
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);
const ThemedGitMerge = withUnistyles(GitMerge);
const ThemedGitPullRequestClosed = withUnistyles(GitPullRequestClosed);
const ThemedGlobe = withUnistyles(Globe);
const ThemedSquareTerminal = withUnistyles(SquareTerminal);
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mergedMapping = (theme: Theme) => ({ color: theme.colors.statusMutedMerged });
const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
// Theme-independent, so the mapping per host color is fixed for the life of the module. A
// fresh `uniProps` identity on every render makes withUnistyles re-subscribe each pass.
const HOST_ICON_MAPPINGS: Record<HostColor, (theme: Theme) => { color: string }> = (() => {
const byColor = {} as Record<HostColor, (theme: Theme) => { color: string }>;
for (const color of HOST_COLORS) {
byColor[color] = color === "none" ? mutedMapping : () => ({ color: identityColor(color) });
}
return byColor;
})();
/**
* The subtitle under a workspace title: which host it lives on, its change request, that
* change request's CI, and any running service. Everything the row knows about a workspace
* that isn't its name.
*
* Items are peers separated by a dot rather than ranked by chrome. The host used to be a
* tinted pill on the title line, which made it the loudest thing in a row whose subject is
* the title; flattening it lets the whole line read as one piece of secondary text and
* leaves color to mean status.
*/
export function WorkspaceMetaRow({
hostBadge,
prHint,
scriptSummary,
}: {
hostBadge: HostBadgeModel | null;
prHint: PrHint | null;
scriptSummary: WorkspaceScriptSummary | null;
}) {
const visible = useSidebarRowItems();
const items = selectMetaRowItems({
hasHostBadge: hostBadge !== null,
prHint,
scriptSummary,
visible,
});
if (items.length === 0) return null;
return (
<View style={styles.row}>
{items.map((item, index) => (
<Fragment key={item.kind}>
{index > 0 ? <Text style={styles.separator}>·</Text> : null}
<MetaItemNode item={item} hostBadge={hostBadge} />
</Fragment>
))}
</View>
);
}
function MetaItemNode({
item,
hostBadge,
}: {
item: MetaRowItem;
hostBadge: HostBadgeModel | null;
}): ReactNode {
if (item.kind === "host") {
return hostBadge ? <HostItem hostBadge={hostBadge} /> : null;
}
if (item.kind === "changeRequest") {
return <PullRequestItem hint={item.hint} />;
}
if (item.kind === "checks") {
return <ChecksItem summary={item.summary} />;
}
return <ScriptItem summary={item.summary} />;
}
function HostItem({ hostBadge }: { hostBadge: HostBadgeModel }) {
return (
<View
style={styles.hostItem}
testID={`sidebar-host-badge-${hostBadge.serverId}`}
accessibilityLabel={hostBadge.label}
>
<ThemedServer size={META_ICON_SIZE} uniProps={HOST_ICON_MAPPINGS[hostBadge.color]} />
{hostBadge.showLabel ? (
<Text style={styles.hostLabel} numberOfLines={1}>
{hostBadge.label}
</Text>
) : null}
</View>
);
}
/**
* The only thing on the line that navigates, so it is the only thing that answers the
* pointer: it brightens to full foreground and its icon becomes an external-link arrow,
* saying where the click goes before you spend it.
*
* `onHoverIn`/`onHoverOut` with local state is safe here despite the usual rule in
* docs/hover.md — the state never leaves this Pressable, and nothing pressable is nested
* inside it, so there is no second hover state machine to fight. Both icons are the same
* size, so the swap can't move the target out from under the cursor.
*/
function PullRequestItem({ hint }: { hint: PrHint }) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const presentation = getForgePresentation(normalizeForge(hint.forge));
const handlePress = useCallback(
(event: GestureResponderEvent) => {
event.stopPropagation();
void openExternalUrl(hint.url);
},
[hint.url],
);
const handlePressIn = useCallback((event: GestureResponderEvent) => event.stopPropagation(), []);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const Icon = isHovered ? ThemedExternalLink : PR_ICONS[hint.state];
return (
<Pressable
accessibilityRole="link"
accessibilityLabel={t("workspace.git.pr.accessibility.pullRequest", {
number: hint.number,
context: presentation.changeRequestContext,
})}
hitSlop={4}
onPressIn={handlePressIn}
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
style={pressableItemStyle}
>
<Icon
size={META_ICON_SIZE}
uniProps={isHovered ? foregroundMapping : PR_COLOR_MAPPINGS[hint.state]}
/>
<Text style={isHovered ? styles.prTextHovered : styles.prText} numberOfLines={1}>
{hint.number}
{/* An open change request is the unremarkable case and says nothing extra; a merged
or closed one is why the row still looks like it has work in it. */}
{hint.state === "open" ? "" : ` ${t(PR_STATE_LABEL_KEYS[hint.state])}`}
</Text>
</Pressable>
);
}
/**
* Every state says its own name. A tick on its own has no subject — the reader has to already
* know the glyph is about CI to read it at all, and sitting after a host and a change request it
* reads as something left over. One short word costs less than that ambiguity.
*
* The words are the outcome, not the noun: "passed" against "failed", both naming how a finished
* run ended, "running" for one still deciding. Repeating the subject on every healthy row —
* "checks passed" — is the noise worth avoiding; naming the outcome is not.
*/
function ChecksItem({ summary }: { summary: CheckSummary }) {
const { t } = useTranslation();
return (
<View
style={styles.item}
accessibilityLabel={t(CHECK_STATE_ACCESSIBLE_KEYS[summary.state])}
testID={`sidebar-workspace-checks-${summary.state}`}
>
<CheckIndicator summary={summary} size={META_ICON_SIZE} />
<Text style={checksTextStyle(summary.state)} numberOfLines={1}>
{t(CHECK_STATE_LABEL_KEYS[summary.state])}
</Text>
</View>
);
}
const CHECK_STATE_LABEL_KEYS = {
passed: "workspace.git.pr.checksSummary.passedLabel",
failed: "workspace.git.pr.checksSummary.failedLabel",
running: "workspace.git.pr.checksSummary.runningLabel",
} as const;
const CHECK_STATE_ACCESSIBLE_KEYS = {
passed: "workspace.git.pr.checksSummary.passedAccessible",
failed: "workspace.git.pr.checksSummary.failedAccessible",
running: "workspace.git.pr.checksSummary.runningAccessible",
} as const;
function ScriptItem({ summary }: { summary: WorkspaceScriptSummary }) {
const { t } = useTranslation();
return (
<View
style={styles.item}
accessibilityLabel={t("workspace.status.scriptsAvailable")}
testID={summary.kind === "service" ? "workspace-globe-icon" : "workspace-terminal-icon"}
>
{summary.kind === "service" ? (
<ThemedGlobe size={META_ICON_SIZE} uniProps={successMapping} />
) : (
<ThemedSquareTerminal size={META_ICON_SIZE} uniProps={successMapping} />
)}
{summary.port !== null ? <Text style={styles.scriptPort}>:{summary.port}</Text> : null}
</View>
);
}
const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
const PR_ICONS = {
open: ThemedGitPullRequest,
merged: ThemedGitMerge,
closed: ThemedGitPullRequestClosed,
} as const;
// Same three colours the hover card gives the same three states — a change request has one
// identity, and it shouldn't shift when you hover the row that names it.
const PR_COLOR_MAPPINGS = {
open: successMapping,
merged: mergedMapping,
closed: dangerMapping,
} as const;
const PR_STATE_LABEL_KEYS = {
merged: "workspace.git.pr.states.merged",
closed: "workspace.git.pr.states.closed",
} as const;
function pressableItemStyle({ pressed }: { pressed: boolean }) {
return [styles.item, pressed && styles.itemPressed];
}
const styles = StyleSheet.create((theme) => ({
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
minWidth: 0,
},
item: {
flexDirection: "row",
alignItems: "center",
gap: 3,
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.
hostItem: {
flexDirection: "row",
alignItems: "center",
gap: 3,
minWidth: 0,
flexShrink: 0,
maxWidth: MAX_HOST_LABEL_WIDTH,
},
itemPressed: {
opacity: 0.82,
},
separator: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
hostLabel: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 1,
minWidth: 0,
},
scriptPort: {
color: theme.colors.statusMutedSuccess,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
prText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
prTextHovered: {
color: theme.colors.foreground,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
// Matches the indicator — see COLOR_MAPPINGS in check-indicator.tsx.
checksTextPassed: {
color: theme.colors.statusMutedSuccess,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
checksTextFailed: {
color: theme.colors.statusMutedDanger,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
checksTextRunning: {
color: theme.colors.statusMutedWarning,
fontSize: theme.fontSize.xs,
lineHeight: 16,
flexShrink: 0,
},
}));
// Read inside render, never into a module-scope table: touching `styles.x` at module load
// materializes the Unistyles proxy before the persisted theme has resolved, and the style
// freezes on whatever theme happened to be active first. See docs/unistyles.md.
function checksTextStyle(state: CheckSummaryState) {
if (state === "failed") return styles.checksTextFailed;
if (state === "running") return styles.checksTextRunning;
return styles.checksTextPassed;
}
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { PrHint } from "@/git/pr-hint";
import { DEFAULT_SIDEBAR_ROW_ITEMS } from "@/components/sidebar/display-preferences/row-items";
import { selectMetaRowItems } from "./meta-items";
import type { WorkspaceScriptSummary } from "./script-summary";
const PR_HINT: PrHint = {
url: "https://github.com/acme/app/pull/7",
number: 7,
state: "open",
forge: "github",
checksStatus: "success",
};
const SCRIPT: WorkspaceScriptSummary = { kind: "service", port: 3000 };
function select(overrides: Partial<Parameters<typeof selectMetaRowItems>[0]> = {}) {
return selectMetaRowItems({
hasHostBadge: true,
prHint: PR_HINT,
scriptSummary: SCRIPT,
visible: DEFAULT_SIDEBAR_ROW_ITEMS,
...overrides,
});
}
const kinds = (items: ReturnType<typeof selectMetaRowItems>) => items.map((item) => item.kind);
describe("selectMetaRowItems", () => {
it("reads identity, then the change, then its state, then what is running", () => {
expect(kinds(select())).toEqual(["host", "changeRequest", "checks", "scripts"]);
});
it("omits what the workspace does not have", () => {
expect(kinds(select({ hasHostBadge: false, prHint: null, scriptSummary: null }))).toEqual([]);
});
it.each([
["changeRequest", ["host", "checks", "scripts"]],
["checks", ["host", "changeRequest", "scripts"]],
["scripts", ["host", "changeRequest", "checks"]],
] as const)("drops %s and only %s when it is switched off", (item, expected) => {
expect(kinds(select({ visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, [item]: false } }))).toEqual(
expected,
);
});
it("keeps checks when the change request is hidden", () => {
// Each toggle answers for itself. A ticked "checks" that drew nothing because a different
// switch was off would be lying about its own state.
const items = select({
visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false, checks: true },
});
expect(kinds(items)).toEqual(["host", "checks", "scripts"]);
});
it("draws nothing for checks when both are off", () => {
const items = select({
visible: { ...DEFAULT_SIDEBAR_ROW_ITEMS, changeRequest: false, checks: false },
});
expect(kinds(items)).toEqual(["host", "scripts"]);
});
it("carries the resolved check summary rather than the raw hint", () => {
const checks = select().find((item) => item.kind === "checks");
expect(checks).toEqual({
kind: "checks",
summary: { state: "passed", completed: 1, total: 1 },
});
});
it("keeps a change request whose forge reports no checks", () => {
const items = select({ prHint: { ...PR_HINT, checksStatus: undefined } });
expect(kinds(items)).toEqual(["host", "changeRequest", "scripts"]);
});
});
@@ -0,0 +1,59 @@
import type { PrHint } from "@/git/pr-hint";
import type { SidebarRowItems } from "@/components/sidebar/display-preferences/row-items";
import { selectCheckSummary, type CheckSummary } from "./check-summary";
import type { WorkspaceScriptSummary } from "./script-summary";
/**
* What ends up on the line under a workspace title, in the order it is read: where the
* workspace lives, what change it belongs to, whether that change is passing, and what it is
* running. Identity first, then the work, then the work's state.
*/
export type MetaRowItem =
| { kind: "host" }
| { kind: "changeRequest"; hint: PrHint }
| { kind: "checks"; summary: CheckSummary }
| { kind: "scripts"; summary: WorkspaceScriptSummary };
/**
* Which peers a row should draw, given what it knows and what the user left switched on.
*
* Kept out of the component because this — not the markup — is the part with rules in it: every
* toggle answers for itself, so a row can end up showing checks with no change request beside
* them, and CI resolves from the hint even when the hint itself is not drawn.
*
* The host is filtered upstream, where the badge map is built: a host that should show nothing
* has no badge to hand down, so by the time a row sees one it is meant to be drawn.
*/
export function selectMetaRowItems(input: {
hasHostBadge: boolean;
prHint: PrHint | null;
scriptSummary: WorkspaceScriptSummary | null;
visible: SidebarRowItems;
}): MetaRowItem[] {
const { hasHostBadge, prHint, scriptSummary, visible } = input;
const items: MetaRowItem[] = [];
if (hasHostBadge) {
items.push({ kind: "host" });
}
if (prHint && visible.changeRequest) {
items.push({ kind: "changeRequest", hint: prHint });
}
// Independent of the change request, even though checks are read off one. Tying them together
// meant the checks toggle could sit ticked while nothing was drawn, which is a switch that lies
// about its own state. Showing checks without the change request beside them is the stranger
// combination, but it is the one you asked for and it is what you get.
if (visible.checks) {
const summary = selectCheckSummary(prHint);
if (summary) {
items.push({ kind: "checks", summary });
}
}
if (scriptSummary && visible.scripts) {
items.push({ kind: "scripts", summary: scriptSummary });
}
return items;
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { selectWorkspaceScriptSummary } from "./script-summary";
type Script = SidebarWorkspaceEntry["scripts"][number];
function script(overrides: Partial<Script>): Script {
return {
scriptName: "dev",
type: "service",
hostname: "localhost",
port: null,
proxyUrl: null,
lifecycle: "running",
health: null,
exitCode: null,
terminalId: null,
...overrides,
} as Script;
}
describe("selectWorkspaceScriptSummary", () => {
it("returns nothing when no script is running", () => {
expect(selectWorkspaceScriptSummary([])).toBeNull();
expect(selectWorkspaceScriptSummary([script({ lifecycle: "stopped", port: 3000 })])).toBeNull();
});
it("reports a running service with its port", () => {
expect(selectWorkspaceScriptSummary([script({ port: 3000 })])).toEqual({
kind: "service",
port: 3000,
});
});
it("reports a service that has not bound a port yet", () => {
expect(selectWorkspaceScriptSummary([script({ port: null })])).toEqual({
kind: "service",
port: null,
});
});
it("falls back to a running command when no service runs", () => {
expect(selectWorkspaceScriptSummary([script({ type: "script", port: null })])).toEqual({
kind: "command",
port: null,
});
});
it("prefers a running service over a running command regardless of order", () => {
expect(
selectWorkspaceScriptSummary([
script({ scriptName: "test", type: "script" }),
script({ scriptName: "dev", type: "service", port: 8080 }),
]),
).toEqual({ kind: "service", port: 8080 });
});
it("ignores stopped services when a command is running", () => {
expect(
selectWorkspaceScriptSummary([
script({ scriptName: "dev", type: "service", port: 3000, lifecycle: "stopped" }),
script({ scriptName: "test", type: "script" }),
]),
).toEqual({ kind: "command", port: null });
});
});
@@ -0,0 +1,26 @@
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
/**
* The one running script a row reports. A workspace can run several; the row has space for
* one item, and a service (something reachable on a port) is the one worth surfacing, so a
* running service outranks a running command.
*/
export interface WorkspaceScriptSummary {
kind: "service" | "command";
/** Only a service binds a port, and only then when the daemon has observed it. */
port: number | null;
}
export function selectWorkspaceScriptSummary(
scripts: SidebarWorkspaceEntry["scripts"],
): WorkspaceScriptSummary | null {
let command: WorkspaceScriptSummary | null = null;
for (const script of scripts) {
if (script.lifecycle !== "running") continue;
if ((script.type ?? "service") === "service") {
return { kind: "service", port: script.port };
}
command ??= { kind: "command", port: null };
}
return command;
}
@@ -0,0 +1,80 @@
import { Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DiffStat } from "@/components/diff-stat";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { useAppSettings } from "@/hooks/use-settings";
import type { SidebarWorkspaceTrailing } from "@/hooks/use-settings";
import { useCompactTimeAgo } from "@/hooks/use-compact-time-ago";
export type { SidebarWorkspaceTrailing };
/**
* The slot to the right of a workspace title. Three renderers behind one preference, so
* every row renderer asks the same question and the kebab overlay geometry stays identical
* no matter which one is showing.
*
* "none" exists because the slot is the only thing competing with the title for width, and
* a user who never reads the diff would rather have the characters.
*/
export function useSidebarWorkspaceTrailing(): SidebarWorkspaceTrailing {
const {
settings: { sidebarWorkspaceTrailing },
} = useAppSettings();
return sidebarWorkspaceTrailing;
}
/** Whether the slot has anything to draw for this workspace under the current preference. */
export function hasSidebarWorkspaceTrailing({
workspace,
trailing,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
}): boolean {
if (trailing === "diff") return workspace.diffStat !== null;
if (trailing === "timestamp") return workspace.statusEnteredAt !== null;
return false;
}
export function SidebarWorkspaceTrailingContent({
workspace,
trailing,
}: {
workspace: SidebarWorkspaceEntry;
trailing: SidebarWorkspaceTrailing;
}) {
if (trailing === "diff" && workspace.diffStat) {
return (
<DiffStat additions={workspace.diffStat.additions} deletions={workspace.diffStat.deletions} />
);
}
if (trailing === "timestamp" && workspace.statusEnteredAt) {
return <WorkspaceTimestamp enteredAt={workspace.statusEnteredAt} />;
}
return null;
}
/**
* Its own component so the clock stops here. `useCompactTimeAgo` holds state, and state
* re-renders the component that owns it — keeping that component down to a single `<Text>` is
* what stops a minute tick from reaching the row, the list, or the diff stat next door.
*/
function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) {
const label = useCompactTimeAgo(enteredAt);
return (
<Text style={styles.timestamp} numberOfLines={1}>
{label}
</Text>
);
}
const styles = StyleSheet.create((theme) => ({
timestamp: {
height: 20,
lineHeight: 20,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
flexShrink: 0,
},
}));
+45 -791
View File
@@ -1,26 +1,8 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useCallback, type PropsWithChildren, type ReactElement, type Ref } from "react";
import {
type ComponentProps,
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type PropsWithChildren,
type ReactElement,
type ReactNode,
type Ref,
} from "react";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Modal,
Platform,
Pressable,
StatusBar,
Text,
View,
type GestureResponderEvent,
type PressableProps,
@@ -28,150 +10,40 @@ import {
type StyleProp,
type ViewStyle,
} from "react-native";
import { FadeIn, FadeOut } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Check, CheckCircle } from "lucide-react-native";
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { isNative, isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb, isNative } from "@/constants/platform";
MenuHint,
MenuItem,
MenuLabel,
MenuRoot,
MenuSeparator,
MenuSurface,
useMenuContext,
type MenuSurfaceProps,
type MenuTriggerState,
} from "@/components/ui/menu";
// Keep parity with dropdown-menu action statuses.
export type ActionStatus = "idle" | "pending" | "success";
/**
* A menu opened by a long press or a right click, anchored to the point of the gesture rather
* than to a trigger box.
*
* Everything below the trigger is the shared menu engine — see `@/components/ui/menu` and
* docs/menus.md. Only the way it opens is different from `dropdown-menu.tsx`.
*/
type Placement = "top" | "bottom" | "left" | "right";
type Alignment = "start" | "center" | "end";
type MobileMenuMode = "dropdown" | "sheet";
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";
interface Rect {
x: number;
y: number;
width: number;
height: number;
export function ContextMenuContent(props: MenuSurfaceProps): ReactElement | null {
return <MenuSurface {...props} />;
}
interface ContextMenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
triggerRef: React.RefObject<View | null>;
anchorRect: Rect | null;
setAnchorRect: (rect: Rect | null) => void;
}
const ContextMenuContext = createContext<ContextMenuContextValue | null>(null);
function useContextMenuContext(componentName: string): ContextMenuContextValue {
const ctx = useContext(ContextMenuContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <ContextMenu />`);
}
return ctx;
}
export function useContextMenu(): ContextMenuContextValue {
return useContextMenuContext("useContextMenu");
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? open : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
return [value, setValue];
}
function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
function computePosition({
triggerRect,
contentSize,
displayArea,
placement,
alignment,
offset,
}: {
triggerRect: Rect;
contentSize: { width: number; height: number };
displayArea: Rect;
placement: Placement;
alignment: Alignment;
offset: number;
}): { x: number; y: number; actualPlacement: Placement } {
const { width: contentWidth, height: contentHeight } = contentSize;
// Calculate available space
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
// Flip if needed
let actualPlacement = placement;
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
actualPlacement = "top";
} else if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
actualPlacement = "bottom";
}
let x: number;
let y: number;
// Position based on placement
if (actualPlacement === "bottom") {
y = triggerRect.y + triggerRect.height + offset;
} else if (actualPlacement === "top") {
y = triggerRect.y - contentHeight - offset;
} else if (actualPlacement === "left") {
x = triggerRect.x - contentWidth - offset;
y = triggerRect.y;
} else {
x = triggerRect.x + triggerRect.width + offset;
y = triggerRect.y;
}
// Alignment
if (actualPlacement === "top" || actualPlacement === "bottom") {
if (alignment === "start") {
x = triggerRect.x;
} else if (alignment === "end") {
x = triggerRect.x + triggerRect.width - contentWidth;
} else {
x = triggerRect.x + (triggerRect.width - contentWidth) / 2;
}
}
// Constrain to screen
const padding = 8;
x = Math.max(padding, Math.min(displayArea.width - contentWidth - padding, x!));
y = Math.max(
displayArea.y + padding,
Math.min(displayArea.y + displayArea.height - contentHeight - padding, y!),
);
return { x, y, actualPlacement };
export function useContextMenu() {
return useMenuContext("useContextMenu");
}
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
@@ -179,18 +51,18 @@ function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
}
function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | null {
if (typeof event !== "object" || event === null) {
return null;
}
if (typeof event !== "object" || event === null) return null;
const nativeEvent = Reflect.get(event, "nativeEvent");
const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
const pageX = Reflect.get(native, "pageX");
const pageY = Reflect.get(native, "pageY");
const source = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
const pageX = Reflect.get(source, "pageX");
const pageY = Reflect.get(source, "pageY");
if (typeof pageX === "number" && typeof pageY === "number") {
return { pageX, pageY };
}
const clientX = Reflect.get(native, "clientX");
const clientY = Reflect.get(native, "clientY");
const clientX = Reflect.get(source, "clientX");
const clientY = Reflect.get(source, "clientY");
if (typeof clientX === "number" && typeof clientY === "number") {
return { pageX: clientX, pageY: clientY };
}
@@ -198,59 +70,15 @@ function coerceEventPoint(event: unknown): { pageX: number; pageY: number } | nu
}
function assignRef<T>(ref: Ref<T> | undefined, value: T): void {
if (!ref) return;
if (typeof ref === "function") {
ref(value);
return;
}
if (ref && typeof ref === "object") {
Object.assign(ref, { current: value });
}
(ref as { current: T }).current = value;
}
export function ContextMenu({
open,
defaultOpen,
onOpenChange,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}>): ReactElement {
const triggerRef = useRef<View>(null);
const [isOpen, setIsOpen] = useControllableOpenState({
open,
defaultOpen,
onOpenChange,
});
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
useEffect(() => {
if (!isOpen) {
setAnchorRect(null);
}
}, [isOpen]);
const value = useMemo<ContextMenuContextValue>(
() => ({
open: isOpen,
setOpen: setIsOpen,
triggerRef,
anchorRect,
setAnchorRect,
}),
[anchorRect, isOpen, setAnchorRect, setIsOpen],
);
return <ContextMenuContext.Provider value={value}>{children}</ContextMenuContext.Provider>;
}
interface TriggerState {
pressed: boolean;
hovered: boolean;
open: boolean;
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: TriggerState) => StyleProp<ViewStyle>);
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => StyleProp<ViewStyle>);
export function ContextMenuTrigger({
children,
@@ -272,19 +100,16 @@ export function ContextMenuTrigger({
triggerRef?: Ref<View | null>;
}
>): ReactElement {
const ctx = useContextMenuContext("ContextMenuTrigger");
const ctx = useMenuContext("ContextMenuTrigger");
const shouldEnableOnThisPlatform = enabled && (isWeb ? enabledOnWeb : enabledOnMobile);
const openAtEvent = useCallback(
(event: unknown) => {
if (!shouldEnableOnThisPlatform || disabled) {
return;
}
if (!shouldEnableOnThisPlatform || disabled) return;
const point = coerceEventPoint(event);
if (!point) {
return;
}
if (!point) return;
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
ctx.setAnchorRect({
x: point.pageX,
@@ -320,9 +145,7 @@ export function ContextMenuTrigger({
const handleContextMenu = useCallback(
(event: unknown) => {
if (isNative) {
return;
}
if (isNative) return;
if (typeof event === "object" && event !== null) {
const preventDefault = Reflect.get(event, "preventDefault");
const stopPropagation = Reflect.get(event, "stopPropagation");
@@ -360,572 +183,3 @@ export function ContextMenuTrigger({
</Pressable>
);
}
export function ContextMenuContent({
children,
side = "bottom",
align = "start",
offset = 4,
width,
minWidth = 180,
maxWidth,
fullWidth = false,
horizontalPadding = 16,
mobileMode = "dropdown",
testID,
}: PropsWithChildren<{
side?: Placement;
align?: Alignment;
offset?: number;
width?: number;
minWidth?: number;
maxWidth?: number;
fullWidth?: boolean;
horizontalPadding?: number;
mobileMode?: MobileMenuMode;
testID?: string;
}>): ReactElement | null {
const { t } = useTranslation();
const context = useContextMenuContext("ContextMenuContent");
const { theme } = useUnistyles();
const isMobile = useIsCompactFormFactor();
const useMobileSheet = isMobile && mobileMode === "sheet";
const { open, setOpen, triggerRef, anchorRect } = context;
const sheetSnapPoints = useMemo(() => ["30%", "55%"], []);
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<{ width: number; height: number } | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const handleClose = useCallback(() => {
setOpen(false);
}, [setOpen]);
const {
sheetRef: bottomSheetRef,
handleSheetChange,
handleSheetDismiss,
} = useIsolatedBottomSheetVisibility({
visible: open,
isEnabled: useMobileSheet,
onClose: handleClose,
});
const renderSheetBackdrop = useCallback(
(props: ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop {...props} appearsOnIndex={0} disappearsOnIndex={-1} opacity={0.45} />
),
[],
);
const sheetBackgroundStyle = useMemo(
() => [
styles.sheetBackground,
{
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
],
[theme.colors.surface0, theme.colors.border],
);
const sheetHandleStyle = useMemo(
() => [styles.sheetHandle, { backgroundColor: theme.colors.surface2 }],
[theme.colors.surface2],
);
// Measure trigger when opening (fallback) and capture point anchors.
useEffect(() => {
if (useMobileSheet || !open) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return () => {};
}
if (anchorRect) {
setTriggerRect(anchorRect);
return () => {};
}
if (!triggerRef.current) {
setTriggerRect(null);
return () => {};
}
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(triggerRef.current).then((rect) => {
if (!cancelled) setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return undefined;
});
return () => {
cancelled = true;
};
}, [anchorRect, open, triggerRef, useMobileSheet]);
// Calculate position when we have both measurements
useEffect(() => {
if (useMobileSheet) return;
if (!triggerRect || !contentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
const displayArea = {
x: 0,
y: 0,
width: screenWidth,
height: screenHeight,
};
const result = computePosition({
triggerRect,
contentSize,
displayArea,
placement: side,
alignment: align,
offset,
});
const x = fullWidth ? horizontalPadding : result.x;
setPosition({ x, y: result.y });
}, [triggerRect, contentSize, side, align, offset, fullWidth, horizontalPadding, useMobileSheet]);
const handleContentLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width: w, height: h } = event.nativeEvent.layout;
setContentSize({ width: w, height: h });
},
[],
);
const frameStyle = useMemo(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
resolvedWidthStyle,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: position?.x ?? -9999,
},
];
}, [fullWidth, horizontalPadding, width, minWidth, maxWidth, position?.y, position?.x]);
if (useMobileSheet) {
return (
<ContextMenuContext.Provider value={context}>
<IsolatedBottomSheetModal
ref={bottomSheetRef}
index={0}
snapPoints={sheetSnapPoints}
enableDynamicSizing={false}
onChange={handleSheetChange}
onDismiss={handleSheetDismiss}
backdropComponent={renderSheetBackdrop}
enablePanDownToClose
backgroundStyle={sheetBackgroundStyle}
handleIndicatorStyle={sheetHandleStyle}
keyboardBehavior="extend"
keyboardBlurBehavior="restore"
>
<BottomSheetScrollView
contentContainerStyle={styles.sheetScrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
testID={testID ? `${testID}-content` : undefined}
>
{children}
</BottomSheetScrollView>
</IsolatedBottomSheetModal>
</ContextMenuContext.Provider>
);
}
if (!open) return null;
return (
<Modal
visible={open}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onRequestClose={handleClose}
>
<View style={styles.overlay}>
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={handleClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
<FloatingSurface
entering={FadeIn.duration(100)}
exiting={FadeOut.duration(100)}
collapsable={false}
testID={testID}
onLayout={handleContentLayout}
style={styles.content}
frameStyle={frameStyle}
>
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
contentContainerStyle={SCROLL_CONTENT_CONTAINER_STYLE}
>
{children}
</FloatingScrollView>
</FloatingSurface>
</View>
</Modal>
);
}
export function ContextMenuLabel({
children,
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
const containerStyle = useMemo(() => [styles.labelContainer, style], [style]);
return (
<View style={containerStyle} testID={testID}>
<Text style={styles.labelText}>{children}</Text>
</View>
);
}
export function ContextMenuSeparator({
style,
testID,
}: {
style?: ViewStyle;
testID?: string;
}): ReactElement {
const separatorStyle = useMemo(() => [styles.separator, style], [style]);
return <View style={separatorStyle} testID={testID} />;
}
export function ContextMenuHint({
children,
testID,
}: PropsWithChildren<{ testID?: string }>): ReactElement {
return (
<View style={styles.hintContainer} testID={testID}>
<Text style={styles.hintText}>{children}</Text>
</View>
);
}
function resolveLeadingContent(input: {
isPending: boolean;
isSuccess: boolean;
leading: ReactElement | null | undefined;
pendingColor: string;
successColor: string;
}): ReactElement | null {
if (input.isPending) {
return <LoadingSpinner size={16} color={input.pendingColor} />;
}
if (input.isSuccess) {
return <CheckCircle size={16} color={input.successColor} />;
}
return input.leading ?? null;
}
function resolveItemLabel(input: {
children: ReactNode;
isPending: boolean;
isSuccess: boolean;
pendingLabel: string | undefined;
successLabel: string | undefined;
}): ReactNode {
if (input.isPending && input.pendingLabel) return input.pendingLabel;
if (input.isSuccess && input.successLabel) return input.successLabel;
return input.children;
}
export function ContextMenuItem({
children,
description,
onSelect,
disabled,
destructive,
selected,
showSelectedCheck = false,
selectedVariant = "default",
leading,
trailing,
loading,
status,
pendingLabel,
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<{
description?: string;
onSelect?: () => void;
disabled?: boolean;
destructive?: boolean;
selected?: boolean;
showSelectedCheck?: boolean;
selectedVariant?: "default" | "accent";
leading?: ReactElement | null;
trailing?: ReactElement | null;
/** @deprecated Use `status` instead */
loading?: boolean;
status?: ActionStatus;
pendingLabel?: string;
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}>): ReactElement {
const { theme } = useUnistyles();
const { setOpen } = useContextMenuContext("ContextMenuItem");
const isPending = status === "pending" || Boolean(loading);
const isSuccess = status === "success";
const isDisabled = Boolean(disabled) || isPending || isSuccess;
const leadingContent = resolveLeadingContent({
isPending,
isSuccess,
leading,
pendingColor: theme.colors.foregroundMuted,
successColor: theme.colors.palette.green[500],
});
const label = resolveItemLabel({ children, isPending, isSuccess, pendingLabel, successLabel });
const trailingContent =
trailing ??
(!showSelectedCheck && selected ? (
<Check size={16} color={theme.colors.foregroundMuted} />
) : null);
const handleItemPress = useCallback(() => {
if (isDisabled) return;
if (closeOnSelect) {
setOpen(false);
}
onSelect?.();
}, [isDisabled, closeOnSelect, setOpen, onSelect]);
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
let selectedStyle: typeof styles.itemSelectedAccent | typeof styles.itemSelected | null;
if (!selected) selectedStyle = null;
else if (selectedVariant === "accent") selectedStyle = styles.itemSelectedAccent;
else selectedStyle = styles.itemSelected;
return [
styles.item,
selectedStyle,
selected && (hovered || pressed) && selectedVariant !== "accent"
? styles.itemSelectedInteractive
: null,
isDisabled ? styles.itemDisabled : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
];
},
[selected, selectedVariant, isDisabled],
);
const itemTextStyle = useMemo(
() => [
styles.itemText,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
selected && selectedVariant === "accent" ? styles.itemTextSelectedAccent : null,
],
[destructive, isSuccess, selected, selectedVariant],
);
const itemDescriptionStyle = useMemo(
() => [
styles.itemDescription,
selected && selectedVariant === "accent" ? styles.itemDescriptionSelectedAccent : null,
],
[selected, selectedVariant],
);
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={isDisabled}
onPress={handleItemPress}
style={itemPressableStyle}
>
{showSelectedCheck ? (
<View style={styles.checkSlot}>
{selected ? <Check size={16} color={theme.colors.foreground} /> : null}
</View>
) : null}
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
<View style={styles.itemContent}>
<Text numberOfLines={1} style={itemTextStyle}>
{label}
</Text>
{description && !isPending && !isSuccess ? (
<Text numberOfLines={2} style={itemDescriptionStyle}>
{description}
</Text>
) : null}
</View>
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
backdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
content: {
backgroundColor: theme.colors.surface0,
borderWidth: 1,
borderColor: theme.colors.border,
borderRadius: theme.borderRadius.lg,
...theme.shadow.md,
overflow: "hidden",
},
sheetBackground: {
backgroundColor: theme.colors.surface0,
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
borderWidth: 1,
borderColor: theme.colors.border,
},
sheetHandle: {
backgroundColor: theme.colors.surface2,
},
sheetScrollContent: {
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[4],
},
labelContainer: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
labelText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
separator: {
height: 1,
backgroundColor: theme.colors.border,
},
hintContainer: {
paddingHorizontal: theme.spacing[3],
paddingBottom: theme.spacing[2],
},
hintText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
item: {
flexDirection: "row",
alignItems: "center",
minHeight: 36,
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
},
itemHovered: {
backgroundColor: theme.colors.surface1,
},
itemPressed: {
backgroundColor: theme.colors.surface1,
},
itemDisabled: {
opacity: 0.5,
},
itemSelected: {
backgroundColor: theme.colors.surface1,
},
itemSelectedInteractive: {
backgroundColor: theme.colors.surface1,
},
itemSelectedAccent: {
backgroundColor: theme.colors.accent,
},
checkSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
leadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
trailingSlot: {
marginLeft: "auto",
alignItems: "center",
justifyContent: "center",
},
itemContent: {
flexShrink: 1,
},
itemText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foreground,
},
itemTextDestructive: {
color: theme.colors.destructive,
},
itemTextSuccess: {
color: theme.colors.palette.green[500],
},
itemTextSelectedAccent: {
color: theme.colors.accentForeground,
},
itemDescription: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
itemDescriptionSelectedAccent: {
color: theme.colors.accentForeground,
opacity: 0.85,
},
}));
const SCROLL_CONTENT_CONTAINER_STYLE = { flexGrow: 1 };
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,35 @@
/**
* The menu engine.
*
* One state model and one item vocabulary behind two presentations — an anchored popover on
* wide screens, a bottom sheet on compact ones — and two trigger shapes, which live in the
* `dropdown-menu` and `context-menu` wrappers.
*
* See docs/menus.md.
*/
export { MenuRoot, MenuTrigger, type MenuTriggerProps, type MenuTriggerState } from "./menu-root";
export {
MenuSurface,
useMenuSurface,
type MenuPageDefinition,
type MenuSurfaceProps,
} from "./menu-surface";
export { MenuSubTrigger } from "./menu-sub";
export {
MenuHint,
MenuItem,
MenuLabel,
MenuSeparator,
type ActionStatus,
type MenuItemProps,
} from "./menu-item";
export {
useMenuContext,
useMenuDepth,
type MenuCompactMode,
type MenuContextValue,
type MenuPresentation,
} from "./menu-context";
export { currentPageId, isSubPageOpen, MENU_ROOT_PATH, type MenuPath } from "./menu-navigation";
export type { Alignment, Placement, Rect, Size } from "./menu-anchor";
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { computePosition, getTransformOrigin, type Rect } from "./menu-anchor";
const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 };
const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 };
const CONTENT = { width: 200, height: 100 };
function position(overrides: Partial<Parameters<typeof computePosition>[0]> = {}) {
return computePosition({
triggerRect: TRIGGER,
contentSize: CONTENT,
displayArea: DISPLAY,
placement: "bottom",
alignment: "start",
offset: 4,
...overrides,
});
}
describe("computePosition", () => {
it("sits below the trigger with room to spare", () => {
expect(position()).toEqual({ x: 100, y: 124, actualPlacement: "bottom" });
});
it("aligns to the trigger's start, center, and end edges", () => {
expect(position({ alignment: "start" }).x).toBe(100);
expect(position({ alignment: "center" }).x).toBe(20);
// 100 + 40 - 200 = -60, pulled back to the edge padding.
expect(position({ alignment: "end" }).x).toBe(8);
});
it("flips above the trigger when it does not fit below and there is more room above", () => {
const result = position({
triggerRect: { ...TRIGGER, y: 700 },
contentSize: { width: 200, height: 200 },
});
expect(result.actualPlacement).toBe("top");
expect(result.y).toBe(496);
});
it("stays put when flipping would be just as cramped", () => {
// Taller than the whole display area: neither side fits, so flipping buys nothing.
const result = position({
displayArea: { x: 0, y: 0, width: 1000, height: 300 },
contentSize: { width: 200, height: 400 },
});
expect(result.actualPlacement).toBe("bottom");
});
it("places side placements flush with the trigger top and ignores alignment", () => {
const left = position({ placement: "left", alignment: "end" });
expect(left).toEqual({ x: 8, y: 100, actualPlacement: "left" });
const right = position({ placement: "right", alignment: "end" });
expect(right).toEqual({ x: 144, y: 100, actualPlacement: "right" });
});
it("keeps the surface inside a display area that does not start at the origin", () => {
// Regression: the horizontal clamp used to ignore displayArea.x while the vertical one
// honoured displayArea.y, so an offset display area pushed the surface off its left edge.
const result = position({
displayArea: { x: 100, y: 0, width: 900, height: 800 },
alignment: "end",
});
expect(result.x).toBe(108);
});
});
describe("getTransformOrigin", () => {
it.each([
["bottom", "start", "top left"],
["top", "end", "bottom right"],
["right", "center", "center center"],
] as const)("maps %s/%s to %s", (placement, alignment, expected) => {
expect(getTransformOrigin(placement, alignment)).toBe(expected);
});
});
@@ -0,0 +1,159 @@
import type { View } from "react-native";
/**
* Where a floating menu surface goes relative to the thing that opened it.
*
* This was two byte-identical copies, one in `dropdown-menu.tsx` and one in `context-menu.tsx`.
* Submenus need a third caller, so it lives here now and the menus import it.
*/
export type Placement = "top" | "bottom" | "left" | "right";
export type Alignment = "start" | "center" | "end";
export interface Rect {
x: number;
y: number;
width: number;
height: number;
}
export interface Size {
width: number;
height: number;
}
/** Gap kept between the surface and the edge of the display area. */
const EDGE_PADDING = 8;
export function measureElement(element: View): Promise<Rect> {
return new Promise((resolve) => {
element.measureInWindow((x, y, width, height) => {
resolve({ x, y, width, height });
});
});
}
/**
* A surface asked to open below the trigger flips above it when it doesn't fit below *and*
* there is more room above. Both conditions matter: flipping into a side that is equally
* cramped just moves the clipping, so a surface taller than the whole viewport stays put.
*
* Only the vertical placements flip. A left/right submenu that doesn't fit is handled by the
* caller choosing its side up front, because flipping it mid-open would move the surface out
* from under the pointer that is travelling toward it.
*/
function flipPlacement(input: {
placement: Placement;
triggerRect: Rect;
contentHeight: number;
displayArea: Rect;
}): Placement {
const { placement, triggerRect, contentHeight, displayArea } = input;
const spaceTop = triggerRect.y - displayArea.y;
const spaceBottom = displayArea.y + displayArea.height - (triggerRect.y + triggerRect.height);
if (placement === "bottom" && spaceBottom < contentHeight && spaceTop > spaceBottom) {
return "top";
}
if (placement === "top" && spaceTop < contentHeight && spaceBottom > spaceTop) {
return "bottom";
}
return placement;
}
/** Where the surface's top-left corner lands, before it is pulled back inside the display area. */
function anchorToPlacement(input: {
placement: Placement;
triggerRect: Rect;
contentSize: Size;
alignment: Alignment;
offset: number;
}): { x: number; y: number } {
const { placement, triggerRect, contentSize, alignment, offset } = input;
if (placement === "left") {
return { x: triggerRect.x - contentSize.width - offset, y: triggerRect.y };
}
if (placement === "right") {
return { x: triggerRect.x + triggerRect.width + offset, y: triggerRect.y };
}
const y =
placement === "bottom"
? triggerRect.y + triggerRect.height + offset
: triggerRect.y - contentSize.height - offset;
// Vertical placements align horizontally against the trigger; side placements sit flush
// with its top edge and ignore alignment entirely.
if (alignment === "start") {
return { x: triggerRect.x, y };
}
if (alignment === "end") {
return { x: triggerRect.x + triggerRect.width - contentSize.width, y };
}
return { x: triggerRect.x + (triggerRect.width - contentSize.width) / 2, y };
}
function clampToDisplayArea(input: {
x: number;
y: number;
contentSize: Size;
displayArea: Rect;
}): { x: number; y: number } {
const { x, y, contentSize, displayArea } = input;
return {
x: Math.max(
displayArea.x + EDGE_PADDING,
Math.min(displayArea.x + displayArea.width - contentSize.width - EDGE_PADDING, x),
),
y: Math.max(
displayArea.y + EDGE_PADDING,
Math.min(displayArea.y + displayArea.height - contentSize.height - EDGE_PADDING, y),
),
};
}
export function computePosition({
triggerRect,
contentSize,
displayArea,
placement,
alignment,
offset,
}: {
triggerRect: Rect;
contentSize: Size;
displayArea: Rect;
placement: Placement;
alignment: Alignment;
offset: number;
}): { x: number; y: number; actualPlacement: Placement } {
const actualPlacement = flipPlacement({
placement,
triggerRect,
contentHeight: contentSize.height,
displayArea,
});
const anchored = anchorToPlacement({
placement: actualPlacement,
triggerRect,
contentSize,
alignment,
offset,
});
return { ...clampToDisplayArea({ ...anchored, contentSize, displayArea }), actualPlacement };
}
export function getTransformOrigin(placement: Placement, alignment: Alignment): string {
let vertical: string;
if (placement === "bottom") vertical = "top";
else if (placement === "top") vertical = "bottom";
else vertical = "center";
let horizontal: string;
if (alignment === "start") horizontal = "left";
else if (alignment === "end") horizontal = "right";
else horizontal = "center";
return `${vertical} ${horizontal}`;
}
@@ -0,0 +1,203 @@
import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react";
import { Platform, type View } from "react-native";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useDismissKeyboardOnOpen } from "@/components/ui/keyboard-dismiss";
import {
closeSubPage,
goBack as goBackPath,
MENU_ROOT_PATH,
openSubPage,
type MenuPath,
} from "./menu-navigation";
import type { Rect } from "./menu-anchor";
/**
* How a menu draws itself once it is open.
*
* `popover` anchors a floating surface to the trigger and opens submenus as flyouts beside it.
* `sheet` presents from the bottom edge and opens submenus by replacing the page in place.
* The choice is made from form factor, not platform, so a tablet in a narrow split view sheets
* the same way a phone does.
*/
export type MenuPresentation = "popover" | "sheet";
/** What a menu does on a compact screen. Wide screens are always a popover. */
export type MenuCompactMode = "popover" | "sheet";
export interface MenuContextValue {
open: boolean;
setOpen: (open: boolean) => void;
/** Runs an item's action, closing the menu first when the item asks for it. */
selectItem: (onSelect: (() => void) | undefined, closeOnSelect: boolean) => void;
flushPendingSelect: () => void;
triggerRef: React.RefObject<View | null>;
/** Point anchor for menus opened from a gesture rather than a trigger box. */
anchorRect: Rect | null;
setAnchorRect: (rect: Rect | null) => void;
presentation: MenuPresentation;
path: MenuPath;
openSub: (sub: { id: string; depth: number }) => void;
closeSub: (depth: number) => void;
goBack: () => void;
}
const MenuContext = createContext<MenuContextValue | null>(null);
export const MenuContextProvider = MenuContext.Provider;
export function useMenuContext(componentName: string): MenuContextValue {
const ctx = useContext(MenuContext);
if (!ctx) {
throw new Error(`${componentName} must be used within a menu root`);
}
return ctx;
}
/**
* How many submenu pages sit above the component reading it. A trigger on the root page is
* depth 0. Each open submenu re-provides this one higher, which is what lets a submenu trigger
* know whether it should nest below the current page or replace a sibling branch.
*/
const MenuDepthContext = createContext(0);
export const MenuDepthProvider = MenuDepthContext.Provider;
export function useMenuDepth(): number {
return useContext(MenuDepthContext);
}
function useControllableOpenState({
open,
defaultOpen,
onOpenChange,
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}): [boolean, (next: boolean) => void] {
const [internalOpen, setInternalOpen] = useState(Boolean(defaultOpen));
const isControlled = typeof open === "boolean";
const value = isControlled ? open : internalOpen;
const setValue = useCallback(
(next: boolean) => {
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
},
[isControlled, onOpenChange],
);
return [value, setValue];
}
/**
* Everything one menu knows about itself. Both menu shapes build on this; they differ only in
* what opens them, which is why the trigger is the wrapper's job and nothing here.
*/
export function useMenuState({
open,
defaultOpen,
onOpenChange,
compactMode = "popover",
}: {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
compactMode?: MenuCompactMode;
}): MenuContextValue {
const triggerRef = useRef<View>(null);
const pendingSelectRef = useRef<(() => void) | null>(null);
const [anchorRect, setAnchorRect] = useState<Rect | null>(null);
const [path, setPath] = useState<MenuPath>(MENU_ROOT_PATH);
const isCompact = useIsCompactFormFactor();
const presentation: MenuPresentation = isCompact && compactMode === "sheet" ? "sheet" : "popover";
const [isOpen, setIsOpenState] = useControllableOpenState({ open, defaultOpen, onOpenChange });
useDismissKeyboardOnOpen(isOpen);
// A menu always reopens on its root page, against a freshly measured anchor. Keeping either
// would reopen the surface three levels deep, or at the coordinates of the last right click.
const setOpen = useCallback(
(next: boolean) => {
if (!next) {
setPath(MENU_ROOT_PATH);
setAnchorRect(null);
}
setIsOpenState(next);
},
[setIsOpenState],
);
const flushPendingSelect = useCallback(() => {
const pendingSelect = pendingSelectRef.current;
pendingSelectRef.current = null;
if (!pendingSelect) return;
if (Platform.OS === "ios") {
// Native presenters such as PHPicker can hang if launched while an RN
// Modal is still completing dismissal on UIKit's side.
setTimeout(pendingSelect, 250);
return;
}
pendingSelect();
}, []);
const selectItem = useCallback(
(onSelect: (() => void) | undefined, closeOnSelect: boolean) => {
if (!closeOnSelect) {
onSelect?.();
return;
}
if (Platform.OS === "ios") {
pendingSelectRef.current = onSelect ?? null;
setOpen(false);
return;
}
setOpen(false);
onSelect?.();
},
[setOpen],
);
const openSub = useCallback((sub: { id: string; depth: number }) => {
setPath((current) => openSubPage(current, sub));
}, []);
const closeSub = useCallback((depth: number) => {
setPath((current) => closeSubPage(current, depth));
}, []);
const goBack = useCallback(() => {
setPath((current) => goBackPath(current));
}, []);
return useMemo(
() => ({
open: isOpen,
setOpen,
selectItem,
flushPendingSelect,
triggerRef,
anchorRect,
setAnchorRect,
presentation,
path,
openSub,
closeSub,
goBack,
}),
[
isOpen,
setOpen,
selectItem,
flushPendingSelect,
anchorRect,
presentation,
path,
openSub,
closeSub,
goBack,
],
);
}
@@ -0,0 +1,357 @@
import {
useCallback,
useMemo,
type PropsWithChildren,
type ReactElement,
type ReactNode,
} from "react";
import {
Pressable,
Text,
View,
type PressableStateCallbackType,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { Check, CheckCircle } from "lucide-react-native";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { Theme } from "@/styles/theme";
import { useMenuContext } from "./menu-context";
const ThemedCheck = withUnistyles(Check);
const ThemedCheckCircle = withUnistyles(CheckCircle);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const foregroundMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const successMapping = (theme: Theme) => ({ color: theme.colors.palette.green[500] });
/**
* Height of the filled part of a row, which together with the uniform inset puts the row pitch
* back at the 36pt it was before the fill was inset at all — the space is redistributed, not
* added.
*
* It only holds if the label's line box is pinned: 18 line + 8 padding + 2 border is exactly
* this. Leave the text's `lineHeight` to the platform and the content outgrows `minHeight`,
* which then does nothing and the rows drift taller again.
*/
const MENU_ITEM_HEIGHT = 28;
const MENU_ITEM_LINE_HEIGHT = 18;
/** Action status for menu items with loading/success feedback. */
export type ActionStatus = "idle" | "pending" | "success";
export function MenuLabel({
children,
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
const labelContainerStyle = useMemo(() => [styles.labelContainer, style], [style]);
return (
<View style={labelContainerStyle} testID={testID}>
<Text style={styles.labelText}>{children}</Text>
</View>
);
}
export function MenuSeparator({
style,
testID,
}: {
style?: ViewStyle;
testID?: string;
}): ReactElement {
const separatorStyle = useMemo(() => [styles.separator, style], [style]);
return <View style={separatorStyle} testID={testID} />;
}
export function MenuHint({
children,
style,
testID,
}: PropsWithChildren<{ style?: ViewStyle | ViewStyle[]; testID?: string }>): ReactElement {
const hintContainerStyle = useMemo(() => [styles.hintContainer, style], [style]);
return (
<View style={hintContainerStyle} testID={testID}>
<Text style={styles.hintText}>{children}</Text>
</View>
);
}
function resolveLeadingContent(input: {
isPending: boolean | undefined;
isSuccess: boolean;
leading: ReactElement | null;
}): ReactElement | null {
const { isPending, isSuccess, leading } = input;
if (isPending) {
return <ThemedLoadingSpinner size={16} uniProps={mutedMapping} />;
}
if (isSuccess) {
return <ThemedCheckCircle size={16} uniProps={successMapping} />;
}
return leading;
}
function resolveItemLabel(input: {
children: ReactNode;
isPending: boolean | undefined;
isSuccess: boolean;
pendingLabel?: string;
successLabel?: string;
}): ReactNode {
const { children, isPending, isSuccess, pendingLabel, successLabel } = input;
if (isPending && pendingLabel) return pendingLabel;
if (isSuccess && successLabel) return successLabel;
return children;
}
export interface MenuItemProps {
description?: string;
onSelect?: () => void;
disabled?: boolean;
muted?: boolean;
destructive?: boolean;
/**
* This row's value is the chosen one. Draws a check and nothing else — a checked row that is
* also filled in reads as two different claims about the same state.
*/
selected?: boolean;
/** Reserves a leading check column so a group of items stays aligned whether ticked or not. */
showSelectedCheck?: boolean;
/**
* This row's submenu is open. Distinct from `selected`: it is about where you are, not what
* you picked, so it takes the background that `selected` gives up.
*/
active?: boolean;
leading?: ReactElement | null;
trailing?: ReactElement | null;
/** @deprecated Use `status` instead */
loading?: boolean;
status?: ActionStatus;
/** Label to show while pending (e.g. "Pushing…") */
pendingLabel?: string;
/** Label to show on success (e.g. "Pushed") */
successLabel?: string;
closeOnSelect?: boolean;
testID?: string;
tooltip?: string;
}
export function MenuItem({
children,
description,
onSelect,
disabled,
muted = false,
destructive,
selected,
showSelectedCheck = false,
active = false,
leading,
trailing,
loading,
status,
pendingLabel,
successLabel,
closeOnSelect = true,
testID,
tooltip,
}: PropsWithChildren<MenuItemProps>): ReactElement {
const { selectItem } = useMenuContext("MenuItem");
const isPending = status === "pending" || loading;
const isSuccess = status === "success";
const isDisabled = disabled || isPending || isSuccess;
const leadingContent = resolveLeadingContent({
isPending,
isSuccess,
leading: leading ?? null,
});
const label = resolveItemLabel({ children, isPending, isSuccess, pendingLabel, successLabel });
const trailingContent =
trailing ??
(!showSelectedCheck && selected ? <ThemedCheck size={16} uniProps={mutedMapping} /> : null);
const handleItemPress = useCallback(() => {
if (isDisabled) return;
selectItem(onSelect, closeOnSelect);
}, [isDisabled, selectItem, onSelect, closeOnSelect]);
const itemPressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.item,
active ? styles.itemActive : null,
isDisabled ? styles.itemDisabled : null,
muted && !isDisabled ? styles.itemMuted : null,
hovered && !pressed && !isDisabled ? styles.itemHovered : null,
pressed && !isDisabled ? styles.itemPressed : null,
],
[active, isDisabled, muted],
);
const itemTextStyle = useMemo(
() => [
styles.itemText,
destructive && !isSuccess ? styles.itemTextDestructive : null,
isSuccess ? styles.itemTextSuccess : null,
muted && !isDisabled ? styles.itemTextMuted : null,
],
[destructive, isSuccess, muted, isDisabled],
);
const content = (
<Pressable
testID={testID}
accessibilityRole="button"
disabled={isDisabled}
onPress={handleItemPress}
style={itemPressableStyle}
>
{showSelectedCheck ? (
<View style={styles.checkSlot}>
{selected ? <ThemedCheck size={16} uniProps={foregroundMapping} /> : null}
</View>
) : null}
{leadingContent ? <View style={styles.leadingSlot}>{leadingContent}</View> : null}
<View style={styles.itemContent}>
<Text numberOfLines={1} style={itemTextStyle}>
{label}
</Text>
{description && !isPending && !isSuccess ? (
<Text numberOfLines={2} style={styles.itemDescription}>
{description}
</Text>
) : null}
</View>
{trailingContent ? <View style={styles.trailingSlot}>{trailingContent}</View> : null}
</Pressable>
);
if (!tooltip) {
return content;
}
return (
<Tooltip delayDuration={250} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent side="right" align="center" offset={10}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({
labelContainer: {
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[2],
paddingBottom: theme.spacing[1],
},
labelText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
// `border` sits between surface1 and surface2, which put it within a hair of the hover fill and
// made separators vanish against a hovered row. `borderAccent` is the colour the menu surface
// already outlines itself with, so the divider reads as part of the same frame.
//
// No margin of its own: the items above and below already hold themselves off it. Giving the
// rule its own spacing on top of theirs is how you end up tuning two numbers to control one gap.
separator: {
height: 1,
backgroundColor: theme.colors.borderAccent,
},
hintContainer: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
hintText: {
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foreground,
},
// The fill is inset by the same amount on every side and rounded, so a hovered row reads as a
// chip sitting inside the menu. That inset is the only thing that spaces this menu: rows sit
// apart because each holds itself in, and a separator gets clearance from its neighbours for
// free instead of carrying its own margin.
//
// The inset is taken out of the row, not added to it. Horizontally that means padding gives up
// what margin takes — margin 4 + padding 8 + border 1 lands the label at the same 13pt it
// always sat at. Vertically the fill itself is shorter by the same 8, so the pitch is unchanged
// and the menu did not grow; it just stopped filling every row edge to edge.
item: {
flexDirection: "row",
alignItems: "center",
minHeight: MENU_ITEM_HEIGHT,
gap: theme.spacing[2],
margin: theme.spacing[1],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderWidth: theme.borderWidth[1],
borderColor: "transparent",
borderRadius: theme.borderRadius.md,
},
itemHovered: {
backgroundColor: theme.colors.surface2,
},
itemPressed: {
backgroundColor: theme.colors.surface2,
},
// The row you are inside, not the value you chose. A chosen value is marked by its check.
itemActive: {
backgroundColor: theme.colors.surface2,
},
itemDisabled: {
opacity: 0.5,
},
itemMuted: {
opacity: 0.72,
},
itemText: {
fontSize: theme.fontSize.sm,
lineHeight: MENU_ITEM_LINE_HEIGHT,
color: theme.colors.foreground,
fontWeight: theme.fontWeight.normal,
},
itemTextMuted: {
color: theme.colors.foregroundMuted,
},
itemTextDestructive: {
color: theme.colors.destructive,
},
itemTextSuccess: {
color: theme.colors.palette.green[500],
},
itemDescription: {
marginTop: 2,
fontSize: theme.fontSize.xs,
color: theme.colors.foregroundMuted,
},
checkSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
leadingSlot: {
width: 16,
alignItems: "center",
justifyContent: "center",
},
trailingSlot: {
marginLeft: "auto",
alignItems: "center",
justifyContent: "center",
},
itemContent: {
flexShrink: 1,
minWidth: 0,
},
}));
@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
closeSubPage,
currentPageId,
goBack,
isSubPageOpen,
MENU_ROOT_PATH,
openSubPage,
} from "./menu-navigation";
describe("openSubPage", () => {
it("opens a submenu from the root", () => {
expect(openSubPage(MENU_ROOT_PATH, { id: "show", depth: 0 })).toEqual(["show"]);
});
it("nests a submenu opened from inside another", () => {
expect(openSubPage(["show"], { id: "colors", depth: 1 })).toEqual(["show", "colors"]);
});
it("swaps branches instead of nesting when the trigger is a sibling", () => {
// Sliding the pointer from one root trigger to another must not stack both flyouts.
expect(openSubPage(["grouping"], { id: "show", depth: 0 })).toEqual(["show"]);
});
it("drops deeper pages when an ancestor's sibling opens", () => {
expect(openSubPage(["show", "colors"], { id: "grouping", depth: 0 })).toEqual(["grouping"]);
});
it("returns the same path when the submenu is already open", () => {
const path = ["show"];
expect(openSubPage(path, { id: "show", depth: 0 })).toBe(path);
});
});
describe("closeSubPage", () => {
it("closes the page at a depth and everything below it", () => {
expect(closeSubPage(["show", "colors"], 0)).toEqual([]);
expect(closeSubPage(["show", "colors"], 1)).toEqual(["show"]);
});
it("returns the same path when nothing is open at that depth", () => {
const path = ["show"];
expect(closeSubPage(path, 1)).toBe(path);
expect(closeSubPage(MENU_ROOT_PATH, 0)).toBe(MENU_ROOT_PATH);
});
});
describe("goBack", () => {
it("pops one page", () => {
expect(goBack(["show", "colors"])).toEqual(["show"]);
expect(goBack(["show"])).toEqual([]);
});
it("is a no-op at the root", () => {
expect(goBack(MENU_ROOT_PATH)).toBe(MENU_ROOT_PATH);
});
});
describe("currentPageId", () => {
it("is null at the root and the last id otherwise", () => {
expect(currentPageId(MENU_ROOT_PATH)).toBeNull();
expect(currentPageId(["show", "colors"])).toBe("colors");
});
});
describe("isSubPageOpen", () => {
it("matches on id and depth together", () => {
const path = ["show", "colors"];
expect(isSubPageOpen(path, { id: "show", depth: 0 })).toBe(true);
expect(isSubPageOpen(path, { id: "colors", depth: 1 })).toBe(true);
// Right id, wrong level — a different trigger that happens to share a name.
expect(isSubPageOpen(path, { id: "colors", depth: 0 })).toBe(false);
expect(isSubPageOpen(path, { id: "grouping", depth: 0 })).toBe(false);
});
});
@@ -0,0 +1,48 @@
/**
* Which submenu pages are open, as a path from the root outward.
*
* The desktop flyout chain and the mobile push stack are the same structure, which is why
* there is one model and not two: a pointer surface renders *every* page in the path as its
* own anchored surface, and a compact sheet renders only the last one. Nothing else differs.
*
* Pure and React-free on purpose — the ordering rules below are the part worth testing, and
* they don't need a renderer to exercise.
*/
/** Sub-page ids from the root outward. Empty means the root page is showing. */
export type MenuPath = readonly string[];
export const MENU_ROOT_PATH: MenuPath = [];
/**
* Opening a submenu truncates the path to the depth of the trigger that opened it, so moving
* between two triggers on the same page swaps branches instead of nesting them. Without this,
* sliding the pointer across a menu would stack up every flyout it passed.
*
* `depth` is how many sub-pages sit above the trigger's own page: a trigger on the root page
* is depth 0, one inside a first-level submenu is depth 1.
*/
export function openSubPage(path: MenuPath, sub: { id: string; depth: number }): MenuPath {
if (isSubPageOpen(path, sub)) return path;
return [...path.slice(0, sub.depth), sub.id];
}
/** Closes the page opened at `depth` and everything it opened in turn. */
export function closeSubPage(path: MenuPath, depth: number): MenuPath {
if (path.length <= depth) return path;
return path.slice(0, depth);
}
export function goBack(path: MenuPath): MenuPath {
if (path.length === 0) return path;
return path.slice(0, -1);
}
/** The page a compact surface should render. Null means the root page. */
export function currentPageId(path: MenuPath): string | null {
return path.length === 0 ? null : (path[path.length - 1] ?? null);
}
export function isSubPageOpen(path: MenuPath, sub: { id: string; depth: number }): boolean {
return path[sub.depth] === sub.id;
}
@@ -0,0 +1,472 @@
import { useCallback, useEffect, useId, useMemo, useState, type ReactElement } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import {
Dimensions,
Modal,
Platform,
Pressable,
StatusBar,
View,
type StyleProp,
type ViewStyle,
} from "react-native";
import { Keyframe, runOnJS } from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
import { isWeb } from "@/constants/platform";
import {
getOverlayRoot,
OverlayLayerProvider,
useOverlayLayer,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
import {
computePosition,
getTransformOrigin,
measureElement,
type Alignment,
type Placement,
type Rect,
type Size,
} from "./menu-anchor";
const SCROLL_CONTENT_STYLE = { flexGrow: 1 } as const;
const CONTENT_ENTERING_DURATION_MS = 150;
const contentEntering = new Keyframe({
0: { opacity: 0, transform: [{ scale: 0.97 }] },
100: { opacity: 1, transform: [{ scale: 1 }] },
}).duration(CONTENT_ENTERING_DURATION_MS);
const contentExiting = new Keyframe({
0: { opacity: 1, transform: [{ scale: 1 }] },
100: { opacity: 0, transform: [{ scale: 0.97 }] },
}).duration(100);
function releaseFixedMenuHeight(surfaceNativeID: string): void {
if (!isWeb) return;
document.getElementById(surfaceNativeID)?.style.removeProperty("height");
}
/**
* Reanimated's web entering animation leaves an inline height snapshot on the measured surface.
* Once the menu is open, height must return to content-sized so rows can grow in place — and so
* a submenu page taller than the page it replaced is not clipped to the old height.
*
* `revision` is what makes that second case work: bump it whenever the rendered content changes
* identity (a page push, for instance) and the snapshot is released again.
*/
function useReleaseFixedMenuHeight({
contentSize,
enabled,
surfaceNativeID,
revision,
}: {
contentSize: Size | null;
enabled: boolean;
surfaceNativeID: string;
revision?: string | number;
}): void {
useEffect(() => {
if (!enabled) return undefined;
const release = () => {
releaseFixedMenuHeight(surfaceNativeID);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => releaseFixedMenuHeight(surfaceNativeID));
}
};
const timers: ReturnType<typeof setTimeout>[] = [
setTimeout(release, CONTENT_ENTERING_DURATION_MS),
];
if (contentSize) {
timers.push(setTimeout(release, 0));
}
return () => {
for (const timer of timers) clearTimeout(timer);
};
}, [contentSize, enabled, surfaceNativeID, revision]);
}
/**
* Resolves where an anchored surface sits: measures the anchor, measures the content, and runs
* the two through `computePosition`. Returns nulls until both measurements have landed, which is
* why the surface renders off-screen at -9999 rather than at 0,0 on its first frame.
*/
export function useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
}: {
open: boolean;
anchorRect: Rect | null;
anchorRef: React.RefObject<View | null>;
side: Placement;
align: Alignment;
offset: number;
scrollable: boolean;
maxHeight?: number;
}) {
const [triggerRect, setTriggerRect] = useState<Rect | null>(null);
const [contentSize, setContentSize] = useState<Size | null>(null);
const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
const [actualPlacement, setActualPlacement] = useState<Placement>(side);
const visibleContentSize = useMemo(() => {
if (!contentSize) return null;
if (!scrollable) return contentSize;
const { height: screenHeight } = Dimensions.get("window");
const viewportMaxHeight = Math.max(screenHeight - 16, 0);
const resolvedMaxHeight =
typeof maxHeight === "number" ? Math.min(maxHeight, viewportMaxHeight) : viewportMaxHeight;
return {
width: contentSize.width,
height: Math.min(contentSize.height, resolvedMaxHeight),
};
}, [contentSize, scrollable, maxHeight]);
useEffect(() => {
if (!open) {
setTriggerRect(null);
setContentSize(null);
setPosition(null);
return undefined;
}
if (anchorRect) {
setTriggerRect(anchorRect);
return undefined;
}
if (!anchorRef.current) {
setTriggerRect(null);
return undefined;
}
// Capture status bar height synchronously before async measurement, so it cannot change
// or read back null between the measure call and its resolution.
const statusBarHeight = Platform.OS === "android" ? (StatusBar.currentHeight ?? 0) : 0;
let cancelled = false;
void measureElement(anchorRef.current).then((rect) => {
if (cancelled) return undefined;
// On Android with statusBarTranslucent, measureInWindow returns coordinates relative to
// below the status bar while Modal content starts at the screen top.
setTriggerRect({ ...rect, y: rect.y + statusBarHeight });
return undefined;
});
return () => {
cancelled = true;
};
}, [anchorRect, anchorRef, open]);
useEffect(() => {
if (!triggerRect || !visibleContentSize) return;
const { width: screenWidth, height: screenHeight } = Dimensions.get("window");
// measureInWindow and Modal both use full-screen coordinates, so the display area starts at 0.
const result = computePosition({
triggerRect,
contentSize: visibleContentSize,
displayArea: { x: 0, y: 0, width: screenWidth, height: screenHeight },
placement: side,
alignment: align,
offset,
});
setPosition({ x: result.x, y: result.y });
setActualPlacement(result.actualPlacement);
}, [triggerRect, visibleContentSize, side, align, offset]);
const onContentLayout = useCallback(
(event: { nativeEvent: { layout: { width: number; height: number } } }) => {
const { width, height } = event.nativeEvent.layout;
setContentSize((current) => {
if (current && current.width === width && current.height === height) return current;
return { width, height };
});
},
[],
);
return { position, actualPlacement, contentSize, visibleContentSize, onContentLayout };
}
export interface AnchoredSurfaceProps {
open: boolean;
onClose: () => void;
onExited?: () => void;
anchorRect: Rect | null;
anchorRef: React.RefObject<View | null>;
side?: Placement;
align?: Alignment;
offset?: number;
width?: number;
minWidth?: number;
maxWidth?: number;
maxHeight?: number;
fullWidth?: boolean;
horizontalPadding?: number;
scrollable?: boolean;
/** Bump when the rendered content changes identity, so the height snapshot is released. */
revision?: string | number;
/** A submenu sits inside its parent's overlay and must not paint a second backdrop. */
backdrop?: boolean;
/**
* Hover tracking for the surface box, per docs/hover.md: plain View, pointer events, never
* a Pressable. Web-only by nature, which is all a flyout needs.
*/
onPointerEnter?: () => void;
onPointerLeave?: () => void;
testID?: string;
children: ReactElement;
}
/**
* One floating menu surface, positioned against an anchor. The root menu renders one of these
* against its trigger; every open submenu renders another against the row that opened it.
*/
export function AnchoredSurface({
open,
onClose,
onExited,
anchorRect,
anchorRef,
side = "bottom",
align = "start",
offset = 4,
width,
minWidth = 180,
maxWidth,
maxHeight,
fullWidth = false,
horizontalPadding = 16,
scrollable = false,
revision,
backdrop = true,
onPointerEnter,
onPointerLeave,
testID,
children,
}: AnchoredSurfaceProps): ReactElement | null {
const { t } = useTranslation();
const surfaceNativeID = useId();
const { position, actualPlacement, contentSize, visibleContentSize, onContentLayout } =
useAnchoredPosition({
open,
anchorRect,
anchorRef,
side,
align,
offset,
scrollable,
maxHeight,
});
useReleaseFixedMenuHeight({
contentSize,
enabled: open,
surfaceNativeID,
revision,
});
const frameStyle = useMemo<StyleProp<ViewStyle>>(() => {
const { width: screenWidth } = Dimensions.get("window");
const resolvedWidthStyle: ViewStyle = fullWidth
? { width: screenWidth - horizontalPadding * 2 }
: {
...(typeof width === "number" ? { width } : null),
...(typeof minWidth === "number" ? { minWidth } : null),
...(typeof maxWidth === "number" ? { maxWidth } : null),
};
return [
resolvedWidthStyle,
{
position: "absolute" as const,
top: position?.y ?? -9999,
left: fullWidth ? horizontalPadding : (position?.x ?? -9999),
transformOrigin: getTransformOrigin(actualPlacement, align),
},
];
}, [
fullWidth,
horizontalPadding,
width,
minWidth,
maxWidth,
position?.x,
position?.y,
actualPlacement,
align,
]);
const scrollViewportStyle = useMemo(
() => [visibleContentSize ? { height: visibleContentSize.height } : null],
[visibleContentSize],
);
if (!open) return null;
const measured = (
<View
collapsable={false}
onLayout={onContentLayout}
onPointerEnter={onPointerEnter}
onPointerLeave={onPointerLeave}
>
{children}
</View>
);
return (
<>
{backdrop ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("menu.backdrop")}
style={styles.backdrop}
onPress={onClose}
testID={testID ? `${testID}-backdrop` : undefined}
/>
) : null}
<FloatingSurface
collapsable={false}
tabIndex={-1}
nativeID={surfaceNativeID}
testID={testID}
style={styles.content}
frameStyle={frameStyle}
entering={contentEntering}
exiting={
isWeb || !onExited
? undefined
: contentExiting.withCallback((finished) => {
"worklet";
if (finished) {
runOnJS(onExited)();
}
})
}
>
{scrollable ? (
<FloatingScrollView
bounces={false}
showsVerticalScrollIndicator
style={scrollViewportStyle}
contentContainerStyle={SCROLL_CONTENT_STYLE}
>
{measured}
</FloatingScrollView>
) : (
measured
)}
</FloatingSurface>
</>
);
}
/**
* The full-screen layer every floating menu surface lives in: a web portal into the overlay
* root, or a transparent Modal on native. Submenus render inside their parent's layer rather
* than opening another one, so there is exactly one Modal per menu no matter how deep it goes.
*/
export function MenuOverlay({
visible,
onClose,
onDismiss,
children,
}: {
visible: boolean;
onClose: () => void;
onDismiss?: () => void;
children: ReactElement | null;
}): ReactElement | null {
const floatingLayer = useOverlayLayer("floating");
const handleWebOverlayKeyDown = useCallback(
(event: KeyboardEvent) => {
if (event.key !== "Escape") return false;
event.preventDefault();
event.stopPropagation();
onClose();
return true;
},
[onClose],
);
const setWebOverlayScope = useWebOverlayRegistration({
active: isWeb && visible,
layer: floatingLayer,
onKeyDown: handleWebOverlayKeyDown,
});
if (!visible) return null;
const overlay = (
<OverlayLayerProvider layer={floatingLayer}>
<View
ref={setWebOverlayScope}
collapsable={false}
style={[
styles.overlay,
isWeb ? styles.overlayWeb : null,
isWeb ? { zIndex: floatingLayer } : null,
]}
>
{children}
</View>
</OverlayLayerProvider>
);
if (isWeb && typeof document !== "undefined") {
return createPortal(overlay, getOverlayRoot());
}
return (
<Modal
visible={visible}
transparent
animationType="none"
statusBarTranslucent={Platform.OS === "android"}
onDismiss={onDismiss}
onRequestClose={onClose}
>
{overlay}
</Modal>
);
}
const styles = StyleSheet.create((theme) => ({
overlay: {
flex: 1,
},
overlayWeb: {
...StyleSheet.absoluteFillObject,
pointerEvents: "auto" as const,
},
backdrop: {
position: "absolute",
top: 0,
right: 0,
bottom: 0,
left: 0,
},
content: {
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius.lg,
overflow: "hidden",
...theme.shadow.md,
},
}));
@@ -0,0 +1,95 @@
import { useCallback, type PropsWithChildren, type ReactElement, type ReactNode } from "react";
import {
Pressable,
type PressableProps,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import {
MenuContextProvider,
useMenuContext,
useMenuState,
type MenuCompactMode,
} from "./menu-context";
/**
* Owns one menu's state. Wrap a trigger and a `MenuSurface` in it.
*
* The trigger is deliberately not part of this: what opens a menu is the only thing that
* differs between a dropdown (press) and a context menu (long press or right click), and it is
* the whole reason those two wrappers still exist.
*/
export function MenuRoot({
open,
defaultOpen,
onOpenChange,
compactMode,
children,
}: PropsWithChildren<{
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
compactMode?: MenuCompactMode;
}>): ReactElement {
const value = useMenuState({ open, defaultOpen, onOpenChange, compactMode });
return <MenuContextProvider value={value}>{children}</MenuContextProvider>;
}
export interface MenuTriggerState {
pressed: boolean;
hovered: boolean;
open: boolean;
}
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => StyleProp<ViewStyle>);
export interface MenuTriggerProps extends Omit<PressableProps, "style" | "children"> {
style?: TriggerStyleProp;
children: ReactNode | ((state: MenuTriggerState) => ReactNode);
}
export function MenuTrigger({
children,
disabled,
style,
...props
}: MenuTriggerProps): ReactElement {
const ctx = useMenuContext("MenuTrigger");
const handlePress = useCallback(() => {
if (disabled) return;
ctx.setOpen(!ctx.open);
}, [disabled, ctx]);
const pressableStyle = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
if (typeof style === "function") {
return style({ pressed, hovered, open: ctx.open });
}
return style;
},
[style, ctx.open],
);
const renderChildren = useCallback(
({ pressed, hovered = false }: PressableStateCallbackType & { hovered?: boolean }) => {
const state: MenuTriggerState = { pressed, hovered, open: ctx.open };
return typeof children === "function" ? children(state) : children;
},
[children, ctx.open],
);
return (
<Pressable
{...props}
ref={ctx.triggerRef}
collapsable={false}
disabled={disabled}
onPress={handlePress}
style={pressableStyle}
>
{renderChildren}
</Pressable>
);
}
@@ -0,0 +1,136 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
type PropsWithChildren,
type ReactElement,
} from "react";
import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { ChevronRight } from "lucide-react-native";
import type { Theme } from "@/styles/theme";
import { useMenuContext, useMenuDepth } from "./menu-context";
import { isSubPageOpen } from "./menu-navigation";
import { useMenuSurface } from "./menu-surface";
import { MenuItem } from "./menu-item";
const ThemedChevronRight = withUnistyles(ChevronRight);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/**
* A row that leads to a submenu page, showing the decision's current value on the way in
* ("Grouping Project ›"). Selecting it navigates rather than acting, so it never closes the
* menu.
*
* Hover lives on a plain `View` wrapping the item's `Pressable`, per docs/hover.md — a
* `Pressable` tracking its own hover would fight the one inside it. Hover only fires on web,
* which is exactly where flyouts exist; every other platform opens the page by pressing.
*/
export function MenuSubTrigger({
id,
value,
indicator = false,
disabled,
testID,
children,
}: PropsWithChildren<{
/** Matches the `id` of a `MenuPageDefinition` passed to the surface. */
id: string;
/** Current value of the decision this row leads to. */
value?: string;
/** Marks the branch as holding a non-default setting, e.g. an active filter. */
indicator?: boolean;
disabled?: boolean;
testID?: string;
}>): ReactElement {
const menu = useMenuContext("MenuSubTrigger");
const depth = useMenuDepth();
const { registerSubAnchor, hoverOpen, hoverClose, cancelHoverClose } =
useMenuSurface("MenuSubTrigger");
const anchorRef = useRef<View>(null);
useEffect(() => {
registerSubAnchor(id, anchorRef);
}, [id, registerSubAnchor]);
const isOpen = isSubPageOpen(menu.path, { id, depth });
const isPopover = menu.presentation === "popover";
const handleSelect = useCallback(() => {
menu.openSub({ id, depth });
}, [menu, id, depth]);
const handlePointerEnter = useCallback(() => {
cancelHoverClose();
hoverOpen({ id, depth });
}, [cancelHoverClose, hoverOpen, id, depth]);
const handlePointerLeave = useCallback(() => {
hoverClose(depth);
}, [hoverClose, depth]);
const trailing = useMemo(
() => <MenuSubTrailing value={value} indicator={indicator} />,
[value, indicator],
);
return (
<View
ref={anchorRef}
collapsable={false}
onPointerEnter={isPopover && !disabled ? handlePointerEnter : undefined}
onPointerLeave={isPopover && !disabled ? handlePointerLeave : undefined}
>
<MenuItem
active={isOpen}
closeOnSelect={false}
disabled={disabled}
onSelect={handleSelect}
trailing={trailing}
testID={testID}
>
{children}
</MenuItem>
</View>
);
}
function MenuSubTrailing({
value,
indicator,
}: {
value?: string;
indicator: boolean;
}): ReactElement {
return (
<View style={styles.trailing}>
{value ? (
<Text style={styles.valueText} numberOfLines={1}>
{value}
</Text>
) : null}
{indicator ? <View style={styles.indicator} testID="menu-sub-indicator" /> : null}
<ThemedChevronRight size={14} uniProps={mutedIconMapping} />
</View>
);
}
const styles = StyleSheet.create((theme) => ({
trailing: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
},
valueText: {
fontSize: theme.fontSize.sm,
color: theme.colors.foregroundMuted,
flexShrink: 1,
},
indicator: {
width: 6,
height: 6,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
},
}));
@@ -0,0 +1,422 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
type ComponentProps,
type ReactElement,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { BottomSheetBackdrop, BottomSheetScrollView } from "@gorhom/bottom-sheet";
import { ChevronLeft } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import type { Theme } from "@/styles/theme";
import { useMenuContext, MenuDepthProvider } from "./menu-context";
import { currentPageId, isSubPageOpen } from "./menu-navigation";
import { AnchoredSurface, MenuOverlay } from "./menu-overlay";
import type { Alignment, Placement } from "./menu-anchor";
const ThemedChevronLeft = withUnistyles(ChevronLeft);
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
// `backgroundStyle` and `handleIndicatorStyle` are style-shaped props the Babel plugin does not
// track, so the sheet is wrapped rather than reading the theme through a hook.
// See docs/unistyles.md.
const ThemedBottomSheetModal = withUnistyles(IsolatedBottomSheetModal, (theme) => ({
backgroundStyle: {
borderTopLeftRadius: theme.borderRadius.xl,
borderTopRightRadius: theme.borderRadius.xl,
borderWidth: 1,
backgroundColor: theme.colors.surface0,
borderColor: theme.colors.border,
},
handleIndicatorStyle: {
width: 36,
height: 4,
backgroundColor: theme.colors.surface2,
},
}));
/** How long the pointer must rest on a submenu row before its flyout opens. */
const HOVER_OPEN_DELAY_MS = 90;
/**
* How long a flyout survives after the pointer leaves. The pointer still crosses sibling rows on
* its way down into the flyout, so leaving the trigger cannot close it immediately.
*/
const HOVER_CLOSE_GRACE_MS = 260;
/**
* How far the flyout sits *over* its parent surface rather than beside it. The overlap is what
* makes the hand-off reliable: with a gap there is a strip of backdrop between the two where the
* pointer belongs to neither, and every pixel of it is a chance to dismiss the thing you are
* reaching for. Overlapping removes the strip instead of timing around it.
*/
const SUBMENU_OVERLAP = 5;
export interface MenuPageDefinition {
id: string;
title: string;
content: ReactNode;
}
interface MenuSurfaceContextValue {
registerSubAnchor: (id: string, ref: React.RefObject<View | null>) => void;
hoverOpen: (sub: { id: string; depth: number }) => void;
hoverClose: (depth: number) => void;
cancelHoverClose: () => void;
}
const MenuSurfaceContext = createContext<MenuSurfaceContextValue | null>(null);
export function useMenuSurface(componentName: string): MenuSurfaceContextValue {
const ctx = useContext(MenuSurfaceContext);
if (!ctx) {
throw new Error(`${componentName} must be used within <MenuSurface />`);
}
return ctx;
}
export interface MenuSurfaceProps {
/** Root page content. */
children: ReactNode;
/** Sub pages, reachable from a `MenuSubTrigger` on the root page. */
pages?: readonly MenuPageDefinition[];
/** Title shown on the sheet's root page. Sheets always have a header; popovers never do. */
sheetTitle?: string;
side?: Placement;
align?: Alignment;
offset?: number;
width?: number;
minWidth?: number;
maxWidth?: number;
maxHeight?: number;
fullWidth?: boolean;
horizontalPadding?: number;
scrollable?: boolean;
testID?: string;
}
/**
* The open menu. Reads its presentation from the menu root and renders either an anchored
* popover (plus one flyout per open submenu) or a bottom sheet showing one page at a time.
*/
export function MenuSurface(props: MenuSurfaceProps): ReactElement | null {
const { presentation } = useMenuContext("MenuSurface");
if (presentation === "sheet") {
return <MenuSheetSurface {...props} />;
}
return <MenuPopoverSurface {...props} />;
}
function useSubAnchors(): {
value: MenuSurfaceContextValue;
getAnchor: (id: string) => React.RefObject<View | null> | null;
} {
const anchors = useRef(new Map<string, React.RefObject<View | null>>());
const openTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const { openSub, closeSub } = useMenuContext("MenuSurface");
const clearTimers = useCallback(() => {
if (openTimer.current) clearTimeout(openTimer.current);
if (closeTimer.current) clearTimeout(closeTimer.current);
openTimer.current = null;
closeTimer.current = null;
}, []);
useEffect(() => clearTimers, [clearTimers]);
const registerSubAnchor = useCallback((id: string, ref: React.RefObject<View | null>) => {
anchors.current.set(id, ref);
}, []);
const hoverOpen = useCallback(
(sub: { id: string; depth: number }) => {
clearTimers();
openTimer.current = setTimeout(() => openSub(sub), HOVER_OPEN_DELAY_MS);
},
[clearTimers, openSub],
);
const hoverClose = useCallback(
(depth: number) => {
if (openTimer.current) clearTimeout(openTimer.current);
openTimer.current = null;
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = setTimeout(() => closeSub(depth), HOVER_CLOSE_GRACE_MS);
},
[closeSub],
);
const cancelHoverClose = useCallback(() => {
if (closeTimer.current) clearTimeout(closeTimer.current);
closeTimer.current = null;
}, []);
const value = useMemo(
() => ({ registerSubAnchor, hoverOpen, hoverClose, cancelHoverClose }),
[registerSubAnchor, hoverOpen, hoverClose, cancelHoverClose],
);
const getAnchor = useCallback((id: string) => anchors.current.get(id) ?? null, []);
return { value, getAnchor };
}
function MenuPopoverSurface({
children,
pages = [],
side = "bottom",
align = "start",
offset = 4,
width,
minWidth = 180,
maxWidth,
maxHeight,
fullWidth = false,
horizontalPadding = 16,
scrollable = false,
testID,
}: MenuSurfaceProps): ReactElement | null {
const menu = useMenuContext("MenuSurface");
const { value: surfaceValue, getAnchor } = useSubAnchors();
const handleClose = useCallback(() => menu.setOpen(false), [menu]);
// Every open page in the path gets its own flyout, so a submenu of a submenu simply stacks.
const openPages = useMemo(
() =>
menu.path
.map((id, depth) => {
const page = pages.find((candidate) => candidate.id === id);
return page ? { page, depth } : null;
})
.filter((entry): entry is { page: MenuPageDefinition; depth: number } => entry !== null),
[menu.path, pages],
);
return (
<MenuSurfaceContext.Provider value={surfaceValue}>
<MenuOverlay visible={menu.open} onClose={handleClose} onDismiss={menu.flushPendingSelect}>
<>
<AnchoredSurface
open={menu.open}
onClose={handleClose}
anchorRect={menu.anchorRect}
anchorRef={menu.triggerRef}
side={side}
align={align}
offset={offset}
width={width}
minWidth={minWidth}
maxWidth={maxWidth}
maxHeight={maxHeight}
fullWidth={fullWidth}
horizontalPadding={horizontalPadding}
scrollable={scrollable}
testID={testID}
>
<MenuDepthProvider value={0}>{children}</MenuDepthProvider>
</AnchoredSurface>
{openPages.map(({ page, depth }) => (
<MenuFlyout
key={page.id}
page={page}
depth={depth}
anchorRef={getAnchor(page.id)}
minWidth={minWidth}
maxHeight={maxHeight}
scrollable={scrollable}
testID={testID ? `${testID}-${page.id}` : undefined}
/>
))}
</>
</MenuOverlay>
</MenuSurfaceContext.Provider>
);
}
/**
* A submenu's own floating surface. It keeps itself alive while the pointer is inside it —
* without that, walking from the row into the flyout would cross the gap between them and
* trigger the close it was travelling to avoid.
*/
function MenuFlyout({
page,
depth,
anchorRef,
minWidth,
maxHeight,
scrollable,
testID,
}: {
page: MenuPageDefinition;
depth: number;
anchorRef: React.RefObject<View | null> | null;
minWidth?: number;
maxHeight?: number;
scrollable?: boolean;
testID?: string;
}): ReactElement | null {
const menu = useMenuContext("MenuFlyout");
const { hoverClose, cancelHoverClose } = useMenuSurface("MenuFlyout");
const handleClose = useCallback(() => menu.setOpen(false), [menu]);
const handleHoverOut = useCallback(() => hoverClose(depth), [hoverClose, depth]);
if (!anchorRef) return null;
return (
<AnchoredSurface
open
onClose={handleClose}
anchorRect={null}
anchorRef={anchorRef}
side="right"
align="start"
offset={-SUBMENU_OVERLAP}
minWidth={minWidth}
maxHeight={maxHeight}
scrollable={scrollable}
backdrop={false}
revision={page.id}
onPointerEnter={cancelHoverClose}
onPointerLeave={handleHoverOut}
testID={testID}
>
<MenuDepthProvider value={depth + 1}>{page.content}</MenuDepthProvider>
</AnchoredSurface>
);
}
function MenuSheetSurface({
children,
pages = [],
sheetTitle,
testID,
}: MenuSurfaceProps): ReactElement | null {
const menu = useMenuContext("MenuSurface");
const { value: surfaceValue } = useSubAnchors();
const handleClose = useCallback(() => menu.setOpen(false), [menu]);
const { sheetRef, handleSheetChange, handleSheetDismiss } = useIsolatedBottomSheetVisibility({
visible: menu.open,
isEnabled: true,
onClose: handleClose,
});
const renderBackdrop = useCallback(
(backdropProps: ComponentProps<typeof BottomSheetBackdrop>) => (
<BottomSheetBackdrop
{...backdropProps}
appearsOnIndex={0}
disappearsOnIndex={-1}
opacity={0.45}
/>
),
[],
);
const openPageId = currentPageId(menu.path);
const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null;
const depth = menu.path.length;
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"
>
<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>
);
}
function MenuSheetHeader({
title,
onBack,
}: {
title: string;
onBack: (() => void) | null;
}): ReactElement {
const { t } = useTranslation();
return (
<View style={styles.sheetHeader}>
{onBack ? (
<Pressable
accessibilityRole="button"
accessibilityLabel={t("common.back")}
hitSlop={8}
onPress={onBack}
style={styles.sheetBackButton}
testID="menu-sheet-back"
>
<ThemedChevronLeft size={18} uniProps={mutedIconMapping} />
</Pressable>
) : null}
<Text style={styles.sheetTitle} numberOfLines={1}>
{title}
</Text>
</View>
);
}
const styles = StyleSheet.create((theme) => ({
sheetScrollContent: {
paddingBottom: theme.spacing[6],
},
sheetHeader: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingTop: theme.spacing[1],
paddingBottom: theme.spacing[3],
},
sheetBackButton: {
width: 24,
height: 24,
alignItems: "center",
justifyContent: "center",
},
sheetTitle: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
flexShrink: 1,
},
}));
export { isSubPageOpen };
@@ -363,9 +363,9 @@ const ThemedCheck = withUnistyles(Check);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusSuccess });
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusWarning });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusDanger });
const successColorMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
const warningColorMapping = (theme: Theme) => ({ color: theme.colors.statusMutedWarning });
const dangerColorMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
function InfoRow({
icon: Icon,
@@ -660,16 +660,16 @@ const styles = StyleSheet.create((theme) => ({
checksStatusTextFailed: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusDanger,
color: theme.colors.statusMutedDanger,
},
checksStatusTextPending: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusWarning,
color: theme.colors.statusMutedWarning,
},
checksStatusTextPassed: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.statusSuccess,
color: theme.colors.statusMutedSuccess,
},
}));
@@ -29,6 +29,7 @@ import { requireWorkspaceDirectory } from "@/utils/workspace-directory";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { navigateToWorkspace } from "@/stores/navigation-active-workspace-store";
import type { MessagePayload } from "@/composer/types";
import { projectIconRadius } from "@/components/project-icon-view";
function toProjectIconDataUri(icon: { mimeType: string; data: string } | null): string | null {
if (!icon) {
@@ -460,15 +461,15 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
// A user's uploaded mark is sized, never clipped — see projectIconRadius.
projectIcon: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
},
projectIconFallback: {
width: theme.iconSize.md,
height: theme.iconSize.md,
borderRadius: theme.borderRadius.sm,
borderRadius: projectIconRadius(theme.iconSize.md),
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",