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
+1
View File
@@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
+24 -12
View File
@@ -2,20 +2,32 @@
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
Icons are inlined as SVG strings in:
```
packages/app/src/components/material-file-icons.ts
```
This file is auto-generated. Do not edit it by hand.
## How it works
- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
Two files:
- `packages/app/src/components/material-file-icons.ts` — the vendor table. `SVG_ICONS` maps icon
names to SVG strings copied verbatim from the theme, `EXTENSION_TO_ICON` maps extensions to icon
names, and `getRawFileIconSvg(fileName)` looks one up with a generic fallback. Keep it a faithful
copy so re-copying an icon stays a mechanical edit.
- `packages/app/src/components/file-icon-svg.ts` — `getFileIconSvg(fileName)`, the only thing
anyone should import. It desaturates the vendor colours and caches the result.
Render with `<MaterialFileIcon fileName size />` rather than reaching for `SvgXml` yourself.
## Why the colours are toned down
material-icon-theme picks its palette for VS Code's file tree, where the icon is the only colour on
the row. Ours share rows with status dots, diff stats, and check badges, and a column of them at
full chroma is the loudest thing in the panel.
Every hex in the SVG is pulled toward neutral at the same perceived lightness (`desaturateHexColor`
in `packages/app/src/utils/color.ts`, OKLab — scaling HSL saturation instead would darken the
yellows and barely touch the blues). Hue survives, which is the only part of an icon's colour that
carries meaning at 16pt.
`ICON_CHROMA` in `file-icon-svg.ts` is the single knob. Move it; do not add per-icon overrides, or
we are back to hand-picking 53 colours.
## Adding a new icon
+3
View File
@@ -258,3 +258,6 @@ Before you write a new one, ask:
bounded**. Verify before you commit.
Then copy the closest canonical file and trim.
Building a **menu** rather than a bare panel? Don't. Use the menu engine — it already solves
everything above, plus submenus, sheets, and hover intent. See [menus.md](menus.md).
+1 -1
View File
@@ -10,7 +10,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
- **Host badge** — The sidebar pill naming which host a workspace lives on. Only drawn once the visible sidebar spans more than one host. Its name, color, and display mode (**Name** / **Icon only** / **Hidden**) are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. UI: the "Appearance" group on a host's settings page. Code: `HostBadge` (`packages/app/src/components/sidebar/host-badge.tsx`), `selectHostBadges` (`packages/app/src/hosts/appearance.ts`). Don't confuse with **Status pill**, which reports state rather than identity.
- **Host badge** — The item on a sidebar workspace row's meta row naming which host that workspace lives on. Three things gate it, and they compose as a lattice rather than competing: the sidebar must span more than one host (automatic), **Show → Host** in the sidebar's display preferences must be on (global, all hosts at once), and then that host's own display mode decides _how_ it draws — **Name** / **Icon only** / **Hidden**. Name, color, and display mode are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. Never add a fourth way to hide it. UI: the "Appearance" group on a host's settings page, and the "Show" submenu in the sidebar's display preferences. Code: `selectHostBadges` (`packages/app/src/hosts/appearance.ts`) takes the first two as its `enabled` input; `WorkspaceMetaRow` (`packages/app/src/components/sidebar/workspace-meta-row/index.tsx`) draws it. Don't confuse with **Status pill**, which reports state rather than identity.
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
+122
View File
@@ -0,0 +1,122 @@
# Menus
One engine, in `packages/app/src/components/ui/menu/`. `dropdown-menu.tsx` and `context-menu.tsx`
are wrappers over it and differ only in what opens them — a press, or a long press / right click.
Import the `DropdownMenu*` or `ContextMenu*` names as before; reach for `@/components/ui/menu`
directly only when you are building a third trigger shape.
Do not add a third menu implementation. The two that existed were byte-identical from
`computePosition` down, and the copies drifted: only one of them ever grew a sheet.
## Two presentations
`MenuRoot` picks one from form factor, never from platform — a tablet in a narrow split view
sheets the same way a phone does.
| Screen | Surface | Submenus |
| ------------------------------ | ------------------------------- | -------------------------------------------------- |
| Wide | Popover anchored to the trigger | Flyout overlapping the row, opened by hover intent |
| Compact, `compactMode="sheet"` | Bottom sheet | The page is replaced in place, with a back header |
| Compact, default | Popover | Same as wide |
`compactMode` defaults to `"popover"`, so adopting the sheet is per-menu. That is deliberate:
flipping every menu in the app to sheets at once is not a change anyone can review. Opt a menu in
when you have actually looked at it on a phone.
## Pages
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
matches:
```tsx
<MenuSurface pages={pages} sheetTitle="Display">
<MenuSubTrigger id="grouping" value="Project">
Grouping
</MenuSubTrigger>
</MenuSurface>
```
Pages are data rather than nested children because the popover renders them as _siblings_ of the
root surface — a flyout nested inside the root's box would be clipped by its `overflow: hidden`.
Declaring them separately is what lets one model drive both presentations.
`menu-navigation.ts` holds that model, and it is pure: the open flyout chain and the mobile push
stack are the same path, so the popover renders every entry in it and the sheet renders only the
last. Nothing else differs between the two.
Opening a submenu truncates the path to the depth of the trigger that opened it. Without that,
sliding the pointer across a row of triggers would stack up every flyout it passed instead of
swapping between them.
## Hover intent
A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
of backdrop between the two that belongs to neither surface, and every pixel of it is a chance to
dismiss the menu you are reaching for. Overlapping deletes the strip; don't reintroduce the gap
and try to cover it with a longer timer.
On top of that, a flyout opens after the pointer rests ~90ms, closes ~260ms after it leaves, and
cancels its own pending close while the pointer is inside it. The grace still matters because the
pointer crosses sibling rows on the way down into the flyout.
Hover lives on a plain `View`, never on a `Pressable` — see [hover.md](hover.md), which owns that
rule. Hover only fires on web, which is exactly where flyouts exist; everywhere else the page
opens on press.
## Item states
`selected` and `active` are different questions and must not be merged.
| Prop | Means | Draws |
| ---------- | -------------------------- | ------------------------- |
| `selected` | This is the chosen value | A check, and nothing else |
| `active` | This row's submenu is open | The fill, and no check |
A selected row does **not** get a background. A check and a fill are two separate claims about the
same state, and showing both makes a chosen row compete with the row the pointer is actually on.
`showSelectedCheck` moves the check to a reserved leading column when a group needs to stay
aligned whether ticked or not; otherwise it sits at the trailing edge and the leading slot is free
for the option's icon.
Give options icons; leave the root rows without them. The root is labels and their current values,
and a column of icons there is decoration competing with the values you actually came to read.
## Surface details
- The hover fill is **inset by the same amount on every side and rounded** — a chip inside the
menu, not a band across it. The inset is taken _out of_ the row, never added to it: horizontally
padding gives up what margin takes so labels don't move, and vertically the fill is shorter by
the same amount so the row pitch is unchanged. Insetting by growing the row is how the menu ends
up taller than it started.
- The fill's height only holds because the label's `lineHeight` is pinned. Leave it to the platform
and content outgrows `minHeight`, which then does nothing.
- **The item's inset is the menu's only spacing.** Rows sit apart because each holds itself in, and
a separator gets clearance from its neighbours for free. Don't give the separator a margin of its
own — that is two numbers controlling one gap, and they will disagree.
- Separators use `borderAccent`, the same colour the surface outlines itself with. `border` sits
between `surface1` and `surface2`, which put it within a hair of the hover fill and made
separators disappear against a hovered row.
## When a decision earns a submenu
Put a decision behind a submenu when its options are not the point — the current _value_ is. The
root row then reads as the answer (`Grouping Project ›`) and costs one line instead of one line
per option. A menu whose every option is on screen at once does not survive its third decision.
Independent toggles stay on their page as a checkmark list. A pick-one group can share that page
below a `MenuSeparator`; make selecting the checked row clear it, so "none" doesn't need a row of
its own.
## Gotchas
- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
identity changes — a pushed page taller than the one it replaced is clipped without that.
- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
rarely the height of the page before it.
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
positioning bugs there, not at a call site.
- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
@@ -223,19 +223,29 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
test("changes file actions open from the kebab and right-click", async ({ page }) => {
test("changes file actions open below the right-click without a reserved kebab", async ({
page,
}) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
await page.getByTestId("diff-file-1-actions").click();
await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
const fileRow = page.getByTestId("diff-file-0-toggle");
const fileRowBounds = await fileRow.boundingBox();
expect(fileRowBounds).not.toBeNull();
await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
expect(menuBounds).not.toBeNull();
expect(menuBounds!.x).toBeCloseTo(fileRowBounds!.x + 80, 0);
expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
@@ -303,7 +313,7 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
await expect(visiblePanel).toContainText("Deleted");
await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
@@ -340,6 +350,9 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -360,12 +373,7 @@ test("workspace file panes keep their controls on shared alignment rails", async
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
const changesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "changes-options-menu"),
readSvgRight(page, "diff-file-0-actions"),
]);
expectAligned(changesRightRail);
await expect(page.getByTestId("diff-file-0-actions")).toHaveCount(0);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
@@ -378,12 +386,17 @@ test("workspace file panes keep their controls on shared alignment rails", async
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
const filesRightRail = await Promise.all([
readSvgRight(page, "explorer-close"),
readSvgRight(page, "files-refresh"),
readSvgRight(page, "file-explorer-row-0-actions"),
]);
expectAligned(filesRightRail);
await expect(page.getByTestId("file-explorer-row-0-actions")).toHaveCount(0);
const fileExplorerRow = page.getByTestId("file-explorer-row-0");
const fileExplorerRowBounds = await fileExplorerRow.boundingBox();
expect(fileExplorerRowBounds).not.toBeNull();
await fileExplorerRow.click({ button: "right", position: { x: 80, y: 10 } });
await expect(page.getByTestId("file-explorer-row-0-context-menu")).toBeVisible();
const fileMenuBounds = await page.getByTestId("file-explorer-row-0-context-menu").boundingBox();
expect(fileMenuBounds).not.toBeNull();
expect(fileMenuBounds!.x).toBeCloseTo(fileExplorerRowBounds!.x + 80, 0);
expect(fileMenuBounds!.y).toBeGreaterThan(fileExplorerRowBounds!.y + 10);
await page.keyboard.press("Escape");
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
@@ -593,21 +606,6 @@ async function openChangesInVisibleExplorer(page: Page): Promise<void> {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
async function readSvgRight(page: Page, testID: string): Promise<number> {
const box = await page.getByTestId(testID).locator("svg").first().boundingBox();
if (!box) {
throw new Error(`Could not measure ${testID}`);
}
return box.x + box.width;
}
function expectAligned(values: number[]): void {
const [first, ...rest] = values;
for (const value of rest) {
expect(value).toBeCloseTo(first, 0);
}
}
async function expectExpandedMountedTabDiff(page: Page): Promise<void> {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
+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",
-5
View File
@@ -8,7 +8,6 @@ import {
} from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DiffStat } from "@/components/diff-stat";
import { FILE_ACTIONS_MENU_WIDTH } from "@/components/file-actions-menu";
import {
TreeChevron,
TreeIndentGuides,
@@ -91,7 +90,6 @@ export function DiffFolderRow({
deletions={deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
<View style={styles.actionSlot} />
</View>
</Pressable>
</View>
@@ -126,9 +124,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
flexShrink: 0,
gap: theme.spacing[1],
},
actionSlot: {
width: FILE_ACTIONS_MENU_WIDTH,
},
folderName: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
+30 -84
View File
@@ -50,8 +50,8 @@ import {
treeRowPaddingLeft,
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
} from "@/components/tree-primitives";
import { SvgXml } from "react-native-svg";
import { getFileIconSvg } from "@/components/material-file-icons";
import { MaterialFileIcon } from "@/components/material-file-icon";
import { FileChangeIcon } from "@/components/file-change-icon";
import { useCheckoutPrStatusQuery } from "@/git/use-pr-status-query";
import { CommitsSection } from "@/git/commits-section/commits-section";
import { useChangesPreferences } from "@/hooks/use-changes-preferences";
@@ -75,7 +75,8 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import * as Clipboard from "expo-clipboard";
import { FILE_ACTIONS_MENU_WIDTH, 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 { buildAbsoluteExplorerPath } from "@/utils/explorer-paths";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
@@ -919,7 +920,6 @@ const DiffFileHeader = memo(function DiffFileHeader({
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const { t } = useTranslation();
const dragSourceRef = useWorkspaceFileDragSource({
enabled: interactive,
disabled: file.isDeleted,
@@ -930,7 +930,6 @@ const DiffFileHeader = memo(function DiffFileHeader({
const layoutYRef = useRef<number | null>(null);
const pressHandledRef = useRef(false);
const pressInRef = useRef<{ ts: number; pageX: number; pageY: number } | null>(null);
const [isActionsOpen, setIsActionsOpen] = useState(false);
const toggleExpanded = useCallback(() => {
if (!interactive) {
@@ -956,15 +955,6 @@ const DiffFileHeader = memo(function DiffFileHeader({
onDownload?.(file.path);
}, [file.path, onDownload]);
const handleContextMenu = useCallback(
(event: { preventDefault: () => void; stopPropagation: () => void }) => {
event.preventDefault();
event.stopPropagation();
setIsActionsOpen(true);
},
[],
);
const handleLayout = useCallback(
(event: LayoutChangeEvent) => {
layoutYRef.current = event.nativeEvent.layout.y;
@@ -1025,7 +1015,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
<View ref={dragSourceRef} style={styles.fileHeaderLeft}>
{showDir ? null : (
<View style={styles.fileIcon}>
<SvgXml xml={getFileIconSvg(fileName)} width={16} height={16} />
<MaterialFileIcon fileName={fileName} size={16} />
</View>
)}
<Text style={styles.fileName} numberOfLines={1}>
@@ -1040,16 +1030,8 @@ const DiffFileHeader = memo(function DiffFileHeader({
// stays right-aligned next to the diff stats, as in the flat list.
<View style={styles.fileDirSpacer} />
)}
{file.isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>{t("workspace.git.diff.newFile")}</Text>
</View>
)}
{file.isDeleted && (
<View style={styles.deletedBadge}>
<Text style={styles.deletedBadgeText}>{t("workspace.git.diff.deletedFile")}</Text>
</View>
)}
{file.isNew && <FileChangeIcon change="added" />}
{file.isDeleted && <FileChangeIcon change="deleted" />}
</View>
<View style={styles.fileHeaderRight}>
<DiffStat
@@ -1057,20 +1039,6 @@ const DiffFileHeader = memo(function DiffFileHeader({
deletions={file.deletions}
testID={testID ? `${testID}-stat` : undefined}
/>
{interactive ? (
<FileActionsMenu
fileKind="file"
fileExists={!file.isDeleted}
onOpenFile={onOpenFile ? handleOpenFile : undefined}
onCopyPath={onCopyPath ? handleCopyPath : undefined}
onDownload={onDownload ? handleDownload : undefined}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
open={isActionsOpen}
onOpenChange={setIsActionsOpen}
accessibilityLabel={t("workspace.fileActions.moreActions")}
testIDPrefix={testID}
/>
) : null}
</View>
</>
);
@@ -1082,7 +1050,8 @@ const DiffFileHeader = memo(function DiffFileHeader({
);
} else {
trigger = (
<Pressable
<ContextMenuTrigger
enabledOnMobile={false}
testID={testID ? `${testID}-toggle` : undefined}
style={headerPressableStyle}
// Android: prevent parent pan/scroll gestures from canceling the tap release.
@@ -1090,23 +1059,34 @@ const DiffFileHeader = memo(function DiffFileHeader({
onPressIn={handlePressIn}
onPressOut={handlePressOut}
onPress={toggleExpanded}
// @ts-ignore - onContextMenu is web-only and not in RN types.
onContextMenu={handleContextMenu}
>
{headerContent}
</Pressable>
</ContextMenuTrigger>
);
}
return (
<View style={containerStyle} onLayout={handleLayout} testID={testID}>
<TreeIndentGuides depth={depth} />
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
<Text style={styles.tooltipText}>{file.path}</Text>
</TooltipContent>
</Tooltip>
<ContextMenu>
<Tooltip delayDuration={300} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
<TooltipContent side="bottom" align="start" offset={6} maxWidth={520}>
<Text style={styles.tooltipText}>{file.path}</Text>
</TooltipContent>
</Tooltip>
{interactive ? (
<FileActionsContextMenuContent
fileKind="file"
fileExists={!file.isDeleted}
onOpenFile={onOpenFile ? handleOpenFile : undefined}
onCopyPath={onCopyPath ? handleCopyPath : undefined}
onDownload={onDownload ? handleDownload : undefined}
onAddToChat={onAddToChat ? handleAddToChat : undefined}
testIDPrefix={testID}
/>
) : null}
</ContextMenu>
</View>
);
});
@@ -3031,7 +3011,7 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 0,
},
overflowButton: {
width: FILE_ACTIONS_MENU_WIDTH,
width: ICON_SIZE.sm + 2 * SPACING[1],
height: {
xs: 32,
sm: 32,
@@ -3176,40 +3156,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minWidth: 0,
},
newBadge: {
backgroundColor: "rgba(46, 160, 67, 0.2)",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
newBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition,
},
deletedBadge: {
backgroundColor: "rgba(248, 81, 73, 0.2)",
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md,
flexShrink: 0,
},
deletedBadgeText: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion,
},
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffAddition,
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
color: theme.colors.diffDeletion,
},
diffContent: {
borderTopWidth: theme.borderWidth[1],
borderTopColor: theme.colors.border,
@@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { subscribeToRelativeTimeTick, type TickResolution } from "@/utils/relative-time-ticker";
import { describeCompactTimeAgo } from "@/utils/time";
/**
* A compact relative timestamp that keeps itself current.
*
* Call this from the smallest component that renders the text. The state lives there, so a tick
* re-renders one `<Text>` and nothing above it — the row, the list, and the sidebar are never
* involved.
*
* Two things keep the cost near zero:
*
* - **Ticking is not re-rendering.** A tick recomputes the label and only sets state if the
* string actually changed. A row reading "3d" is woken hourly and re-renders roughly once a
* day, when it becomes "4d".
* - **The tier follows the label.** As a timestamp ages the label changes more slowly, so the
* subscription moves to a slower tier; past a week it unsubscribes for good, because a date
* never changes again.
*/
export function useCompactTimeAgo(date: Date | null): string {
const [label, setLabel] = useState(() => (date ? describeCompactTimeAgo(date).label : ""));
// Keyed on the instant, not the Date object: the store parses a fresh Date on every payload, so
// depending on identity would tear down and rebuild the subscription for an unchanged time.
const time = date === null ? null : date.getTime();
useEffect(() => {
if (time === null) {
setLabel("");
return undefined;
}
const source = new Date(time);
let current = describeCompactTimeAgo(source);
setLabel(current.label);
let unsubscribe: (() => void) | null = null;
const handleTick = () => {
const next = describeCompactTimeAgo(source);
if (next.label !== current.label) {
setLabel(next.label);
}
if (next.resolution !== current.resolution) {
// Aged into a slower tier — or out of them entirely.
current = next;
unsubscribe?.();
unsubscribe = null;
attach();
return;
}
current = next;
};
const attach = () => {
if (current.resolution === "static") return;
unsubscribe = subscribeToRelativeTimeTick(current.resolution as TickResolution, handleTick);
};
attach();
return () => {
unsubscribe?.();
};
}, [time]);
return label;
}
+20 -43
View File
@@ -38,6 +38,7 @@ import {
type SendBehavior,
type ServiceUrlBehavior,
type Settings,
type SidebarWorkspaceTrailing,
type SettingsDeps,
type WorkspaceTitleSource,
} from "./storage";
@@ -69,9 +70,27 @@ export type {
ServiceUrlBehavior,
Settings,
SettingsDeps,
SidebarWorkspaceTrailing,
WorkspaceTitleSource,
};
/**
* Split a `Settings` patch into the part the app owns. The two halves persist to different
* places (AsyncStorage vs the Electron settings bridge), and the app's half is exactly the
* key set of `DEFAULT_CLIENT_SETTINGS` — reading the keys off it means a new app setting
* flows through here without anyone remembering to widen a hand-written list.
*/
function pickDefinedAppSettings(updates: Partial<Settings>): Partial<AppSettings> {
const appUpdates: Partial<AppSettings> = {};
for (const key of Object.keys(DEFAULT_CLIENT_SETTINGS) as (keyof AppSettings)[]) {
const value = updates[key];
if (value !== undefined) {
Object.assign(appUpdates, { [key]: value });
}
}
return appUpdates;
}
const productionDeps: SettingsDeps = {
storage: AsyncStorage,
desktop: {
@@ -151,49 +170,7 @@ export function useSettings<TSelected>(
const updateSettings = useCallback(
async (updates: Partial<Settings>) => {
const appUpdates: Partial<AppSettings> = {};
if (updates.theme !== undefined) {
appUpdates.theme = updates.theme;
}
if (updates.language !== undefined) {
appUpdates.language = updates.language;
}
if (updates.sendBehavior !== undefined) {
appUpdates.sendBehavior = updates.sendBehavior;
}
if (updates.serviceUrlBehavior !== undefined) {
appUpdates.serviceUrlBehavior = updates.serviceUrlBehavior;
}
if (updates.terminalScrollbackLines !== undefined) {
appUpdates.terminalScrollbackLines = updates.terminalScrollbackLines;
}
if (updates.uiFontFamily !== undefined) {
appUpdates.uiFontFamily = updates.uiFontFamily;
}
if (updates.monoFontFamily !== undefined) {
appUpdates.monoFontFamily = updates.monoFontFamily;
}
if (updates.uiFontSize !== undefined) {
appUpdates.uiFontSize = updates.uiFontSize;
}
if (updates.codeFontSize !== undefined) {
appUpdates.codeFontSize = updates.codeFontSize;
}
if (updates.syntaxTheme !== undefined) {
appUpdates.syntaxTheme = updates.syntaxTheme;
}
if (updates.workspaceTitleSource !== undefined) {
appUpdates.workspaceTitleSource = updates.workspaceTitleSource;
}
if (updates.autoExpandReasoning !== undefined) {
appUpdates.autoExpandReasoning = updates.autoExpandReasoning;
}
if (updates.toolCallDetailLevel !== undefined) {
appUpdates.toolCallDetailLevel = updates.toolCallDetailLevel;
}
if (updates.vimKeybindings !== undefined) {
appUpdates.vimKeybindings = updates.vimKeybindings;
}
const appUpdates = pickDefinedAppSettings(updates);
const promises: Promise<void>[] = [];
if (Object.keys(appUpdates).length > 0) {
promises.push(appSettings.updateSettings(appUpdates));
+50 -14
View File
@@ -2,6 +2,11 @@ import { isSyntaxThemeId, type SyntaxThemeId } from "@getpaseo/highlight";
import type { QueryClient } from "@tanstack/react-query";
import type { DesktopSettings } from "@/desktop/settings/desktop-settings";
import { parseAppLanguage, type AppLanguage } from "@/i18n/locales";
import {
DEFAULT_SIDEBAR_ROW_ITEMS,
parseSidebarRowItems,
type SidebarRowItems,
} from "@/components/sidebar/display-preferences/row-items";
import { THEME_TO_UNISTYLES, type ThemeName } from "@/styles/theme";
export const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -12,11 +17,18 @@ export type SendBehavior = "interrupt" | "queue";
export type ReleaseChannel = "stable" | "beta";
export type ServiceUrlBehavior = "ask" | "in-app" | "external";
export type WorkspaceTitleSource = "title" | "branch";
/** What a sidebar workspace row shows in the space to the right of its title. */
export type SidebarWorkspaceTrailing = "diff" | "timestamp" | "none";
export type ToolCallDetailLevel = "overview" | "detailed";
const VALID_THEMES = new Set<string>([...Object.keys(THEME_TO_UNISTYLES), "auto"]);
const VALID_SERVICE_URL_BEHAVIORS = new Set<ServiceUrlBehavior>(["ask", "in-app", "external"]);
const VALID_WORKSPACE_TITLE_SOURCES = new Set<WorkspaceTitleSource>(["title", "branch"]);
const VALID_SIDEBAR_WORKSPACE_TRAILINGS = new Set<SidebarWorkspaceTrailing>([
"diff",
"timestamp",
"none",
]);
const VALID_TOOL_CALL_DETAIL_LEVELS = new Set<ToolCallDetailLevel>(["overview", "detailed"]);
export const DEFAULT_TERMINAL_SCROLLBACK_LINES = 10_000;
export const MIN_TERMINAL_SCROLLBACK_LINES = 0;
@@ -42,6 +54,8 @@ export interface AppSettings {
codeFontSize: number; // clamped px, default 12
syntaxTheme: SyntaxThemeId; // default "one"
workspaceTitleSource: WorkspaceTitleSource;
sidebarWorkspaceTrailing: SidebarWorkspaceTrailing;
sidebarRowItems: SidebarRowItems;
autoExpandReasoning: boolean;
toolCallDetailLevel: ToolCallDetailLevel;
chatOutlineEnabled: boolean;
@@ -68,6 +82,8 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
codeFontSize: DEFAULT_CODE_FONT_SIZE,
syntaxTheme: "one",
workspaceTitleSource: "title",
sidebarWorkspaceTrailing: "diff",
sidebarRowItems: DEFAULT_SIDEBAR_ROW_ITEMS,
autoExpandReasoning: false,
toolCallDetailLevel: "detailed",
chatOutlineEnabled: true,
@@ -206,15 +222,16 @@ function pickBooleanAppSettings(stored: StoredAppSettings): Partial<AppSettings>
return result;
}
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
/**
* The settings whose stored value only has to be a member of a fixed set. Grouped like the
* boolean settings are: the numeric and font settings need real parsing and clamping, these
* need a membership check and nothing else.
*/
function pickEnumAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
if (typeof stored.theme === "string" && VALID_THEMES.has(stored.theme)) {
result.theme = stored.theme;
}
const language = parseAppLanguage(stored.language);
if (language !== null) {
result.language = language;
}
if (stored.sendBehavior === "interrupt" || stored.sendBehavior === "queue") {
result.sendBehavior = stored.sendBehavior;
}
@@ -224,6 +241,34 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
) {
result.serviceUrlBehavior = stored.serviceUrlBehavior;
}
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
if (
typeof stored.workspaceTitleSource === "string" &&
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
) {
result.workspaceTitleSource = stored.workspaceTitleSource;
}
if (
typeof stored.sidebarWorkspaceTrailing === "string" &&
VALID_SIDEBAR_WORKSPACE_TRAILINGS.has(stored.sidebarWorkspaceTrailing)
) {
result.sidebarWorkspaceTrailing = stored.sidebarWorkspaceTrailing;
}
return result;
}
function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
const result: Partial<AppSettings> = {};
Object.assign(result, pickEnumAppSettings(stored));
if (stored.sidebarRowItems !== undefined) {
result.sidebarRowItems = parseSidebarRowItems(stored.sidebarRowItems);
}
const language = parseAppLanguage(stored.language);
if (language !== null) {
result.language = language;
}
const terminalScrollbackLines = parseTerminalScrollbackLines(stored.terminalScrollbackLines);
if (terminalScrollbackLines !== null) {
result.terminalScrollbackLines = terminalScrollbackLines;
@@ -250,16 +295,7 @@ function pickAppSettings(stored: StoredAppSettings): Partial<AppSettings> {
if (codeFontSize !== null) {
result.codeFontSize = codeFontSize;
}
if (typeof stored.syntaxTheme === "string" && isSyntaxThemeId(stored.syntaxTheme)) {
result.syntaxTheme = stored.syntaxTheme;
}
Object.assign(result, pickBooleanAppSettings(stored));
if (
typeof stored.workspaceTitleSource === "string" &&
VALID_WORKSPACE_TITLE_SOURCES.has(stored.workspaceTitleSource)
) {
result.workspaceTitleSource = stored.workspaceTitleSource;
}
if (typeof stored.autoExpandReasoning === "boolean") {
result.autoExpandReasoning = stored.autoExpandReasoning;
}
+4
View File
@@ -159,6 +159,10 @@ describe("translation resources", () => {
expect(zhCN.settings.providers.models.many).toBe("{{count}} 个 Model");
});
it("labels the immediate add-to-chat action without an ellipsis", () => {
expect(en.workspace.fileActions.addToChat).toBe("Add to chat");
});
it("keeps local connection fallback errors translated", () => {
expect(findUntranslatedConnectionErrors()).toEqual([]);
});
+36 -1
View File
@@ -384,7 +384,7 @@ export const ar: TranslationResources = {
openFile: "افتح الملف",
copyPath: "نسخ المسار",
download: "تحميل",
addToChat: "إضافة إلى الدردشة…",
addToChat: "إضافة إلى الدردشة",
moreActions: "المزيد من الإجراءات",
},
fileExplorer: {
@@ -835,6 +835,14 @@ export const ar: TranslationResources = {
viewPullRequest: "عرض",
openOn: "فتح على {{brand}}",
},
checksSummary: {
passedLabel: "نجح",
failedLabel: "فشل",
runningLabel: "قيد التشغيل",
passedAccessible: "نجحت الفحوصات",
failedAccessible: "فشلت الفحوصات",
runningAccessible: "الفحوصات قيد التشغيل",
},
sections: {
checks: "الشيكات",
pipeline: "خط المعالجة",
@@ -882,6 +890,33 @@ export const ar: TranslationResources = {
},
},
sidebar: {
display: {
trigger: "تفضيلات العرض",
heading: "العرض",
grouping: {
label: "التجميع",
project: "المشروع",
status: "الحالة",
},
titleSource: {
label: "العنوان",
title: "العنوان",
branch: "اسم الفرع",
},
show: {
label: "إظهار",
host: "المضيف",
changeRequest: "طلب السحب",
checks: "الفحوصات",
scripts: "البرامج النصية",
diff: "إحصائيات الفروق",
timestamp: "آخر نشاط",
},
hostFilter: {
label: "المضيف",
all: "كل المضيفين",
},
},
pinned: {
title: "المثبتة",
},
+36 -1
View File
@@ -383,7 +383,7 @@ export const en = {
openFile: "Open file",
copyPath: "Copy path",
download: "Download",
addToChat: "Add to chat…",
addToChat: "Add to chat",
moreActions: "More actions",
},
fileExplorer: {
@@ -845,6 +845,14 @@ export const en = {
viewPullRequest: "View",
openOn: "Open on {{brand}}",
},
checksSummary: {
passedLabel: "passed",
failedLabel: "failed",
runningLabel: "running",
passedAccessible: "Checks passed",
failedAccessible: "Checks failed",
runningAccessible: "Checks running",
},
sections: {
checks: "Checks",
pipeline: "Pipeline",
@@ -892,6 +900,33 @@ export const en = {
},
},
sidebar: {
display: {
trigger: "Display preferences",
heading: "Display",
grouping: {
label: "Grouping",
project: "Project",
status: "Status",
},
titleSource: {
label: "Title",
title: "Title",
branch: "Branch name",
},
show: {
label: "Show",
host: "Host",
changeRequest: "Pull request",
checks: "Checks",
scripts: "Scripts",
diff: "Diff stats",
timestamp: "Last activity",
},
hostFilter: {
label: "Host",
all: "All hosts",
},
},
pinned: {
title: "Pinned",
},
+36 -1
View File
@@ -388,7 +388,7 @@ export const es: TranslationResources = {
openFile: "Abrir archivo",
copyPath: "Copiar ruta",
download: "Descargar",
addToChat: "Añadir al chat…",
addToChat: "Añadir al chat",
moreActions: "Más acciones",
},
fileExplorer: {
@@ -866,6 +866,14 @@ export const es: TranslationResources = {
viewPullRequest: "Ver",
openOn: "Abrir en {{brand}}",
},
checksSummary: {
passedLabel: "pasó",
failedLabel: "falló",
runningLabel: "en curso",
passedAccessible: "Comprobaciones superadas",
failedAccessible: "Comprobaciones fallidas",
runningAccessible: "Comprobaciones en curso",
},
sections: {
checks: "cheques",
pipeline: "Pipeline",
@@ -913,6 +921,33 @@ export const es: TranslationResources = {
},
},
sidebar: {
display: {
trigger: "Preferencias de visualización",
heading: "Visualización",
grouping: {
label: "Agrupación",
project: "Proyecto",
status: "Estado",
},
titleSource: {
label: "Título",
title: "Título",
branch: "Nombre de rama",
},
show: {
label: "Mostrar",
host: "Host",
changeRequest: "Pull request",
checks: "Comprobaciones",
scripts: "Scripts",
diff: "Estadísticas de cambios",
timestamp: "Última actividad",
},
hostFilter: {
label: "Host",
all: "Todos los hosts",
},
},
pinned: {
title: "Anclados",
},
+36 -1
View File
@@ -388,7 +388,7 @@ export const fr: TranslationResources = {
openFile: "Ouvrir le fichier",
copyPath: "Copier le chemin",
download: "Télécharger",
addToChat: "Ajouter au chat…",
addToChat: "Ajouter au chat",
moreActions: "Plus de propositions",
},
fileExplorer: {
@@ -865,6 +865,14 @@ export const fr: TranslationResources = {
viewPullRequest: "Voir",
openOn: "Ouvrir sur {{brand}}",
},
checksSummary: {
passedLabel: "succès",
failedLabel: "échec",
runningLabel: "en cours",
passedAccessible: "Vérifications réussies",
failedAccessible: "Vérifications en échec",
runningAccessible: "Vérifications en cours",
},
sections: {
checks: "Chèques",
pipeline: "Pipeline",
@@ -912,6 +920,33 @@ export const fr: TranslationResources = {
},
},
sidebar: {
display: {
trigger: "Préférences d'affichage",
heading: "Affichage",
grouping: {
label: "Regroupement",
project: "Projet",
status: "Statut",
},
titleSource: {
label: "Titre",
title: "Titre",
branch: "Nom de branche",
},
show: {
label: "Afficher",
host: "Hôte",
changeRequest: "Pull request",
checks: "Vérifications",
scripts: "Scripts",
diff: "Statistiques de diff",
timestamp: "Dernière activité",
},
hostFilter: {
label: "Hôte",
all: "Tous les hôtes",
},
},
pinned: {
title: "Épinglés",
},
+36 -1
View File
@@ -388,7 +388,7 @@ export const ja: TranslationResources = {
openFile: "ファイルを開く",
copyPath: "パスをコピー",
download: "ダウンロード",
addToChat: "チャットに追加…",
addToChat: "チャットに追加",
moreActions: "その他のアクション",
},
fileExplorer: {
@@ -846,6 +846,14 @@ export const ja: TranslationResources = {
viewPullRequest: "表示",
openOn: "{{brand}}で開く",
},
checksSummary: {
passedLabel: "成功",
failedLabel: "失敗",
runningLabel: "実行中",
passedAccessible: "チェック成功",
failedAccessible: "チェック失敗",
runningAccessible: "チェック実行中",
},
sections: {
checks: "チェック",
pipeline: "パイプライン",
@@ -893,6 +901,33 @@ export const ja: TranslationResources = {
},
},
sidebar: {
display: {
trigger: "表示設定",
heading: "表示",
grouping: {
label: "グループ化",
project: "プロジェクト",
status: "ステータス",
},
titleSource: {
label: "タイトル",
title: "タイトル",
branch: "ブランチ名",
},
show: {
label: "表示項目",
host: "ホスト",
changeRequest: "プルリクエスト",
checks: "チェック",
scripts: "スクリプト",
diff: "差分統計",
timestamp: "最終アクティビティ",
},
hostFilter: {
label: "ホスト",
all: "すべてのホスト",
},
},
pinned: {
title: "固定済み",
},
+36 -1
View File
@@ -388,7 +388,7 @@ export const ptBR: TranslationResources = {
openFile: "Abrir arquivo",
copyPath: "Copiar caminho",
download: "Baixar",
addToChat: "Adicionar ao chat…",
addToChat: "Adicionar ao chat",
moreActions: "Mais ações",
},
fileExplorer: {
@@ -857,6 +857,14 @@ export const ptBR: TranslationResources = {
viewPullRequest: "Ver",
openOn: "Abrir no {{brand}}",
},
checksSummary: {
passedLabel: "passou",
failedLabel: "falhou",
runningLabel: "em andamento",
passedAccessible: "Verificações aprovadas",
failedAccessible: "Verificações com falha",
runningAccessible: "Verificações em andamento",
},
sections: {
checks: "Verificações",
pipeline: "Pipeline",
@@ -904,6 +912,33 @@ export const ptBR: TranslationResources = {
},
},
sidebar: {
display: {
trigger: "Preferências de exibição",
heading: "Exibição",
grouping: {
label: "Agrupamento",
project: "Projeto",
status: "Status",
},
titleSource: {
label: "Título",
title: "Título",
branch: "Nome da branch",
},
show: {
label: "Mostrar",
host: "Host",
changeRequest: "Pull request",
checks: "Verificações",
scripts: "Scripts",
diff: "Estatísticas de diff",
timestamp: "Última atividade",
},
hostFilter: {
label: "Host",
all: "Todos os hosts",
},
},
pinned: {
title: "Fixados",
},
+36 -1
View File
@@ -387,7 +387,7 @@ export const ru: TranslationResources = {
openFile: "Открыть файл",
copyPath: "Копировать путь",
download: "Скачать",
addToChat: "Добавить в чат…",
addToChat: "Добавить в чат",
moreActions: "Дополнительные действия",
},
fileExplorer: {
@@ -857,6 +857,14 @@ export const ru: TranslationResources = {
viewPullRequest: "Открыть",
openOn: "Открыть на {{brand}}",
},
checksSummary: {
passedLabel: "успешно",
failedLabel: "ошибка",
runningLabel: "выполняется",
passedAccessible: "Проверки пройдены",
failedAccessible: "Проверки не пройдены",
runningAccessible: "Проверки выполняются",
},
sections: {
checks: "Чеки",
pipeline: "Пайплайн",
@@ -904,6 +912,33 @@ export const ru: TranslationResources = {
},
},
sidebar: {
display: {
trigger: "Настройки отображения",
heading: "Отображение",
grouping: {
label: "Группировка",
project: "Проект",
status: "Статус",
},
titleSource: {
label: "Заголовок",
title: "Заголовок",
branch: "Имя ветки",
},
show: {
label: "Показывать",
host: "Хост",
changeRequest: "Pull request",
checks: "Проверки",
scripts: "Скрипты",
diff: "Статистика изменений",
timestamp: "Последняя активность",
},
hostFilter: {
label: "Хост",
all: "Все хосты",
},
},
pinned: {
title: "Закреплённые",
},
+36 -1
View File
@@ -384,7 +384,7 @@ export const zhCN: TranslationResources = {
openFile: "打开文件",
copyPath: "复制路径",
download: "下载",
addToChat: "添加到聊天…",
addToChat: "添加到聊天",
moreActions: "更多操作",
},
fileExplorer: {
@@ -827,6 +827,14 @@ export const zhCN: TranslationResources = {
viewPullRequest: "查看",
openOn: "在 {{brand}} 上打开",
},
checksSummary: {
passedLabel: "通过",
failedLabel: "失败",
runningLabel: "运行中",
passedAccessible: "检查通过",
failedAccessible: "检查失败",
runningAccessible: "检查运行中",
},
sections: {
checks: "Checks",
pipeline: "流水线",
@@ -874,6 +882,33 @@ export const zhCN: TranslationResources = {
},
},
sidebar: {
display: {
trigger: "显示偏好",
heading: "显示",
grouping: {
label: "分组",
project: "项目",
status: "状态",
},
titleSource: {
label: "标题",
title: "标题",
branch: "分支名称",
},
show: {
label: "显示",
host: "主机",
changeRequest: "拉取请求",
checks: "检查",
scripts: "脚本",
diff: "差异统计",
timestamp: "最近活动",
},
hostFilter: {
label: "主机",
all: "所有主机",
},
},
pinned: {
title: "已置顶",
},
@@ -300,8 +300,7 @@ function ProjectPickerTrigger({
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectViewKey={projectViewKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
size={ICON_SIZE.md}
textStyle={styles.projectIconFallbackText}
/>
) : (
@@ -454,8 +453,7 @@ function ProjectOptionItem({
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectViewKey={projectViewKey}
imageStyle={styles.projectIcon}
fallbackStyle={styles.projectIconFallback}
size={ICON_SIZE.md}
textStyle={styles.projectIconFallbackText}
/>
</View>
@@ -2306,18 +2304,6 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
flexShrink: 0,
},
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,
alignItems: "center",
justifyContent: "center",
},
projectIconFallbackText: {
// Single uppercase initial inside an iconSize.md (16px) square — below the
// smallest font-size token, so it stays a literal sized to the box.
@@ -821,8 +821,7 @@ function ProjectTitleIcon({
iconDataUri={iconDataUri}
initial={initial}
projectViewKey={projectViewKey}
imageStyle={styles.titleIcon}
fallbackStyle={styles.titleIconFallback}
size={28}
textStyle={styles.titleIconFallbackText}
/>
);
@@ -1105,18 +1104,6 @@ const styles = StyleSheet.create((theme) => ({
editButton: {
padding: theme.spacing[1],
},
titleIcon: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
},
titleIconFallback: {
width: 28,
height: 28,
borderRadius: theme.borderRadius.md,
alignItems: "center",
justifyContent: "center",
},
titleIconFallbackText: {
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
+1 -14
View File
@@ -166,8 +166,7 @@ function ProjectRowIcon({
iconDataUri={iconDataUri}
initial={initial}
projectViewKey={projectViewKey}
imageStyle={styles.iconImage}
fallbackStyle={styles.iconFallback}
size={16}
textStyle={styles.iconFallbackText}
/>
);
@@ -218,18 +217,6 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
justifyContent: "center",
},
iconImage: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
},
iconFallback: {
width: 16,
height: 16,
borderRadius: theme.borderRadius.sm,
alignItems: "center",
justifyContent: "center",
},
iconFallbackText: {
fontSize: theme.fontSize.xs,
},
@@ -11,7 +11,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { AdaptiveRenameModal } from "@/components/rename-modal";
import { HostBadge } from "@/components/sidebar/host-badge";
import { WorkspaceMetaRow } from "@/components/sidebar/workspace-meta-row";
import { useToast } from "@/contexts/toast-context";
import { SettingsSection } from "@/screens/settings/settings-section";
import {
@@ -223,19 +223,25 @@ function BadgePreview({
badgeDisplay: HostBadgeDisplay;
}) {
const { t } = useTranslation();
const hostBadge = useMemo(
() =>
badgeDisplay === "hidden"
? null
: {
serverId: host.serverId,
label: host.label,
color: host.appearance.color,
showLabel: badgeDisplay === "name",
},
[badgeDisplay, host.serverId, host.label, host.appearance.color],
);
// The real sidebar row, so the preview can't drift from what the setting actually does.
return (
<View style={styles.preview} testID="host-appearance-preview">
<Text style={styles.previewTitle} numberOfLines={1}>
{t("settings.host.appearance.preview.workspaceName")}
</Text>
{badgeDisplay === "hidden" ? null : (
<HostBadge
serverId={host.serverId}
label={host.label}
color={host.appearance.color}
showLabel={badgeDisplay === "name"}
/>
)}
<WorkspaceMetaRow hostBadge={hostBadge} prHint={null} scriptSummary={null} />
</View>
);
}
@@ -340,9 +346,9 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
},
preview: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
flexDirection: "column",
alignItems: "flex-start",
gap: theme.spacing[1],
paddingVertical: theme.spacing[3],
paddingHorizontal: theme.spacing[4],
borderTopWidth: theme.borderWidth[1],
@@ -0,0 +1,14 @@
/**
* The surface something is drawn on top of.
*
* A knockout — an element that punches a hole in whatever it overlaps, like the status badge on
* the corner of a project icon — has to be filled with the colour *behind* it, or the hole reads
* as a bright halo instead of a gap. It cannot work that colour out for itself: it lives inside a
* row whose background changes on hover, on selection, while dragging. So the row names its
* surface and anything knocking out of it matches.
*
* A token name rather than a colour because theme colours are only legible inside
* `StyleSheet.create` — see docs/unistyles.md. Each knockout maps the name to a style of its own.
* Add a token here when a knockout lands on a surface that isn't listed.
*/
export type SurfaceBackdrop = "surfaceSidebar" | "surfaceSidebarHover" | "surface2";
+43 -21
View File
@@ -109,42 +109,64 @@ export const baseColors = {
export type ThemeName = "light" | "dark" | "zinc" | "midnight" | "claude" | "ghostty";
// Diff colors — light uses muted tones, dark uses the brighter palette values.
// Diff colors — the +/- inside a diff view, where the color *is* the signal and has to
// survive being scanned line by line, so it stays saturated. Light uses muted tones, dark
// uses the brighter palette values.
//
// Two tiers, because the +/- serves two jobs. In the diff view the color *is* the signal and
// has to survive being scanned line by line, so it stays saturated. In a row's metadata the
// counts are a footnote next to a title and a subtitle; at full saturation they read as the
// loudest thing in the row. The Stat tier keeps the hue but drops the chroma to roughly the
// weight of foregroundMuted, so it sits with the subtitle instead of shouting over it.
// A diff *stat* — the "+12 −3" footnote next to a title — is not this. It reads as one of
// several muted signals in a row, so it uses statusMutedSuccess/Danger below rather than a
// near-duplicate tier of its own.
const lightDiffColors = {
diffAddition: "#15803d", // green-700 — readable on white without screaming
diffDeletion: "#b91c1c", // red-700
diffStatAddition: "#32794a", // softened green-700
diffStatDeletion: "#a43c39", // softened red-700
};
const darkDiffColors = {
diffAddition: "#4ade80", // green-400
diffDeletion: "#ef4444", // red-500
diffStatAddition: "#61bf82",
diffStatDeletion: "#d36461",
};
// Status colors — semantic signals for success/danger/warning/merged. Used by
// check statuses, PR states, and review decisions. Kept a step darker than the
// raw palette so they read as signals, not neon.
// Status colors — semantic signals for success/danger/warning/merged. Used by check
// statuses, PR states, and review decisions.
//
// Normalized, not hand-picked. Every color below shares one lightness and one chroma within
// its tier; only the hue changes, and each hue is the one that family already had. Chroma is
// a fixed fraction of what sRGB allows at that lightness and hue, because the gamut is
// lopsided — amber runs out of room long before red does, so a literal equal-chroma set
// leaves amber flat and red screaming. Equal fractions is what makes four hues read as one
// family. Regenerate with the same rule rather than nudging one value.
//
// Two tiers. Full is for a surface whose job is to report the status — the PR pane, a usage
// bar. Muted is for the same signal repeated on every row of a dense list, where full
// saturation makes the list itself the loudest thing on screen; it sits at roughly
// foregroundMuted's weight so a row built from it reads as one line of subtitle.
//
// Deliberately not on this scale: the workspace status dot. That one is supposed to shout,
// so it keeps the raw palette — see getStatusDotColor.
const lightStatusColors = {
statusSuccess: "#15803d", // green-700
statusDanger: "#b91c1c", // red-700
statusWarning: "#d97706", // amber-600
statusMerged: "#7c3aed", // purple-600
// L=0.53, chroma 90% of gamut max
statusSuccess: "#1f8040",
statusDanger: "#c32424",
statusWarning: "#91601b",
statusMerged: "#8728e5",
// L=0.50, chroma 45% of gamut max
statusMutedSuccess: "#496d50",
statusMutedDanger: "#904d46",
statusMutedWarning: "#755f45",
statusMutedMerged: "#6e519d",
};
const darkStatusColors = {
statusSuccess: "#16a34a", // green-600
statusDanger: "#dc2626", // red-600
statusWarning: "#f59e0b", // amber-500
statusMerged: "#9333ea", // purple-600
// L=0.66, chroma 90% of gamut max
statusSuccess: "#2ead58",
statusDanger: "#f65048",
statusWarning: "#c38328",
statusMerged: "#a670f5",
// L=0.70, chroma 40% of gamut max
statusMutedSuccess: "#7cac85",
statusMutedDanger: "#ca8c85",
statusMutedWarning: "#b79875",
statusMutedMerged: "#a594c6",
};
// Semantic color tokens - Layer-based system
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { desaturateHexColor, parseHexColor } from "./color";
describe("parseHexColor", () => {
it("parses six-digit hex", () => {
expect(parseHexColor("#ff8000")).toEqual([1, 128 / 255, 0]);
});
it("expands three-digit hex", () => {
expect(parseHexColor("#f80")).toEqual(parseHexColor("#ff8800"));
});
it("rejects anything else", () => {
expect(parseHexColor("none")).toBeNull();
expect(parseHexColor("url(#a)")).toBeNull();
expect(parseHexColor("#ff800")).toBeNull();
});
});
describe("desaturateHexColor", () => {
it("leaves a colour alone at full chroma", () => {
expect(desaturateHexColor("#0288d1", 1)).toBe("#0288d1");
});
it("returns grey at zero chroma", () => {
const grey = desaturateHexColor("#0288d1", 0);
const rgb = parseHexColor(grey);
expect(rgb).not.toBeNull();
const [r, g, b] = rgb as [number, number, number];
expect(Math.abs(r - g)).toBeLessThan(0.01);
expect(Math.abs(g - b)).toBeLessThan(0.01);
});
it("drops chroma monotonically", () => {
const spread = (hex: string) => {
const [r, g, b] = parseHexColor(hex) as [number, number, number];
return Math.max(r, g, b) - Math.min(r, g, b);
};
for (const source of ["#ffca28", "#0288d1", "#f44336", "#7e57c2"]) {
const steps = [1, 0.75, 0.5, 0.25, 0].map((amount) =>
spread(desaturateHexColor(source, amount)),
);
for (let i = 1; i < steps.length; i++) {
expect(steps[i]).toBeLessThan(steps[i - 1]);
}
}
});
// Pinned so a typo in the OKLab matrices shows up as a diff rather than as icons that look
// slightly off. The interesting one is the yellow: its green channel goes *up* and its blue
// rises to meet it, which is what holding lightness instead of scaling HSL saturation buys.
it("matches known values", () => {
expect(desaturateHexColor("#0288d1", 0.65)).toBe("#4a87b6");
expect(desaturateHexColor("#ffca28", 0.65)).toBe("#efce7b");
expect(desaturateHexColor("#f44336", 0.65)).toBe("#d4685a");
});
it("passes non-hex input through untouched", () => {
expect(desaturateHexColor("none", 0.5)).toBe("none");
expect(desaturateHexColor("currentColor", 0.5)).toBe("currentColor");
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Colour maths in OKLab, for the cases where a colour has to be adjusted rather than chosen.
*
* OKLab because chroma and lightness are separable there in a way they are not in HSL: scaling
* HSL saturation drags perceived brightness with it, so a set of icons desaturated that way ends
* up with the yellows washed out and the blues barely touched. In OKLab, holding L fixed and
* scaling the a/b vector moves a colour straight toward grey at the same apparent lightness.
*
* Reducing chroma always stays inside sRGB — the gamut is convex around the neutral axis — so
* these conversions never need gamut mapping.
*/
interface Oklab {
L: number;
a: number;
b: number;
}
function srgbToLinear(channel: number): number {
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
}
function linearToSrgb(channel: number): number {
return channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055;
}
function linearRgbToOklab(r: number, g: number, b: number): Oklab {
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return {
L: 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
a: 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
b: 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
};
}
function oklabToLinearRgb({ L, a, b }: Oklab): [number, number, number] {
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
return [
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
];
}
/** Parses `#rgb` and `#rrggbb` into 0–1 sRGB channels. Anything else is not a colour we own. */
export function parseHexColor(hex: string): [number, number, number] | null {
const body = hex.startsWith("#") ? hex.slice(1) : hex;
if (!/^[0-9a-fA-F]+$/.test(body)) return null;
let expanded: string;
if (body.length === 3) {
expanded = `${body[0]}${body[0]}${body[1]}${body[1]}${body[2]}${body[2]}`;
} else if (body.length === 6) {
expanded = body;
} else {
return null;
}
return [
Number.parseInt(expanded.slice(0, 2), 16) / 255,
Number.parseInt(expanded.slice(2, 4), 16) / 255,
Number.parseInt(expanded.slice(4, 6), 16) / 255,
];
}
function toHexChannel(channel: number): string {
const clamped = Math.min(255, Math.max(0, Math.round(channel * 255)));
return clamped.toString(16).padStart(2, "0");
}
/**
* Scales a colour's chroma toward neutral while holding its perceived lightness.
*
* `amount` is the fraction of the original chroma to keep: `1` is unchanged, `0` is grey. Input
* that is not a hex colour comes back untouched, so this is safe to run over strings that mix
* colours with things like `none` or `currentColor`.
*/
export function desaturateHexColor(hex: string, amount: number): string {
const rgb = parseHexColor(hex);
if (!rgb) return hex;
const [r, g, b] = rgb;
const lab = linearRgbToOklab(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b));
const [linearR, linearG, linearB] = oklabToLinearRgb({
L: lab.L,
a: lab.a * amount,
b: lab.b * amount,
});
return `#${toHexChannel(linearToSrgb(linearR))}${toHexChannel(linearToSrgb(linearG))}${toHexChannel(linearToSrgb(linearB))}`;
}
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { activeRelativeTimeTickerCount, subscribeToRelativeTimeTick } from "./relative-time-ticker";
const MINUTE = 60_000;
describe("subscribeToRelativeTimeTick", () => {
beforeEach(() => {
vi.useFakeTimers();
// Align the clock so the first tick lands a whole period later, not part-way through one.
vi.setSystemTime(new Date("2026-07-16T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("runs no timer until something subscribes", () => {
expect(activeRelativeTimeTickerCount()).toBe(0);
});
it("ticks a subscriber at its tier's period", () => {
const listener = vi.fn();
const unsubscribe = subscribeToRelativeTimeTick("minute", listener);
vi.advanceTimersByTime(MINUTE);
expect(listener).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(MINUTE);
expect(listener).toHaveBeenCalledTimes(2);
unsubscribe();
});
it("shares one timer across every subscriber at the same tier", () => {
const first = vi.fn();
const second = vi.fn();
const unsubFirst = subscribeToRelativeTimeTick("minute", first);
const unsubSecond = subscribeToRelativeTimeTick("minute", second);
// Fifty rows must not mean fifty timers; the tier count is what proves it.
expect(activeRelativeTimeTickerCount()).toBe(1);
vi.advanceTimersByTime(MINUTE);
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
unsubFirst();
unsubSecond();
});
it("stops the timer when the last subscriber leaves", () => {
const unsubFirst = subscribeToRelativeTimeTick("minute", vi.fn());
const unsubSecond = subscribeToRelativeTimeTick("minute", vi.fn());
unsubFirst();
expect(activeRelativeTimeTickerCount()).toBe(1);
unsubSecond();
expect(activeRelativeTimeTickerCount()).toBe(0);
});
it("runs tiers independently", () => {
const minute = vi.fn();
const hour = vi.fn();
const unsubMinute = subscribeToRelativeTimeTick("minute", minute);
const unsubHour = subscribeToRelativeTimeTick("hour", hour);
expect(activeRelativeTimeTickerCount()).toBe(2);
vi.advanceTimersByTime(MINUTE);
expect(minute).toHaveBeenCalledTimes(1);
expect(hour).not.toHaveBeenCalled();
vi.advanceTimersByTime(30 * MINUTE);
expect(hour).toHaveBeenCalledTimes(1);
unsubMinute();
unsubHour();
expect(activeRelativeTimeTickerCount()).toBe(0);
});
it("survives a listener unsubscribing during its own tick", () => {
const survivor = vi.fn();
let unsubSelf = () => {};
const quitter = vi.fn(() => unsubSelf());
unsubSelf = subscribeToRelativeTimeTick("minute", quitter);
const unsubSurvivor = subscribeToRelativeTimeTick("minute", survivor);
expect(() => vi.advanceTimersByTime(MINUTE)).not.toThrow();
expect(survivor).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(MINUTE);
expect(quitter).toHaveBeenCalledTimes(1);
expect(survivor).toHaveBeenCalledTimes(2);
unsubSurvivor();
});
});
@@ -0,0 +1,96 @@
import type { RelativeTimeResolution } from "./time";
/**
* The shared clock behind relative timestamps.
*
* A sidebar row that nothing has happened to should never re-render — that is what the
* memoization is for — so a "2m" label sitting inside one cannot wait to be re-rendered by
* something else. It needs its own clock. The naive version of that is one timer per row, which
* turns a list of fifty workspaces into fifty timers firing at fifty different moments.
*
* Instead there is at most one timer per resolution, shared by every subscriber at that
* resolution, and a tier with no subscribers has no timer at all. A sidebar full of week-old
* workspaces runs no timers whatsoever, because those labels are `static` and never subscribe.
*/
export type TickResolution = Exclude<RelativeTimeResolution, "static">;
/**
* How often each tier wakes. The label only changes once per unit, so the interval sets how
* stale it can be, not how often it moves: an hour-resolution label is at most half an hour
* behind, which is invisible on a value that reads "3h" for sixty minutes either way.
*/
const INTERVAL_MS: Record<TickResolution, number> = {
minute: 60_000,
hour: 30 * 60_000,
day: 60 * 60_000,
};
interface Tier {
listeners: Set<() => void>;
handle: ReturnType<typeof setTimeout> | null;
interval: ReturnType<typeof setInterval> | null;
}
function createTier(): Tier {
return { listeners: new Set(), handle: null, interval: null };
}
const tiers: Record<TickResolution, Tier> = {
minute: createTier(),
hour: createTier(),
day: createTier(),
};
function notify(tier: Tier): void {
// Iterated live rather than copied: a listener that unsubscribes on its own tick — which is
// exactly what happens when a label ages into a slower tier — deletes from this set mid-loop,
// and Set iteration is defined under deletion. An entry removed before it is reached is simply
// not called, which is what unsubscribing asked for.
for (const listener of tier.listeners) {
listener();
}
}
function start(resolution: TickResolution): void {
const tier = tiers[resolution];
if (tier.handle !== null || tier.interval !== null) return;
// Aligned to the wall clock so every row flips together rather than each on the anniversary of
// whenever it happened to mount.
const period = INTERVAL_MS[resolution];
const delay = period - (Date.now() % period);
tier.handle = setTimeout(() => {
tier.handle = null;
notify(tier);
tier.interval = setInterval(() => notify(tier), period);
}, delay);
}
function stop(resolution: TickResolution): void {
const tier = tiers[resolution];
if (tier.handle !== null) clearTimeout(tier.handle);
if (tier.interval !== null) clearInterval(tier.interval);
tier.handle = null;
tier.interval = null;
}
/** Subscribes to a tier's tick. The tier's timer runs only while it has at least one listener. */
export function subscribeToRelativeTimeTick(
resolution: TickResolution,
listener: () => void,
): () => void {
const tier = tiers[resolution];
tier.listeners.add(listener);
if (tier.listeners.size === 1) start(resolution);
return () => {
tier.listeners.delete(listener);
if (tier.listeners.size === 0) stop(resolution);
};
}
/** Test seam: how many tiers currently hold a live timer. */
export function activeRelativeTimeTickerCount(): number {
return Object.values(tiers).filter((tier) => tier.handle !== null || tier.interval !== null)
.length;
}
+48 -1
View File
@@ -1,5 +1,11 @@
import { describe, it, expect } from "vitest";
import { formatDuration, formatMessageTimestamp, formatTimeAgo } from "./time";
import {
describeCompactTimeAgo,
formatCompactTimeAgo,
formatDuration,
formatMessageTimestamp,
formatTimeAgo,
} from "./time";
describe("formatTimeAgo", () => {
const now = new Date("2026-07-16T12:00:00.000Z");
@@ -16,6 +22,47 @@ describe("formatTimeAgo", () => {
});
});
describe("describeCompactTimeAgo", () => {
const now = new Date("2026-07-16T12:00:00.000Z");
it.each([
["2026-07-16T11:59:59.000Z", "now", "minute"],
["2026-07-16T11:59:30.000Z", "now", "minute"],
["2026-07-16T11:59:00.000Z", "1m", "minute"],
["2026-07-16T11:55:00.000Z", "5m", "minute"],
["2026-07-16T11:00:00.000Z", "1h", "hour"],
["2026-07-16T10:00:00.000Z", "2h", "hour"],
["2026-07-15T12:00:00.000Z", "1d", "day"],
["2026-07-13T12:00:00.000Z", "3d", "day"],
["2026-07-10T12:00:00.000Z", "6d", "day"],
["2026-07-09T12:00:00.000Z", "Jul 9", "static"],
["2026-01-15T12:00:00.000Z", "Jan 15", "static"],
] as const)("formats %s as %s at %s resolution", (date, label, resolution) => {
expect(describeCompactTimeAgo(new Date(date), now)).toEqual({ label, resolution });
});
it("never shows a sub-minute count", () => {
// A seconds label is only true for the second it rendered. Everything under a minute is
// "now", which stays true and lets the row sit still.
for (let seconds = 0; seconds < 60; seconds += 1) {
const date = new Date(now.getTime() - seconds * 1000);
expect(describeCompactTimeAgo(date, now).label).toBe("now");
}
});
it("switches to an absolute date exactly at seven days", () => {
const almost = new Date(now.getTime() - (7 * 24 * 60 * 60 * 1000 - 1));
const exactly = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
expect(describeCompactTimeAgo(almost, now).resolution).toBe("day");
expect(describeCompactTimeAgo(exactly, now).resolution).toBe("static");
});
it("keeps formatCompactTimeAgo as the label alone", () => {
const date = new Date("2026-07-16T10:00:00.000Z");
expect(formatCompactTimeAgo(date, now)).toBe(describeCompactTimeAgo(date, now).label);
});
});
describe("formatDuration", () => {
it("renders sub-minute durations as whole seconds", () => {
expect(formatDuration(0)).toBe("0s");
+80 -24
View File
@@ -1,38 +1,94 @@
/**
* Format a date as a human-friendly relative time string
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
* How long ago something was, before it's worded. The two formatters below share this so
* their thresholds can't drift apart; they differ only in how much room they have to say it.
*
* `elapsed` takes an "ago" in prose, `now` and `date` read as absolutes and never do.
*/
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
type Elapsed =
| { kind: "now" }
| { kind: "elapsed"; value: string }
| { kind: "date"; value: string };
function describeElapsed(date: Date, now: Date): Elapsed {
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (diffSec < 10) {
return "just now";
}
if (diffMin < 1) {
return `${diffSec}s ago`;
}
if (diffHour < 1) {
return `${diffMin}m ago`;
}
if (diffDay < 1) {
return `${diffHour}h ago`;
}
if (diffDay < 7) {
return `${diffDay}d ago`;
}
if (diffSec < 10) return { kind: "now" };
if (diffMin < 1) return { kind: "elapsed", value: `${diffSec}s` };
if (diffHour < 1) return { kind: "elapsed", value: `${diffMin}m` };
if (diffDay < 1) return { kind: "elapsed", value: `${diffHour}h` };
if (diffDay < 7) return { kind: "elapsed", value: `${diffDay}d` };
// For older dates, show abbreviated month and day
const month = date.toLocaleDateString("en-US", { month: "short" });
const day = date.getDate();
return `${month} ${day}`;
return { kind: "date", value: `${month} ${date.getDate()}` };
}
/**
* Format a date as a human-friendly relative time string
* Examples: "just now", "5m ago", "2h ago", "3d ago", "Jan 15"
*/
export function formatTimeAgo(date: Date, now: Date = new Date()): string {
const elapsed = describeElapsed(date, now);
if (elapsed.kind === "now") return "just now";
if (elapsed.kind === "elapsed") return `${elapsed.value} ago`;
return elapsed.value;
}
/**
* How often a compact label can change, which is all a caller needs to know to keep it honest.
* `static` means it never will again.
*/
export type RelativeTimeResolution = "minute" | "hour" | "day" | "static";
export interface CompactTimeAgo {
label: string;
resolution: RelativeTimeResolution;
}
const MINUTE_MS = 60_000;
const HOUR_MS = 60 * MINUTE_MS;
const DAY_MS = 24 * HOUR_MS;
/** Past a week the elapsed count stops meaning anything; the date itself is more use. */
const ABSOLUTE_AFTER_MS = 7 * DAY_MS;
/**
* The same instant with the prose removed, for somewhere with no room for it — a dense list
* where the column is understood to be a timestamp and "ago" is the only word on the line.
* Examples: "now", "5m", "2h", "3d", "Jan 15".
*
* Deliberately never sub-minute. A seconds label is only correct for the second it was rendered,
* so it either lies or forces a once-a-second re-render of a list that has nothing new to say.
* Everything under a minute is "now", which is both true and stable.
*
* The resolution comes back with the label so a caller can wake at the rate the label actually
* changes instead of guessing — see `useCompactTimeAgo`.
*/
export function describeCompactTimeAgo(date: Date, now: Date = new Date()): CompactTimeAgo {
const elapsedMs = now.getTime() - date.getTime();
if (elapsedMs < MINUTE_MS) {
return { label: "now", resolution: "minute" };
}
if (elapsedMs < HOUR_MS) {
return { label: `${Math.floor(elapsedMs / MINUTE_MS)}m`, resolution: "minute" };
}
if (elapsedMs < DAY_MS) {
return { label: `${Math.floor(elapsedMs / HOUR_MS)}h`, resolution: "hour" };
}
if (elapsedMs < ABSOLUTE_AFTER_MS) {
return { label: `${Math.floor(elapsedMs / DAY_MS)}d`, resolution: "day" };
}
const month = date.toLocaleDateString("en-US", { month: "short" });
return { label: `${month} ${date.getDate()}`, resolution: "static" };
}
export function formatCompactTimeAgo(date: Date, now: Date = new Date()): string {
return describeCompactTimeAgo(date, now).label;
}
function isSameLocalDay(a: Date, b: Date): boolean {