mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1066ebb891 | ||
|
|
f821f1eca4 | ||
|
|
b49abdc5dc | ||
|
|
082d9c0e4a | ||
|
|
d84c0a8420 | ||
|
|
721b6a10dc | ||
|
|
40ae6447ba | ||
|
|
c73b03a168 | ||
|
|
20789bcd13 | ||
|
|
7f4eb38fc0 | ||
|
|
644f5ee941 |
@@ -34,7 +34,6 @@ 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 |
|
||||
|
||||
@@ -80,7 +80,6 @@ Paseo starts locally, then asks whether to enable the end-to-end encrypted relay
|
||||
For full setup and configuration, see:
|
||||
|
||||
- [Docs](https://paseo.sh/docs)
|
||||
- [Connectivity guide](https://paseo.sh/docs/connectivity)
|
||||
- [Configuration reference](https://paseo.sh/docs/configuration)
|
||||
|
||||
### Docker
|
||||
|
||||
@@ -111,7 +111,7 @@ Running provider-native subagents contribute `running` to the workspace owned by
|
||||
|
||||
## The subagents track
|
||||
|
||||
The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children:
|
||||
One collapsible section of the composer track lane in an agent's pane (`packages/app/src/subagents/track.tsx`), stacked with any other track the agent has. It combines two kinds of children:
|
||||
|
||||
- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is:
|
||||
|
||||
|
||||
+5
-12
@@ -280,18 +280,11 @@ for machine-readable output.
|
||||
|
||||
## Worktree starting refs
|
||||
|
||||
A new worktree starts from the current branch's upstream, or the local branch when it has no
|
||||
upstream. This keeps unpushed local commits out of new workspaces by default. The picker collapses
|
||||
identical refs; divergent local or non-origin refs remain explicit, qualified choices.
|
||||
|
||||
The daemon sends the exact upstream ref because the remote and branch names cannot be inferred.
|
||||
Worktrees retain that ref for comparisons and updates from base while exposing its branch name to
|
||||
the UI. Merging into base requires a mutable local target: `origin/main` maps to local `main`, while
|
||||
another remote fails closed until the worktree records an explicit local target. Older daemons omit
|
||||
the optional field and retain the previous local-first behavior; older worktree metadata without the
|
||||
exact ref also resolves through its stored branch name.
|
||||
|
||||
Worktrees inherit committed Git state only; uncommitted source-checkout changes are not copied.
|
||||
The New workspace branch picker treats local and `origin/*` refs as distinct starting points when
|
||||
they differ. Local `main` is sent as `refs/heads/main`; remote `origin/main` is sent as
|
||||
`refs/remotes/origin/main`. Matching local/remote refs collapse to one local row. Unqualified refs
|
||||
from older clients resolve local-first, then fall back to `origin`. Worktrees inherit committed Git
|
||||
state only; uncommitted source-checkout changes are not copied.
|
||||
|
||||
## paseo.json service scripts
|
||||
|
||||
|
||||
+12
-24
@@ -2,32 +2,20 @@
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
- `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`
|
||||
|
||||
## Adding a new icon
|
||||
|
||||
|
||||
+1
-31
@@ -124,13 +124,8 @@ quietly relying on:
|
||||
renders inside its host, not necessarily at window origin. Position anchored
|
||||
content relative to the host: `anchorRect - hostRect`. This is what
|
||||
`measureFloatingPanelPortalHost()` is for.
|
||||
- **React context.** `@gorhom/portal` is not a React portal — a real one keeps
|
||||
context, this one does not. It stores the element and the host renders it, so
|
||||
context resolves at the _host's_ position. Everything provided between
|
||||
`PortalProvider` in `app/_layout.tsx` and your sheet is invisible inside it.
|
||||
This is why app-wide providers wrap `PortalProvider` rather than the reverse.
|
||||
|
||||
The fix for transforms is Gotcha 3. The fix for context is Gotcha 7.
|
||||
The fix for transforms is Gotcha 3.
|
||||
|
||||
## Gotcha 3 — Reanimated transforms vs `measureInWindow`
|
||||
|
||||
@@ -241,28 +236,6 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked
|
||||
another pushed sheet. Close React state from `onDismiss`; use `onChange` only to
|
||||
track phase.
|
||||
|
||||
## Gotcha 7 — A sheet cannot read context from its call site
|
||||
|
||||
React cannot copy contexts reflectively, so the only way across the teleport in
|
||||
Gotcha 2 is to render the providers a second time, with values captured on the
|
||||
near side where they are still readable. `IsolatedBottomSheetModal` takes a
|
||||
`contextBridge` for exactly that:
|
||||
|
||||
```tsx
|
||||
const contextBridge = useCallback<ContextBridge>(
|
||||
(content) => <ThingContext.Provider value={thing}>{content}</ThingContext.Provider>,
|
||||
[thing],
|
||||
);
|
||||
```
|
||||
|
||||
The prop is **required**, and `null` is a real answer. A sheet whose content
|
||||
needs nothing local should have to say so, because the failure mode is silent
|
||||
until someone adds a `useContext` deep inside and it throws on device only —
|
||||
never on web, where the desktop path uses a real portal. `menu-surface.tsx`
|
||||
bridges the menu's two contexts; the rest pass `null`.
|
||||
|
||||
Wrapping providers _around_ the modal does nothing. They land on the wrong side.
|
||||
|
||||
## Recipe for a new anchored panel
|
||||
|
||||
Before you write a new one, ask:
|
||||
@@ -285,6 +258,3 @@ 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).
|
||||
|
||||
+3
-3
@@ -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 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.
|
||||
- **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.
|
||||
- **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`).
|
||||
@@ -30,7 +30,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Tab** — UI surface representing one session inside a workspace. Not a conceptual unit; use **Agent session** when talking about the model. Code: `WorkspaceTabDescriptor` (`packages/app/src/screens/workspace/workspace-tabs-types.ts`).
|
||||
- **Terminal** — Workspace-scoped PTY shell streamed over the binary mux channel. UI: "Terminal". Code: `TerminalStreamFrame` (`packages/protocol/src/terminal-stream-protocol.ts`).
|
||||
- **Schedule** — Cron-style trigger that creates new agents. UI: CLI/MCP (`paseo schedule`, `create_schedule`). Don't confuse with: Heartbeat (cron prompt back into the same agent) or Loop (iterative re-execution of one agent).
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline.
|
||||
- **Heartbeat** — Ephemeral cron prompt sent back into the same agent/conversation. Agent surfaces expose create, update cron, and delete only. Use for reminders and babysitting where status should return inline. The agent's **Heartbeats track** lists the ones still on a cadence for that agent on that host; opening a row goes to it on Schedules (`/schedules?serverId=…&scheduleId=…`, keyed by both because schedule ids are host-local). Paused, completed, and expired heartbeats stay on Schedules and leave the track. A host without `heartbeatWorkspaceActivity` still runs the prompts but does not report them as workspace activity, so the track links to host settings instead of contradicting the workspace status.
|
||||
- **Mode** — Provider-specific operational mode (plan, default, full-access, …). UI: icon-only. Code: `modeId` in `AgentSessionConfig` (`packages/protocol/src/messages.ts:257`).
|
||||
- **Attachment** — External or local context bound to an agent prompt: forge issue/change request, review context, uploaded file, text, or image. UI: "Attach issue or PR/MR". Code: `AgentAttachment` (`packages/protocol/src/messages.ts:782`).
|
||||
- **Composer** — The whole prompt surface for sending work to an agent. Code: `Composer` (`packages/app/src/composer/index.tsx`). Don't call this "message input" except for the text-entry subcomponent.
|
||||
@@ -38,7 +38,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Composer toolbar** — The bottom control row inside the composer input. Contains agent controls, attachment button, voice controls, and stop/send controls. Code: `leftContent`, `beforeVoiceContent`, and `rightContent` slots in `MessageInput` (`packages/app/src/composer/input/input.tsx`). Forbidden: "Status bar".
|
||||
- **Agent controls** — Provider, model, mode, thinking, and provider-feature controls for an agent or draft agent. Code: `AgentControls` / `DraftAgentControls` (`packages/app/src/composer/agent-controls/index.tsx`). Forbidden: "Agent status bar".
|
||||
- **Composer footer** — Optional area rendered below the composer input but still inside the keyboard-shifted composer layout. Code: `Composer.footer` (`packages/app/src/composer/index.tsx`).
|
||||
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Subagents track**. Code: queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
|
||||
- **Composer track** — A contextual lane above the composer input. Specific tracks use the `<thing> track` form: **Queue track**, **Heartbeats track**, **Subagents track**. Tracks above the input are sections of one card so several can stack without either one owning the seam with the composer. Code: `ComposerTracks` / `ComposerTrackSection` (`packages/app/src/composer/tracks.tsx`), queue track inside `Composer` (`packages/app/src/composer/index.tsx`), `HeartbeatsTrack` (`packages/app/src/heartbeats/track.tsx`), `SubagentsTrack` (`packages/app/src/subagents/track.tsx`).
|
||||
- **Subagent** — User-facing term for an agent session related to a parent agent session. Use **subagents** in UI copy and docs. Internal daemon/provider plumbing may say "child agent" or `child_session`, especially for provider-managed imports; do not surface "child agent" as a product term.
|
||||
- **Attachment tray** — The selected-attachments row inside the composer input, above the text input. Code: `renderAttachmentTray` (`packages/app/src/composer/index.tsx`). Forbidden: "Attachment bar".
|
||||
- **Fork** — Starting a NEW agent session seeded with a copy of another session's curated chat history. The history rides along as a `chat_history` composer attachment on a draft; the fork does **not** resume or branch the provider-side session, and the source session keeps running untouched. Two targets, one surface, one RPC (`agent.fork_context`, gated on the `agentForkContext` host feature). UI: the fork menu lives in the assistant turn footer and offers "Fork in a new tab" / "Fork in a new workspace". On a **completed** turn it sits beside the copy button and pins a boundary to that turn, so the fork stops where the turn ended. On the **in-flight** turn it sits beside the progress loader and elapsed timer, and omits the boundary entirely — `selectForkContextRows` projects the whole timeline when no boundary is given, so the fork captures everything **up to now**, including the response still streaming. That omission is what makes mid-run forking work; pinning a boundary there would silently drop the live response. Forbidden on provider-subagent (read-only) panes. Code: `useForkAgent` (`packages/app/src/hooks/use-fork-agent.ts`), `selectForkContextRows` (`packages/server/src/server/agent/activity-curator.ts`). Don't confuse with: git fork (unrelated; Paseo has no such concept), **Worktree** (filesystem isolation, not history copying).
|
||||
|
||||
-129
@@ -1,129 +0,0 @@
|
||||
# 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.
|
||||
|
||||
`ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press.
|
||||
Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead.
|
||||
|
||||
## Pages
|
||||
|
||||
A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
|
||||
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.
|
||||
- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds
|
||||
both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts
|
||||
them on the wrong side of the portal and every item inside throws. Gotcha 7 in
|
||||
[floating-panels.md](floating-panels.md).
|
||||
- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
|
||||
backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
|
||||
- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
|
||||
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).
|
||||
+8
-10
@@ -58,8 +58,7 @@ The daemon validates that the epoch is current and the exact source sequence sti
|
||||
|
||||
## Resume behavior
|
||||
|
||||
Opening, reconnecting, or revisiting after a selective-delivery coverage gap fetches the latest tail
|
||||
page.
|
||||
Opening, reconnecting, or revisiting after selective-delivery grace fetches the latest tail page.
|
||||
Focus alone does not mutate timeline state; the tail response is compared with the local
|
||||
authoritative range first.
|
||||
|
||||
@@ -107,14 +106,13 @@ replica cache.
|
||||
|
||||
The app chooses one delivery policy from `server_info.features.selectiveAgentTimeline`:
|
||||
|
||||
- Selective daemons receive every agent visible in any pane plus the most recently viewed hidden
|
||||
agents, up to five subscribed agents. Visible agents always win: if more than five are visible,
|
||||
they all remain subscribed and no hidden agent does. Switching and app backgrounding preserve
|
||||
this connection-scoped hot set, so returning to an agent still covered by it needs no catch-up.
|
||||
Losing window keyboard focus does not make a selected pane invisible. Disconnecting clears hidden
|
||||
hot agents; reconnect restores the currently visible set before authoritative catch-up. Revisiting
|
||||
an evicted retained timeline displays its cached state immediately while authoritative catch-up
|
||||
advances it to the current tail.
|
||||
- Selective daemons receive the union of agents visible in every pane. Additions subscribe and
|
||||
catch up immediately. Every visibility-driven removal, including app backgrounding, stays
|
||||
subscribed for a 30-second grace period so brief tab, pane, route, and app switches do not repeatedly
|
||||
unsubscribe and catch up. Losing window keyboard focus does not make a selected pane invisible.
|
||||
Disconnecting and disposal clear pending grace because the subscription itself no longer exists.
|
||||
After grace has expired, revisiting a retained timeline displays its cached state immediately and
|
||||
authoritative catch-up advances it to the current tail.
|
||||
- Legacy daemons keep globally streaming agent timelines. Visibility still triggers the existing
|
||||
authoritative catch-up, but the app does not issue selective-subscription RPCs.
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
captureHeartbeatEvidence,
|
||||
deleteHeartbeat,
|
||||
expectHeartbeatDetails,
|
||||
expectHeartbeatVisible,
|
||||
expectWorkspaceDone,
|
||||
expectWorkspaceRunning,
|
||||
openHeartbeat,
|
||||
openHeartbeatAndSubagentTracks,
|
||||
openHeartbeatTrack,
|
||||
removeSeededSubagent,
|
||||
returnToAgentHeartbeat,
|
||||
seedAgentWithHeartbeat,
|
||||
} from "../support/helpers/heartbeats";
|
||||
|
||||
test("an agent heartbeat stays visible and keeps its workspace running", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(150_000);
|
||||
const scenario = await seedAgentWithHeartbeat();
|
||||
|
||||
try {
|
||||
await openAgentRoute(page, {
|
||||
workspaceId: scenario.workspace.workspaceId,
|
||||
agentId: scenario.parent.id,
|
||||
});
|
||||
await expectWorkspaceDone(page);
|
||||
const heartbeat = await scenario.createHeartbeat();
|
||||
await expectWorkspaceRunning(page);
|
||||
await openHeartbeatAndSubagentTracks(page, heartbeat, scenario.child.id);
|
||||
await expectHeartbeatVisible(page, heartbeat);
|
||||
await captureHeartbeatEvidence(page, testInfo, "heartbeat-stacked-tracks");
|
||||
|
||||
await openHeartbeat(page, heartbeat);
|
||||
await expectHeartbeatDetails(page, heartbeat);
|
||||
await captureHeartbeatEvidence(page, testInfo, "heartbeat-schedule-details");
|
||||
|
||||
await removeSeededSubagent(scenario);
|
||||
await returnToAgentHeartbeat(page);
|
||||
await expectWorkspaceRunning(page);
|
||||
await openHeartbeatTrack(page, heartbeat);
|
||||
await deleteHeartbeat(page, heartbeat);
|
||||
await expect
|
||||
.poll(() => scenario.readHeartbeatExists(heartbeat.id), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
await expect(scenario.readAgentStatuses()).resolves.toMatchObject({
|
||||
[scenario.parent.id]: "idle",
|
||||
});
|
||||
await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done");
|
||||
await expectWorkspaceDone(page);
|
||||
await captureHeartbeatEvidence(page, testInfo, "heartbeat-removed-workspace-done");
|
||||
|
||||
const expiringHeartbeat = await scenario.createHeartbeat({ name: "Temporary watch" });
|
||||
await expectWorkspaceRunning(page);
|
||||
await openHeartbeatTrack(page, expiringHeartbeat);
|
||||
await expectHeartbeatVisible(page, expiringHeartbeat);
|
||||
await scenario.setHeartbeatExpiry(
|
||||
expiringHeartbeat.id,
|
||||
new Date(Date.now() + 20_000).toISOString(),
|
||||
);
|
||||
await expect(page.getByRole("button", { name: "1 heartbeat" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done");
|
||||
await expectWorkspaceDone(page);
|
||||
} finally {
|
||||
await scenario.cleanup().catch(() => undefined);
|
||||
}
|
||||
});
|
||||
@@ -1,236 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
readScrollMetrics,
|
||||
scrollChatAwayFromBottom,
|
||||
} from "../support/helpers/agent-bottom-anchor";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { seedMockAgentWorkspace, type MockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
import {
|
||||
continueToNextQuestion,
|
||||
fillQuestionAnswer,
|
||||
submitQuestionAnswers,
|
||||
waitForQuestionPrompt,
|
||||
} from "../support/helpers/questions";
|
||||
import { openMobileAgentSidebar } from "../support/helpers/sidebar";
|
||||
import {
|
||||
expectTimelinePromptVisible,
|
||||
openAgentTimeline,
|
||||
seedLongMockAgentTimeline,
|
||||
} from "../support/helpers/timeline-pagination";
|
||||
import { switchWorkspaceViaSidebar } from "../support/helpers/workspace-ui";
|
||||
|
||||
async function expectChatAtBottom(page: Page): Promise<void> {
|
||||
await expect
|
||||
.poll(async () => (await readScrollMetrics(page)).distanceFromBottom, {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toBeLessThanOrEqual(72);
|
||||
}
|
||||
|
||||
async function openCompactWorkspace(
|
||||
page: Page,
|
||||
workspace: Pick<MockAgentWorkspace, "workspaceId">,
|
||||
): Promise<void> {
|
||||
await openMobileAgentSidebar(page);
|
||||
await switchWorkspaceViaSidebar({
|
||||
page,
|
||||
serverId: getServerId(),
|
||||
workspaceId: workspace.workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
function armDelayedScrollForHiddenChat(page: Page): Promise<void> {
|
||||
return page
|
||||
.locator('[data-testid="agent-chat-scroll"]:visible')
|
||||
.first()
|
||||
.evaluate((root) => {
|
||||
const scroll = root as HTMLElement;
|
||||
return new Promise<void>((resolve) => {
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (scroll.getClientRects().length > 0) return;
|
||||
observer.disconnect();
|
||||
scroll.dispatchEvent(new Event("scroll"));
|
||||
resolve();
|
||||
});
|
||||
observer.observe(scroll);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("pointer jitter cannot turn geometry changes into scroll intent", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const backgroundPrompt = "Continue after a pointer is released outside the chat";
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
|
||||
const scroll = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||
const bounds = await scroll.boundingBox();
|
||||
expect(bounds, "Expected visible chat scroll bounds").not.toBeNull();
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2 - 3);
|
||||
await page.setViewportSize({ width: 390, height: 1124 });
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.mouse.move(410, bounds!.y + bounds!.height / 2 - 3);
|
||||
await page.mouse.up();
|
||||
|
||||
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
|
||||
await longChat.client.waitForFinish(longChat.agentId, 20_000);
|
||||
await expectTimelinePromptVisible(page, backgroundPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
} finally {
|
||||
await longChat.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a retained chat ignores delayed scroll delivery while hidden", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const otherWorkspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "scroll-return-hidden-other-",
|
||||
title: "Other workspace",
|
||||
initialPrompt: "Open the other workspace",
|
||||
});
|
||||
const backgroundPrompt = "emit 500 coalesced agent stream updates";
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
|
||||
const delayedScrollDelivered = armDelayedScrollForHiddenChat(page);
|
||||
await openCompactWorkspace(page, otherWorkspace);
|
||||
await delayedScrollDelivered;
|
||||
await longChat.client.sendAgentMessage(longChat.agentId, backgroundPrompt);
|
||||
await longChat.client.waitForFinish(longChat.agentId, 20_000);
|
||||
|
||||
await openCompactWorkspace(page, longChat);
|
||||
await expectTimelinePromptVisible(page, backgroundPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
} finally {
|
||||
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a retained streaming chat returns to the bottom and continues following", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const otherWorkspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "scroll-return-streaming-other-",
|
||||
title: "Other workspace",
|
||||
initialPrompt: "Open the other workspace",
|
||||
});
|
||||
const streamingPrompt = "Continue streaming while this workspace is hidden";
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
|
||||
await longChat.client.sendAgentMessage(longChat.agentId, streamingPrompt);
|
||||
await longChat.client.waitForAgentUpsert(
|
||||
longChat.agentId,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
15_000,
|
||||
);
|
||||
await openCompactWorkspace(page, otherWorkspace);
|
||||
await openCompactWorkspace(page, longChat);
|
||||
|
||||
await expectTimelinePromptVisible(page, streamingPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
await longChat.client.waitForFinish(longChat.agentId, 20_000);
|
||||
await expectChatAtBottom(page);
|
||||
} finally {
|
||||
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("a retained chat preserves an intentional reading position", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const otherWorkspace = await seedMockAgentWorkspace({
|
||||
repoPrefix: "scroll-return-reading-position-other-",
|
||||
title: "Other workspace",
|
||||
initialPrompt: "Open the other workspace",
|
||||
});
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
const readingPosition = await scrollChatAwayFromBottom(page, {
|
||||
deltaY: -900,
|
||||
minDistanceFromBottom: 300,
|
||||
});
|
||||
|
||||
await openCompactWorkspace(page, otherWorkspace);
|
||||
await openCompactWorkspace(page, longChat);
|
||||
|
||||
await expect
|
||||
.poll(async () => Math.abs((await readScrollMetrics(page)).offsetY - readingPosition.offsetY))
|
||||
.toBeLessThanOrEqual(24);
|
||||
} finally {
|
||||
await Promise.allSettled([longChat.cleanup(), otherWorkspace.cleanup()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("editing keys in an embedded question keep output following", async ({ page }) => {
|
||||
test.setTimeout(240_000);
|
||||
const longChat = await seedLongMockAgentTimeline({ turns: 30 });
|
||||
const questionPrompt = "Emit synthetic questions: two free-write questions.";
|
||||
const firstQuestion = "What is the GitHub private repo URL to push to?";
|
||||
const secondQuestion = "What should the first commit message be?";
|
||||
|
||||
try {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await openAgentTimeline(page, longChat);
|
||||
await expectTimelinePromptVisible(page, longChat.newestPrompt);
|
||||
await expectChatAtBottom(page);
|
||||
|
||||
await longChat.client.sendAgentMessage(longChat.agentId, questionPrompt);
|
||||
await waitForQuestionPrompt(page, 30_000);
|
||||
await fillQuestionAnswer(page, {
|
||||
question: firstQuestion,
|
||||
answer: "git@github.com:user/private-repo.git",
|
||||
});
|
||||
const firstInput = page
|
||||
.getByTestId("question-form-card")
|
||||
.first()
|
||||
.getByRole("textbox", { name: firstQuestion });
|
||||
const inputBounds = await firstInput.boundingBox();
|
||||
expect(inputBounds, "Expected embedded question input bounds").not.toBeNull();
|
||||
await page.mouse.move(
|
||||
inputBounds!.x + inputBounds!.width / 2,
|
||||
inputBounds!.y + inputBounds!.height / 2,
|
||||
);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(
|
||||
inputBounds!.x + inputBounds!.width / 2 + 20,
|
||||
inputBounds!.y + inputBounds!.height / 2 - 3,
|
||||
);
|
||||
await page.mouse.up();
|
||||
await firstInput.press("Home");
|
||||
await firstInput.press("ArrowUp");
|
||||
await continueToNextQuestion(page);
|
||||
await fillQuestionAnswer(page, {
|
||||
question: secondQuestion,
|
||||
answer: "Initialize private repo",
|
||||
});
|
||||
await submitQuestionAnswers(page);
|
||||
await longChat.client.waitForFinish(longChat.agentId, 20_000);
|
||||
|
||||
await expectChatAtBottom(page);
|
||||
} finally {
|
||||
await longChat.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
expectActiveChatOutlinePrompt,
|
||||
expectChatOutlinePreview,
|
||||
expectChatOutlinePrompts,
|
||||
expectChatOutlineAlignedWithActiveTabGlyph,
|
||||
expectChatOutlinePromptToRemainBare,
|
||||
expectLiveTurnPromptAboveFoldAndActive,
|
||||
expectNoChatOutlinePreviewWhileCrossingToSidebar,
|
||||
@@ -139,6 +140,10 @@ test.describe("desktop chat outline", () => {
|
||||
await expectChatOutlinePromptToRemainBare(page, 4);
|
||||
});
|
||||
|
||||
test("aligns the ticks with the active tab glyph rail", async ({ page }) => {
|
||||
await expectChatOutlineAlignedWithActiveTabGlyph(page);
|
||||
});
|
||||
|
||||
test("clamps the newest prompt at maximum scroll while keeping it visible", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
+185
-19
@@ -201,29 +201,41 @@ test.afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("changes file actions open below the right-click without a reserved kebab", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("changes diff keeps code rows aligned with the gutter", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 9);
|
||||
await useUnwrappedDiffLines(page);
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
await expectDiffCodeFontSize(page, 9);
|
||||
await expectVisibleDiffRowsAligned(page);
|
||||
await expectDiffCodeTextAlignedWithGutterText(page, [
|
||||
{
|
||||
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
|
||||
lineNumber: "20",
|
||||
},
|
||||
{ codeText: "return next;", lineNumber: "55" },
|
||||
{ codeText: "useLayoutEffect(() => {", lineNumber: "78" },
|
||||
]);
|
||||
await expectHoverCommentButtonAlignedWithCodeLine(page, {
|
||||
codeText: "function createInitialMountedTabIds(input: UseMountedTabSetInput)",
|
||||
lineNumber: "20",
|
||||
});
|
||||
});
|
||||
|
||||
test("changes file actions open from the kebab and right-click", 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 expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
|
||||
await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
|
||||
await page.getByTestId("diff-file-1-actions").click();
|
||||
await expect(page.getByText("Copy path")).toBeVisible();
|
||||
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
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 page.getByTestId("diff-file-0-toggle").click({ button: "right" });
|
||||
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();
|
||||
@@ -291,7 +303,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.getByRole("img", { name: "Deleted" })).toBeVisible();
|
||||
await expect(visiblePanel).toContainText("Deleted");
|
||||
|
||||
await changesTabToggle.click();
|
||||
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
|
||||
@@ -328,9 +340,6 @@ 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);
|
||||
@@ -344,7 +353,56 @@ test("changes diff switches between flat and tree file lists", async ({ page })
|
||||
await expectFlatFileList(page);
|
||||
});
|
||||
|
||||
test("changes diff applies code size changes to gutter and code typography", async ({ page }) => {
|
||||
test("workspace file panes keep their controls on shared alignment rails", async ({ page }) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await openWorkspaceChanges(page, workspace);
|
||||
|
||||
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);
|
||||
|
||||
const [folderStat, fileStat] = await Promise.all([
|
||||
page.getByTestId("diff-folder-src-stat").boundingBox(),
|
||||
page.getByTestId("diff-file-0-stat").boundingBox(),
|
||||
]);
|
||||
expect(folderStat).not.toBeNull();
|
||||
expect(fileStat).not.toBeNull();
|
||||
expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0);
|
||||
|
||||
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);
|
||||
|
||||
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
|
||||
page.getByTestId("files-sort-label").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(),
|
||||
page.getByTestId("file-explorer-tree-scroll").boundingBox(),
|
||||
page.getByTestId("file-explorer-row-0").boundingBox(),
|
||||
]);
|
||||
expect(sortLabel).not.toBeNull();
|
||||
expect(firstRowIcon).not.toBeNull();
|
||||
expect(treeBounds).not.toBeNull();
|
||||
expect(rowBounds).not.toBeNull();
|
||||
expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0);
|
||||
expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0);
|
||||
expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0);
|
||||
});
|
||||
|
||||
test("changes diff keeps unwrapped gutter and code rows aligned after code size changes", async ({
|
||||
page,
|
||||
}) => {
|
||||
const workspace = await createWorkspaceWithMountedTabDiff();
|
||||
await useCodeFont(page, 12);
|
||||
await useUnwrappedDiffLines(page);
|
||||
@@ -357,6 +415,7 @@ test("changes diff applies code size changes to gutter and code typography", asy
|
||||
|
||||
await expectDiffCodeFontSize(page, 18);
|
||||
await expectVisibleDiffRowsShareTypography(page);
|
||||
await expectVisibleDiffRowsAligned(page);
|
||||
});
|
||||
|
||||
async function useCodeFont(page: Page, codeFontSize: number): Promise<void> {
|
||||
@@ -417,17 +476,24 @@ async function expectDiffCodeFontSize(page: Page, fontSize: number): Promise<voi
|
||||
.toBe(fontSize);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsAligned(page: Page): Promise<void> {
|
||||
const geometry = await readVisibleDiffRowGeometry(page);
|
||||
expect(geometry.maxDelta, JSON.stringify(geometry.rows, null, 2)).toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
async function expectVisibleDiffRowsShareTypography(page: Page): Promise<void> {
|
||||
const geometry = await readVisibleDiffRowGeometry(page);
|
||||
expect(geometry.mismatchedTypography, JSON.stringify(geometry, null, 2)).toEqual([]);
|
||||
}
|
||||
|
||||
async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
maxDelta: number;
|
||||
mismatchedTypography: { index: number; gutterLineHeight: number; codeLineHeight: number }[];
|
||||
rows: {
|
||||
index: number;
|
||||
gutterTop: number;
|
||||
codeTop: number;
|
||||
delta: number;
|
||||
gutterLineHeight: number;
|
||||
codeLineHeight: number;
|
||||
}[];
|
||||
@@ -462,6 +528,7 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
index: code.index,
|
||||
gutterTop: gutter.top,
|
||||
codeTop: code.top,
|
||||
delta: Math.abs(code.top - gutter.top),
|
||||
gutterLineHeight: gutter.lineHeight,
|
||||
codeLineHeight: code.lineHeight,
|
||||
};
|
||||
@@ -469,6 +536,7 @@ async function readVisibleDiffRowGeometry(page: Page): Promise<{
|
||||
.filter((row) => row.gutterTop >= 0 && row.codeTop >= 0);
|
||||
|
||||
return {
|
||||
maxDelta: Math.max(...rows.map((row) => row.delta)),
|
||||
mismatchedTypography: rows
|
||||
.filter((row) => Math.abs(row.gutterLineHeight - row.codeLineHeight) > 0.5)
|
||||
.map((row) => ({
|
||||
@@ -488,7 +556,7 @@ async function createWorkspaceWithMountedTabDiff(
|
||||
if (options.includeDeletedFile) {
|
||||
files.push({ path: "src/zz-deleted.ts", content: "export const deleted = true;\n" });
|
||||
}
|
||||
const repo = await createTempGitRepo("changes-pane-", { files });
|
||||
const repo = await createTempGitRepo("diff-row-alignment-", { files });
|
||||
const client = await connectSeedClient();
|
||||
cleanupTasks.push({
|
||||
run: async () => {
|
||||
@@ -525,6 +593,21 @@ 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({
|
||||
@@ -577,3 +660,86 @@ async function scrollToLowerUnwrappedDiffRows(page: Page): Promise<void> {
|
||||
await page.getByTestId(`diff-code-row-${lastRowIndex}`).scrollIntoViewIfNeeded();
|
||||
await expect(page.getByTestId(`diff-code-row-${lastRowIndex}`)).toBeVisible();
|
||||
}
|
||||
|
||||
async function expectDiffCodeTextAlignedWithGutterText(
|
||||
page: Page,
|
||||
lines: { codeText: string; lineNumber: string }[],
|
||||
): Promise<void> {
|
||||
const geometries = await readDiffTextGeometry(page, lines);
|
||||
for (const geometry of geometries) {
|
||||
expect(geometry.codeTop, geometry.codeText).toBeCloseTo(geometry.gutterTop, 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function expectHoverCommentButtonAlignedWithCodeLine(
|
||||
page: Page,
|
||||
line: { codeText: string; lineNumber: string },
|
||||
): Promise<void> {
|
||||
const target = await readDiffTextGeometry(page, [line]).then((rows) => rows[0]);
|
||||
if (!target) {
|
||||
throw new Error(`Could not find target line ${line.lineNumber}`);
|
||||
}
|
||||
await page.getByTestId(`diff-code-row-${target.index}`).hover();
|
||||
const geometry = await page
|
||||
.getByTestId(`diff-gutter-action-${target.index}`)
|
||||
.evaluate((action, expectedCodeCenterY) => {
|
||||
const rect = action.getBoundingClientRect();
|
||||
return {
|
||||
actionCenterY: rect.top + rect.height / 2,
|
||||
codeCenterY: expectedCodeCenterY,
|
||||
};
|
||||
}, target.codeCenterY);
|
||||
expect(geometry.actionCenterY).toBeCloseTo(geometry.codeCenterY, 0);
|
||||
}
|
||||
|
||||
async function readDiffTextGeometry(
|
||||
page: Page,
|
||||
lines: { codeText: string; lineNumber: string }[],
|
||||
): Promise<
|
||||
{ index: number; codeText: string; codeTop: number; gutterTop: number; codeCenterY: number }[]
|
||||
> {
|
||||
return page.locator("body").evaluate(({ ownerDocument }, targets) => {
|
||||
const root = ownerDocument.querySelector('[data-testid="explorer-content-area"]');
|
||||
if (!root) {
|
||||
throw new Error("Changes panel is not mounted");
|
||||
}
|
||||
|
||||
const readIndexedElements = (prefix: string) =>
|
||||
Array.from(root.querySelectorAll<HTMLElement>(`[data-testid^="${prefix}"]`)).map(
|
||||
(element) => {
|
||||
const testId = element.getAttribute("data-testid") ?? "";
|
||||
return { index: Number(testId.slice(prefix.length)), element };
|
||||
},
|
||||
);
|
||||
|
||||
const gutterTexts = readIndexedElements("diff-gutter-text-");
|
||||
const codeTexts = readIndexedElements("diff-code-text-");
|
||||
|
||||
return targets.map((target) => {
|
||||
const gutter = gutterTexts.find(
|
||||
({ element }) => (element.textContent ?? "").trim() === target.lineNumber,
|
||||
);
|
||||
if (!gutter) {
|
||||
throw new Error(`Could not find gutter line ${target.lineNumber}`);
|
||||
}
|
||||
const code = codeTexts.find(
|
||||
({ index, element }) =>
|
||||
index === gutter.index && (element.textContent ?? "").includes(target.codeText),
|
||||
);
|
||||
if (!code) {
|
||||
throw new Error(`Could not find code row ${target.codeText}`);
|
||||
}
|
||||
|
||||
const codeRect = code.element.getBoundingClientRect();
|
||||
const gutterRect = gutter.element.getBoundingClientRect();
|
||||
|
||||
return {
|
||||
index: gutter.index,
|
||||
codeText: target.codeText,
|
||||
codeTop: codeRect.top,
|
||||
gutterTop: gutterRect.top,
|
||||
codeCenterY: codeRect.top + codeRect.height / 2,
|
||||
};
|
||||
});
|
||||
}, lines);
|
||||
}
|
||||
@@ -99,8 +99,11 @@ test.describe("Host appearance", () => {
|
||||
await expectHostBadgeName(page, { ...badge, hostName: "Build Box" });
|
||||
});
|
||||
|
||||
test("host names use the available metadata width", async ({ page, twoHostSidebar }) => {
|
||||
const hostName = "Developer MacBook Pro.local";
|
||||
test("long host names leave room for the workspace title and actions", async ({
|
||||
page,
|
||||
twoHostSidebar,
|
||||
}) => {
|
||||
const hostName = "A very long remote build host name that must not take over the sidebar";
|
||||
const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`;
|
||||
|
||||
await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId);
|
||||
@@ -110,18 +113,25 @@ test.describe("Host appearance", () => {
|
||||
const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`);
|
||||
const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`);
|
||||
await expect(badge).toHaveText(hostName);
|
||||
const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]);
|
||||
await row.hover();
|
||||
|
||||
const title = row.getByText("Secondary workspace", { exact: true });
|
||||
const kebab = page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`);
|
||||
await expect(kebab).toBeVisible();
|
||||
const [badgeBox, titleBox, rowBox, kebabBox] = await Promise.all([
|
||||
badge.boundingBox(),
|
||||
title.boundingBox(),
|
||||
row.boundingBox(),
|
||||
kebab.boundingBox(),
|
||||
]);
|
||||
|
||||
expect(badgeBox).not.toBeNull();
|
||||
expect(titleBox).not.toBeNull();
|
||||
expect(rowBox).not.toBeNull();
|
||||
await expect(badge).toHaveCSS("flex-shrink", "1");
|
||||
expect(badgeBox!.width).toBeGreaterThan(96);
|
||||
expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||
const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({
|
||||
clientWidth: label.clientWidth,
|
||||
scrollWidth: label.scrollWidth,
|
||||
}));
|
||||
expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth);
|
||||
expect(kebabBox).not.toBeNull();
|
||||
await expect(badge).toHaveCSS("flex-shrink", "0");
|
||||
expect(titleBox!.width).toBeGreaterThan(0);
|
||||
expect(kebabBox!.x + kebabBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width);
|
||||
});
|
||||
|
||||
test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => {
|
||||
|
||||
@@ -24,10 +24,6 @@ import {
|
||||
openProjectViaDaemon,
|
||||
openStartingRefPicker,
|
||||
pasteGithubPrUrl,
|
||||
captureStartingRefPicker,
|
||||
expectStartingRefRows,
|
||||
startingRefRow,
|
||||
submitNewWorkspaceEmpty,
|
||||
searchAndSelectBranchInPicker,
|
||||
selectBranchInPicker,
|
||||
selectGitHubPrInPicker,
|
||||
@@ -35,14 +31,7 @@ import {
|
||||
selectWorkspaceIsolation,
|
||||
submitNewWorkspacePrompt,
|
||||
} from "../support/helpers/new-workspace";
|
||||
import {
|
||||
commitLocalOnly,
|
||||
createTempGitRepo,
|
||||
readRepoRef,
|
||||
readWorktreeBaseMetadata,
|
||||
readWorktreeBranchInfo,
|
||||
trackForkUpstream,
|
||||
} from "../support/helpers/workspace";
|
||||
import { createTempGitRepo, readWorktreeBranchInfo } from "../support/helpers/workspace";
|
||||
import {
|
||||
createLocalGithubPrFixture,
|
||||
cloneGithubRepoDefaultBranchOnly,
|
||||
@@ -79,7 +68,6 @@ interface WorkspaceStatusGroupEvent {
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-group-done")).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
@@ -755,142 +743,6 @@ test.describe("New workspace flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
// The starting ref the daemon actually cuts from is the thing that broke: the picker said
|
||||
// one ref and the worktree was created from another. Every assertion here reads the
|
||||
// created worktree's commits or its recorded base, never the trigger text alone.
|
||||
test.describe("default starting ref", () => {
|
||||
async function openWorktreeComposerForRepo(
|
||||
page: import("@playwright/test").Page,
|
||||
repoPath: string,
|
||||
) {
|
||||
const openedProject = await openProjectViaDaemon(client, repoPath);
|
||||
localWorkspaceIds.add(openedProject.workspaceId);
|
||||
|
||||
await gotoAppShell(page);
|
||||
await waitForSidebarHydration(page);
|
||||
await openNewWorkspaceComposer(page, {
|
||||
projectKey: openedProject.projectKey,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
await selectWorkspaceIsolation(page, "worktree");
|
||||
return openedProject;
|
||||
}
|
||||
|
||||
async function createWorktreeAndRead(
|
||||
page: import("@playwright/test").Page,
|
||||
openedProject: Awaited<ReturnType<typeof openProjectViaDaemon>>,
|
||||
) {
|
||||
await submitNewWorkspaceEmpty(page);
|
||||
const createdWorkspace = await assertNewWorkspaceSidebarAndHeader(page, {
|
||||
serverId: getServerId(),
|
||||
client,
|
||||
previousWorkspaceId: openedProject.workspaceId,
|
||||
projectDisplayName: openedProject.projectDisplayName,
|
||||
});
|
||||
createdWorktreeDirectories.add(createdWorkspace.workspaceDirectory);
|
||||
return {
|
||||
...createdWorkspace,
|
||||
branchInfo: await readWorktreeBranchInfo({
|
||||
worktreePath: createdWorkspace.workspaceDirectory,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test("branches off the upstream when the local branch is ahead and the picker is untouched", async ({
|
||||
page,
|
||||
}) => {
|
||||
const tempRepo = await createTempGitRepo("ref-default-ahead-", { withRemote: true });
|
||||
|
||||
try {
|
||||
const originHead = readRepoRef(tempRepo.path, "refs/remotes/origin/main");
|
||||
commitLocalOnly(tempRepo.path, "one");
|
||||
const localHead = commitLocalOnly(tempRepo.path, "two");
|
||||
|
||||
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
|
||||
const created = await createWorktreeAndRead(page, openedProject);
|
||||
|
||||
expect(created.branchInfo.hasAncestor(originHead)).toBe(true);
|
||||
expect(created.branchInfo.hasAncestor(localHead)).toBe(false);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("branches off the local ref when the local row is chosen explicitly", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const tempRepo = await createTempGitRepo("ref-default-local-pick-", { withRemote: true });
|
||||
|
||||
try {
|
||||
commitLocalOnly(tempRepo.path, "one");
|
||||
const localHead = commitLocalOnly(tempRepo.path, "two");
|
||||
|
||||
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
await expectStartingRefRows(page, [
|
||||
"main, origin branch",
|
||||
"main, local branch, 2 commits ahead of origin main",
|
||||
]);
|
||||
const screenshotPath = testInfo.outputPath("ref-picker-local-ahead.png");
|
||||
await captureStartingRefPicker(page, screenshotPath);
|
||||
await testInfo.attach("Ref picker: local ahead of upstream", {
|
||||
path: screenshotPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await startingRefRow(page, "main, local branch, 2 commits ahead of origin main").click();
|
||||
await expectPickerSelected(page, "main (local)");
|
||||
|
||||
const created = await createWorktreeAndRead(page, openedProject);
|
||||
expect(created.branchInfo.hasAncestor(localHead)).toBe(true);
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("branches off a fork's upstream remote and records the branch name", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const tempRepo = await createTempGitRepo("ref-default-fork-", { withRemote: true });
|
||||
|
||||
try {
|
||||
const upstreamHead = await trackForkUpstream(tempRepo.path);
|
||||
const originHead = readRepoRef(tempRepo.path, "refs/remotes/origin/main");
|
||||
|
||||
const openedProject = await openWorktreeComposerForRepo(page, tempRepo.path);
|
||||
|
||||
await openStartingRefPicker(page);
|
||||
// Branch suggestions only know about origin, so the upstream the fork actually
|
||||
// tracks gets its own row rather than silently sharing origin's. Two rows reading
|
||||
// "main" is the ambiguity this whole change exists to remove.
|
||||
await expectStartingRefRows(page, [
|
||||
"main (upstream), upstream branch",
|
||||
"main, origin branch",
|
||||
]);
|
||||
await expectPickerSelected(page, "main (upstream)");
|
||||
const screenshotPath = testInfo.outputPath("ref-picker-fork.png");
|
||||
await captureStartingRefPicker(page, screenshotPath);
|
||||
await testInfo.attach("Ref picker: fork tracking upstream/main", {
|
||||
path: screenshotPath,
|
||||
contentType: "image/png",
|
||||
});
|
||||
await closeBranchPicker(page);
|
||||
|
||||
const created = await createWorktreeAndRead(page, openedProject);
|
||||
|
||||
expect(created.branchInfo.hasAncestor(upstreamHead)).toBe(true);
|
||||
expect(upstreamHead).not.toBe(originHead);
|
||||
// The name is what the UI shows; the ref is what resolves back to this commit.
|
||||
expect(await readWorktreeBaseMetadata(created.workspaceDirectory)).toEqual({
|
||||
baseRefName: "main",
|
||||
baseRef: "refs/remotes/upstream/main",
|
||||
});
|
||||
} finally {
|
||||
await tempRepo.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("branch picker opens via keyboard and selects the filtered option on Enter", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
addOfflineHostAndReload,
|
||||
expectHostFilterRow,
|
||||
openSidebarHostFilter,
|
||||
openSidebarDisplayPreferences,
|
||||
selectAllHostsFilter,
|
||||
toggleHostFilter,
|
||||
} from "../support/helpers/hosts";
|
||||
@@ -29,7 +29,7 @@ test.describe("Sidebar host filter (multi-select)", () => {
|
||||
await addOfflineHostAndReload(page, { serverId: SECONDARY_HOST_ID, label: "Secondary Host" });
|
||||
await expect(workspaceRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarHostFilter(page);
|
||||
await openSidebarDisplayPreferences(page);
|
||||
await expectHostFilterRow(page, serverId);
|
||||
await expectHostFilterRow(page, SECONDARY_HOST_ID);
|
||||
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { projectEquivalenceViewKey } from "../support/helpers/project-view-key";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||
|
||||
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
|
||||
|
||||
async function closeMenuSheet(page: Page): Promise<void> {
|
||||
const backdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first();
|
||||
await backdrop.click({ position: { x: 12, y: 12 } });
|
||||
await expect(backdrop).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => {
|
||||
const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" });
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).click();
|
||||
await waitForSidebarHydration(page);
|
||||
|
||||
await page
|
||||
.getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`)
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("Project actions", { exact: true })).toBeVisible();
|
||||
await closeMenuSheet(page);
|
||||
|
||||
const workspaceRow = page.getByTestId(
|
||||
`sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`,
|
||||
);
|
||||
await workspaceRow.hover();
|
||||
await workspaceRow
|
||||
.getByTestId(`sidebar-workspace-kebab-${getServerId()}:${seeded.workspaceId}`)
|
||||
.click();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -133,7 +133,6 @@ test.describe("Model B sidebar shape", () => {
|
||||
|
||||
// Switch to status grouping.
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
|
||||
const sidebar = page.getByTestId("sidebar-sessions").filter({ visible: true }).first();
|
||||
|
||||
@@ -40,7 +40,6 @@ async function collapseProjectSection(page: Page, project: SeededWorkspace): Pro
|
||||
|
||||
async function switchToStatusGrouping(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.getByTestId("sidebar-status-list-scroll")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
expectMobileAgentSidebarHidden,
|
||||
expectMobileAgentSidebarVisible,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -227,18 +228,19 @@ test.describe("Mobile sidebar panelState transition", () => {
|
||||
const workspace = await seedWorkspace({ repoPrefix: "sidebar-retained-pin-" });
|
||||
|
||||
try {
|
||||
// Pinning behavior has its own E2E suite. Seed this test's precondition directly so it only
|
||||
// exercises whether the retained compact sidebar keeps the pinned row mounted while closed.
|
||||
await workspace.client.setWorkspacePinned(workspace.workspaceId, true);
|
||||
await gotoAppShell(page);
|
||||
await openMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarVisible(page);
|
||||
|
||||
const row = page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId));
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await pinWorkspaceFromSidebar(page, workspace.workspaceId);
|
||||
await expect(page.getByTestId("sidebar-pinned-section")).toBeVisible();
|
||||
|
||||
await closeMobileAgentSidebar(page);
|
||||
await expectMobileAgentSidebarHidden(page);
|
||||
|
||||
await expect(page.getByTestId(getWorkspaceRowTestId(workspace.workspaceId))).toHaveCount(1);
|
||||
await expect(row).toHaveCount(1);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ async function expectAgentConsistentlyIdle(page: Page, title: string): Promise<v
|
||||
}
|
||||
|
||||
test.describe("Viewed agent timelines", () => {
|
||||
test("a turn that finishes while hidden reopens with consistently idle chrome", async ({
|
||||
test("a turn that finishes while unsubscribed reopens with consistently idle chrome", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
@@ -105,9 +105,9 @@ test.describe("Viewed agent timelines", () => {
|
||||
const scenario = await seedViewedTimelineScenario({ firstAgentModel: "one-minute-stream" });
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await startVisibleTurn(page, scenario, "Finish after this chat becomes hidden.");
|
||||
await startVisibleTurn(page, scenario, "Finish after this chat becomes unsubscribed.");
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.firstAgentId, scenario.secondAgentId]);
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||
const finish = await scenario.client.waitForFinish(scenario.firstAgentId, 90_000);
|
||||
expect(finish.status).toBe("idle");
|
||||
|
||||
@@ -118,7 +118,33 @@ test.describe("Viewed agent timelines", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a hidden hot chat stays current", async ({ page }) => {
|
||||
test("an unsubscribed hidden chat catches up when shown", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
try {
|
||||
await openAgent(page, scenario, scenario.firstAgentId);
|
||||
await selectAgent(page, "Second viewed chat");
|
||||
await subscriptions.waitForSubscribedAgents([scenario.secondAgentId], { timeout: 45_000 });
|
||||
await commitMessage(
|
||||
scenario,
|
||||
scenario.firstAgentId,
|
||||
"Committed after the first chat unsubscribed.",
|
||||
);
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toHaveCount(0);
|
||||
await selectAgent(page, "First viewed chat");
|
||||
await expect(
|
||||
page.getByText("Committed after the first chat unsubscribed.", { exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("(end of synthetic stream)", { exact: true })).toBeVisible();
|
||||
} finally {
|
||||
await scenario.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("a hidden retained chat stays current during unsubscribe grace", async ({ page }) => {
|
||||
test.setTimeout(60_000);
|
||||
const subscriptions = observeTimelineSubscriptions(page);
|
||||
const scenario = await seedViewedTimelineScenario();
|
||||
|
||||
@@ -83,7 +83,6 @@ async function fetchAgentStatus(seeded: SeededWorkspace, agentId: string): Promi
|
||||
|
||||
async function switchSidebarToStatusGrouping(page: import("@playwright/test").Page) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(page.locator('[data-testid^="sidebar-status-group-"]').first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
|
||||
@@ -398,7 +398,6 @@ async function expectWorkspaceRowInStatusBucket(
|
||||
input: { serverId: string; workspaceId: string; bucket: string },
|
||||
) {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await page.getByTestId("sidebar-display-grouping").click();
|
||||
await page.getByTestId("sidebar-grouping-status").click();
|
||||
await expect(
|
||||
page
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-explorer-toggle\""
|
||||
wait "id=\"explorer-tab-files\"" 10000
|
||||
press "id=\"explorer-tab-files\""
|
||||
wait "id=\"file-explorer-row-0\"" 10000
|
||||
longpress "id=\"file-explorer-row-0\"" 600
|
||||
wait "id=\"file-explorer-row-0-context-menu-content\"" 10000
|
||||
is visible "text=\"Copy path\""
|
||||
close
|
||||
@@ -111,34 +111,27 @@ export async function scrollChatAwayFromBottom(
|
||||
if (!box) {
|
||||
throw new Error("Agent chat scroll container is not visible");
|
||||
}
|
||||
const wheelSettled = scroll.evaluate(
|
||||
(root: Element) =>
|
||||
new Promise<void>((resolve) => {
|
||||
root.addEventListener(
|
||||
"wheel",
|
||||
() => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
let previousOffset = scrollElement.scrollTop;
|
||||
let stableFrames = 0;
|
||||
const sample = () => {
|
||||
const currentOffset = scrollElement.scrollTop;
|
||||
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
|
||||
previousOffset = currentOffset;
|
||||
if (stableFrames >= 3) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}),
|
||||
);
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await page.mouse.wheel(0, input.deltaY);
|
||||
await wheelSettled;
|
||||
await scroll.evaluate(
|
||||
(root: Element) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const scrollElement = root as HTMLElement;
|
||||
let previousOffset = scrollElement.scrollTop;
|
||||
let stableFrames = 0;
|
||||
const sample = () => {
|
||||
const currentOffset = scrollElement.scrollTop;
|
||||
stableFrames = Math.abs(currentOffset - previousOffset) <= 0.5 ? stableFrames + 1 : 0;
|
||||
previousOffset = currentOffset;
|
||||
if (stableFrames >= 3) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
|
||||
@@ -125,6 +125,26 @@ export async function expectChatOutlinePromptToRemainBare(
|
||||
await expect(chatOutlinePrompt(page, position)).toHaveCSS("background-color", "rgba(0, 0, 0, 0)");
|
||||
}
|
||||
|
||||
export async function expectChatOutlineAlignedWithActiveTabGlyph(page: Page): Promise<void> {
|
||||
const outlinePill = chatOutlinePrompt(page, 1).locator("div").first();
|
||||
const activeTabGlyph = page
|
||||
.getByTestId("workspace-tabs-row")
|
||||
.filter({ visible: true })
|
||||
.locator('[role="button"][aria-selected="true"]')
|
||||
.locator("svg")
|
||||
.first();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const [pillBox, glyphBox] = await Promise.all([
|
||||
requireBoundingBox(outlinePill),
|
||||
requireBoundingBox(activeTabGlyph),
|
||||
]);
|
||||
return Math.abs(pillBox.x - glyphBox.x);
|
||||
})
|
||||
.toBeLessThanOrEqual(1);
|
||||
}
|
||||
|
||||
/** Exactly one prompt is marked, without saying which — the reader always has a "you are here". */
|
||||
export async function expectOneActiveChatOutlinePrompt(page: Page): Promise<void> {
|
||||
await expect(chatOutlineRail(page).getByRole("tab", { selected: true })).toHaveCount(1);
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
|
||||
import type { ScheduleSummary } from "@getpaseo/protocol/schedule/types";
|
||||
import { seedParentWithSubagent } from "./subagents";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./seed-client";
|
||||
|
||||
interface HeartbeatScheduleClient {
|
||||
scheduleCreate(input: {
|
||||
prompt: string;
|
||||
name: string;
|
||||
cadence: { type: "every"; everyMs: number };
|
||||
target: { type: "agent"; agentId: string };
|
||||
runOnCreate: false;
|
||||
expiresAt?: string;
|
||||
}): Promise<{ schedule: ScheduleSummary | null; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
scheduleList(): Promise<{ schedules: ScheduleSummary[]; error: string | null }>;
|
||||
scheduleUpdate(input: {
|
||||
id: string;
|
||||
expiresAt: string;
|
||||
}): Promise<{ schedule: ScheduleSummary | null; error: string | null }>;
|
||||
}
|
||||
|
||||
export interface SeededHeartbeat {
|
||||
id: string;
|
||||
name: string;
|
||||
cadence: string;
|
||||
}
|
||||
|
||||
export interface AgentHeartbeatScenario {
|
||||
workspace: SeededWorkspace;
|
||||
parent: { id: string; title: string };
|
||||
child: { id: string; title: string };
|
||||
createHeartbeat(input?: { name?: string; expiresAt?: string }): Promise<SeededHeartbeat>;
|
||||
setHeartbeatExpiry(id: string, expiresAt: string): Promise<void>;
|
||||
readWorkspaceStatus(): Promise<string | null>;
|
||||
readHeartbeatExists(id: string): Promise<boolean>;
|
||||
readAgentStatuses(): Promise<Record<string, string>>;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
|
||||
export async function seedAgentWithHeartbeat(): Promise<AgentHeartbeatScenario> {
|
||||
const workspace = await seedWorkspace({
|
||||
repoPrefix: "agent-heartbeat-track-",
|
||||
title: "Heartbeat workspace",
|
||||
});
|
||||
const scheduleClient = workspace.client as unknown as HeartbeatScheduleClient;
|
||||
try {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Build babysitter",
|
||||
childTitle: "Build observer",
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agents.parent.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
agents.child.id,
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
const heartbeatIds = new Set<string>();
|
||||
return {
|
||||
workspace,
|
||||
parent: agents.parent,
|
||||
child: agents.child,
|
||||
createHeartbeat: async (input = {}) => {
|
||||
const name = input.name ?? "Watch the build";
|
||||
const created = await scheduleClient.scheduleCreate({
|
||||
prompt: "Check the build and report any failures.",
|
||||
name,
|
||||
cadence: { type: "every", everyMs: 15 * 60_000 },
|
||||
target: { type: "agent", agentId: agents.parent.id },
|
||||
runOnCreate: false,
|
||||
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
||||
});
|
||||
if (!created.schedule) {
|
||||
throw new Error(created.error ?? "Failed to create heartbeat");
|
||||
}
|
||||
heartbeatIds.add(created.schedule.id);
|
||||
return { id: created.schedule.id, name, cadence: "Every 15 minutes" };
|
||||
},
|
||||
setHeartbeatExpiry: async (id, expiresAt) => {
|
||||
const updated = await scheduleClient.scheduleUpdate({ id, expiresAt });
|
||||
if (!updated.schedule) {
|
||||
throw new Error(updated.error ?? "Failed to update heartbeat expiry");
|
||||
}
|
||||
},
|
||||
readWorkspaceStatus: async () => {
|
||||
const result = await workspace.client.fetchWorkspaces();
|
||||
return (
|
||||
(
|
||||
result.entries.find((entry) => entry.id === workspace.workspaceId) as
|
||||
| ((typeof result.entries)[number] & { status?: string })
|
||||
| undefined
|
||||
)?.status ?? null
|
||||
);
|
||||
},
|
||||
readHeartbeatExists: async (id) => {
|
||||
const result = await scheduleClient.scheduleList();
|
||||
return result.schedules.some((schedule) => schedule.id === id);
|
||||
},
|
||||
readAgentStatuses: async () => {
|
||||
const result = await workspace.client.fetchAgents({ scope: "active" });
|
||||
return Object.fromEntries(result.entries.map(({ agent }) => [agent.id, agent.status]));
|
||||
},
|
||||
cleanup: async () => {
|
||||
await Promise.all(
|
||||
Array.from(heartbeatIds, (id) =>
|
||||
scheduleClient.scheduleDelete({ id }).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
await workspace.cleanup();
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await workspace.cleanup();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function expectWorkspaceRunning(page: Page): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: /^Heartbeat workspace(?:, Working)?$/ })
|
||||
.getByTestId("workspace-status-indicator-running"),
|
||||
).toBeVisible({ timeout: 60_000 });
|
||||
}
|
||||
|
||||
export async function expectWorkspaceDone(page: Page): Promise<void> {
|
||||
const workspaceRow = page.getByRole("button", {
|
||||
name: /^Heartbeat workspace(?:, Working)?$/,
|
||||
});
|
||||
await expect(workspaceRow.getByTestId("workspace-status-indicator-done")).toBeVisible({
|
||||
timeout: 60_000,
|
||||
});
|
||||
await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
export async function openHeartbeatAndSubagentTracks(
|
||||
page: Page,
|
||||
heartbeat: SeededHeartbeat,
|
||||
subagentId: string,
|
||||
): Promise<void> {
|
||||
await openHeartbeatTrack(page, heartbeat);
|
||||
await ensureTrackExpanded(
|
||||
page,
|
||||
"1 subagent",
|
||||
page.getByTestId(`subagents-track-row-${subagentId}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function openHeartbeatTrack(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
|
||||
await ensureTrackExpanded(
|
||||
page,
|
||||
"1 heartbeat",
|
||||
page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectHeartbeatVisible(
|
||||
page: Page,
|
||||
heartbeat: SeededHeartbeat,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openHeartbeat(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
|
||||
await page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }).click();
|
||||
}
|
||||
|
||||
export async function expectHeartbeatDetails(
|
||||
page: Page,
|
||||
heartbeat: SeededHeartbeat,
|
||||
): Promise<void> {
|
||||
const sheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(sheet).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sheet.getByText("Edit heartbeat", { exact: true })).toBeVisible();
|
||||
await expect(page).toHaveURL(new RegExp(`scheduleId=${heartbeat.id}`));
|
||||
await expect(page.getByText("*/15 * * * *", { exact: true }).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export async function returnToAgentHeartbeat(page: Page): Promise<void> {
|
||||
await page.goBack();
|
||||
await expect(page.getByRole("button", { name: "1 heartbeat" })).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeSeededSubagent(scenario: AgentHeartbeatScenario): Promise<void> {
|
||||
await scenario.workspace.client.archiveAgent(scenario.child.id);
|
||||
}
|
||||
|
||||
export async function deleteHeartbeat(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
|
||||
const row = page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` });
|
||||
await row.hover();
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await page.getByRole("button", { name: `Delete heartbeat ${heartbeat.name}` }).click();
|
||||
await expect(row).toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function captureHeartbeatEvidence(
|
||||
page: Page,
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
const path = testInfo.outputPath(`${name}.png`);
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
await testInfo.attach(name, { path, contentType: "image/png" });
|
||||
}
|
||||
|
||||
async function ensureTrackExpanded(page: Page, name: string, content: Locator): Promise<void> {
|
||||
const disclosure = page.getByRole("button", { name });
|
||||
await expect(disclosure).toBeVisible({ timeout: 30_000 });
|
||||
if (!(await content.isVisible())) {
|
||||
await disclosure.click();
|
||||
}
|
||||
await expect(content).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
@@ -123,12 +123,11 @@ export async function waitForConnectedHost(
|
||||
await expect(host).not.toBeVisible();
|
||||
}
|
||||
|
||||
export async function openSidebarHostFilter(page: Page): Promise<void> {
|
||||
export async function openSidebarDisplayPreferences(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await page.getByTestId("sidebar-display-host-filter").click();
|
||||
}
|
||||
|
||||
// A host's filter row carries a status dot on the left next to its label.
|
||||
@@ -145,6 +144,32 @@ export async function selectAllHostsFilter(page: Page): Promise<void> {
|
||||
await page.getByTestId("sidebar-host-filter-all").click();
|
||||
}
|
||||
|
||||
// Playwright reports resolved colors, so the expected value is derived from the same identity
|
||||
// table the app reads. A hex literal in the test would be a second copy of the palette.
|
||||
function toRgb(hex: string): string {
|
||||
const { r, g, b } = parseHex(hex);
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function toRgba(hex: string, alpha: number): string {
|
||||
const { r, g, b } = parseHex(hex);
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
interface RgbChannels {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
}
|
||||
|
||||
function parseHex(hex: string): RgbChannels {
|
||||
return {
|
||||
r: Number.parseInt(hex.slice(1, 3), 16),
|
||||
g: Number.parseInt(hex.slice(3, 5), 16),
|
||||
b: Number.parseInt(hex.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export async function openHostAppearanceSettings(page: Page, serverId: string): Promise<void> {
|
||||
await openSettings(page);
|
||||
await selectSettingsHost(page, serverId);
|
||||
@@ -223,6 +248,11 @@ export async function expectHostBadgeTinted(
|
||||
): Promise<void> {
|
||||
const badge = hostBadge(page, target);
|
||||
await expect(badge).toBeVisible({ timeout: 15_000 });
|
||||
await expect(badge.getByText(target.hostName)).toHaveCSS(
|
||||
"color",
|
||||
toRgb(identityColor(target.color)),
|
||||
);
|
||||
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(target.color), 0.1));
|
||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(target.color));
|
||||
}
|
||||
|
||||
@@ -234,6 +264,11 @@ export async function expectHostAppearancePreview(
|
||||
await expect(preview).toBeVisible();
|
||||
const badge = preview.getByTestId(`sidebar-host-badge-${input.serverId}`);
|
||||
await expect(badge).toHaveText(input.hostName);
|
||||
await expect(badge.getByText(input.hostName)).toHaveCSS(
|
||||
"color",
|
||||
toRgb(identityColor(input.color)),
|
||||
);
|
||||
await expect(badge).toHaveCSS("background-color", toRgba(identityColor(input.color), 0.1));
|
||||
await expect(badge.locator("svg")).toHaveAttribute("stroke", identityColor(input.color));
|
||||
}
|
||||
|
||||
|
||||
@@ -348,23 +348,6 @@ export async function searchAndSelectBranchInPicker(page: Page, name: string): P
|
||||
await selectBranchInPicker(page, name);
|
||||
}
|
||||
|
||||
// Ref picker rows are named for a user: "main, origin branch" is the upstream copy and
|
||||
// "main, local branch, 2 commits ahead of origin main" is the local one.
|
||||
export function startingRefRow(page: Page, accessibleName: string) {
|
||||
return page.getByRole("button", { name: accessibleName, exact: true });
|
||||
}
|
||||
|
||||
export async function expectStartingRefRows(page: Page, accessibleNames: string[]): Promise<void> {
|
||||
for (const name of accessibleNames) {
|
||||
await expect(startingRefRow(page, name)).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function captureStartingRefPicker(page: Page, screenshotPath: string): Promise<void> {
|
||||
await expect(page.getByTestId("combobox-desktop-container")).toBeVisible({ timeout: 30_000 });
|
||||
await page.getByTestId("combobox-desktop-container").screenshot({ path: screenshotPath });
|
||||
}
|
||||
|
||||
export async function selectGitHubPrInPicker(page: Page, number: number): Promise<void> {
|
||||
const prRow = page.getByTestId(`new-workspace-ref-picker-pr-${number}`);
|
||||
await expect(prRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
@@ -39,7 +39,6 @@ export interface SeedDaemonClient {
|
||||
}>;
|
||||
removeProject(projectId: string): Promise<{ removedWorkspaceIds: string[] }>;
|
||||
renameProject(projectId: string, customName: string | null): Promise<void>;
|
||||
setWorkspacePinned(workspaceId: string, pinned: boolean): Promise<{ pinnedAt: string | null }>;
|
||||
fetchWorkspaces(options?: { filter?: { projectId?: string } }): Promise<{
|
||||
entries: SeedWorkspaceDescriptor[];
|
||||
}>;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { expect, type Locator } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
export interface Rails {
|
||||
left: number;
|
||||
right: number;
|
||||
}
|
||||
|
||||
interface Box {
|
||||
x: number;
|
||||
@@ -13,6 +18,30 @@ async function readBox(locator: Locator): Promise<Box> {
|
||||
return box as Box;
|
||||
}
|
||||
|
||||
/** Leading and trailing edges of an element, in whole pixels. */
|
||||
export async function readRails(locator: Locator): Promise<Rails> {
|
||||
const box = await readBox(locator);
|
||||
return { left: Math.round(box.x), right: Math.round(box.x + box.width) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The two rails an AdaptiveModalSheet header indents to: the title's leading
|
||||
* glyph and the close button's trailing edge. Sheet content sits on the same
|
||||
* two rails, in every presentation.
|
||||
*/
|
||||
export async function readSheetHeaderRails(
|
||||
page: Page,
|
||||
sheetTestID: string,
|
||||
title: string,
|
||||
): Promise<Rails> {
|
||||
const header = page.getByTestId(sheetTestID);
|
||||
const [titleRails, closeRails] = await Promise.all([
|
||||
readRails(header.getByText(title, { exact: true })),
|
||||
readRails(header.getByRole("button", { name: "Close" })),
|
||||
]);
|
||||
return { left: titleRails.left, right: closeRails.right };
|
||||
}
|
||||
|
||||
/** Vertical space between the bottom of one element and the top of the next. */
|
||||
export async function readVerticalGap(upper: Locator, lower: Locator): Promise<number> {
|
||||
const [above, below] = await Promise.all([readBox(upper), readBox(lower)]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp, readFile, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
||||
import { mkdtemp, writeFile, rm, mkdir, realpath } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -159,80 +159,6 @@ export async function createTempDirectory(prefix = "paseo-e2e-dir-"): Promise<Te
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A commit that exists only in the local checkout. The whole point of defaulting a new
|
||||
* worktree to the upstream is that these commits do not leak into it.
|
||||
*/
|
||||
export function commitLocalOnly(repoPath: string, marker: string): string {
|
||||
execSync(`git commit --allow-empty -m ${JSON.stringify(`local only ${marker}`)}`, {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return execSync("git rev-parse HEAD", { cwd: repoPath, stdio: "pipe" }).toString().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* A fork checkout: `origin` is the fork, `upstream` is the source repo, and the current
|
||||
* branch tracks `upstream/main`. Upstream carries a commit origin does not, so a worktree
|
||||
* based on the wrong remote is provable rather than coincidentally identical.
|
||||
*/
|
||||
export async function trackForkUpstream(repoPath: string): Promise<string> {
|
||||
const upstreamDir = path.join(repoPath, "upstream.git");
|
||||
const upstreamClone = await mkdtemp(path.join(await resolveTempRoot(), "paseo-e2e-upstream-"));
|
||||
await mkdir(upstreamDir, { recursive: true });
|
||||
execSync(`git init --bare -b main ${upstreamDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync(`git remote add upstream ${upstreamDir}`, { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git push upstream main", { cwd: repoPath, stdio: "ignore" });
|
||||
|
||||
execSync(`git clone ${upstreamDir} ${upstreamClone}`, { stdio: "ignore" });
|
||||
execSync('git config user.email "e2e@paseo.test"', { cwd: upstreamClone, stdio: "ignore" });
|
||||
execSync('git config user.name "Paseo E2E"', { cwd: upstreamClone, stdio: "ignore" });
|
||||
execSync("git config commit.gpgsign false", { cwd: upstreamClone, stdio: "ignore" });
|
||||
execSync('git commit --allow-empty -m "upstream only"', {
|
||||
cwd: upstreamClone,
|
||||
stdio: "ignore",
|
||||
});
|
||||
execSync("git push origin main", { cwd: upstreamClone, stdio: "ignore" });
|
||||
await rm(upstreamClone, { recursive: true, force: true });
|
||||
|
||||
execSync("git fetch upstream", { cwd: repoPath, stdio: "ignore" });
|
||||
execSync("git branch --set-upstream-to=upstream/main main", {
|
||||
cwd: repoPath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
return execSync("git rev-parse refs/remotes/upstream/main", { cwd: repoPath, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function readRepoRef(repoPath: string, ref: string): string {
|
||||
return execSync(`git rev-parse ${JSON.stringify(ref)}`, { cwd: repoPath, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* The base identity the daemon recorded in worktree.json: the display name the UI reads back,
|
||||
* and the exact ref every comparison and action resolves through.
|
||||
*/
|
||||
export async function readWorktreeBaseMetadata(
|
||||
worktreePath: string,
|
||||
): Promise<{ baseRefName: string; baseRef?: string }> {
|
||||
const gitDir = execSync("git rev-parse --absolute-git-dir", { cwd: worktreePath, stdio: "pipe" })
|
||||
.toString()
|
||||
.trim();
|
||||
const metadata = JSON.parse(
|
||||
await readFile(path.join(gitDir, "paseo", "worktree.json"), "utf8"),
|
||||
) as { baseRefName?: string; baseRef?: string };
|
||||
if (!metadata.baseRefName) {
|
||||
throw new Error(`worktree.json has no baseRefName: ${worktreePath}`);
|
||||
}
|
||||
return {
|
||||
baseRefName: metadata.baseRefName,
|
||||
...(metadata.baseRef ? { baseRef: metadata.baseRef } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function readWorktreeBranchInfo({ worktreePath }: { worktreePath: string }): Promise<{
|
||||
currentBranch: string;
|
||||
hasAncestor: (ref: string) => boolean;
|
||||
|
||||
@@ -5,7 +5,6 @@ import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import type { StreamRenderInput, StreamSegmentRenderers, StreamViewportHandle } from "./strategy";
|
||||
import { createWebStreamStrategy } from "./strategy-web";
|
||||
@@ -26,10 +25,6 @@ vi.hoisted(() => {
|
||||
});
|
||||
});
|
||||
|
||||
vi.mock("react-native-unistyles", () => ({
|
||||
withUnistyles: (Component: React.ComponentType) => Component,
|
||||
}));
|
||||
|
||||
function userMessage(index: number): StreamItem {
|
||||
return {
|
||||
kind: "user_message",
|
||||
@@ -538,7 +533,6 @@ describe("createWebStreamStrategy", () => {
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
|
||||
const onNearHistoryStart = vi.fn().mockReturnValue(false);
|
||||
const routeBottomAnchorRequest = {
|
||||
agentId: "agent",
|
||||
reason: "initial-entry" as const,
|
||||
@@ -561,10 +555,10 @@ describe("createWebStreamStrategy", () => {
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart,
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: true,
|
||||
olderHistoryProgressKey: "epoch-1:20",
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
@@ -601,7 +595,7 @@ describe("createWebStreamStrategy", () => {
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 520 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
@@ -617,14 +611,6 @@ describe("createWebStreamStrategy", () => {
|
||||
});
|
||||
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
await act(async () => Promise.resolve());
|
||||
expect(onNearHistoryStart).toHaveBeenCalledOnce();
|
||||
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -240 }));
|
||||
});
|
||||
await act(async () => Promise.resolve());
|
||||
expect(onNearHistoryStart).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
|
||||
@@ -835,413 +821,4 @@ describe("createWebStreamStrategy", () => {
|
||||
|
||||
expect(scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps following output after geometry, nested-scroll, and zoom wheel events", () => {
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => root?.render(strategy.render(renderInput)));
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
height: 500,
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
width: 500,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
}) as DOMRect;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
|
||||
const jitterPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX: 100,
|
||||
clientY: 400,
|
||||
});
|
||||
const jitterPointerMove = new MouseEvent("pointermove", {
|
||||
bubbles: true,
|
||||
buttons: 1,
|
||||
clientX: 100,
|
||||
clientY: 397,
|
||||
});
|
||||
for (const event of [jitterPointerDown, jitterPointerMove]) {
|
||||
Object.defineProperties(event, {
|
||||
isPrimary: { value: true },
|
||||
pointerId: { value: 1 },
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
}
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(jitterPointerDown);
|
||||
window.dispatchEvent(jitterPointerMove);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 700 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 800 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
const nestedScroller = document.createElement("div");
|
||||
nestedScroller.style.overflowY = "auto";
|
||||
Object.defineProperty(nestedScroller, "clientHeight", { configurable: true, value: 100 });
|
||||
Object.defineProperty(nestedScroller, "scrollHeight", { configurable: true, value: 300 });
|
||||
Object.defineProperty(nestedScroller, "scrollTop", { configurable: true, value: 100 });
|
||||
scrollContainer.append(nestedScroller);
|
||||
act(() => {
|
||||
nestedScroller.dispatchEvent(new WheelEvent("wheel", { bubbles: true, deltaY: -100 }));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
scrollContainer.dispatchEvent(
|
||||
new WheelEvent("wheel", { bubbles: true, ctrlKey: true, deltaY: -100 }),
|
||||
);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 1800, behavior: "auto" });
|
||||
});
|
||||
|
||||
it("stops following output after scrollbar and middle-button upward scrolling", () => {
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef: React.createRef<StreamViewportHandle>(),
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => root?.render(strategy.render(renderInput)));
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "clientWidth", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 520 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
height: 500,
|
||||
left: 0,
|
||||
right: 520,
|
||||
top: 0,
|
||||
width: 520,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
}) as DOMRect;
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
|
||||
const scrollbarPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX: 515,
|
||||
clientY: 400,
|
||||
});
|
||||
Object.defineProperties(scrollbarPointerDown, {
|
||||
isPrimary: { value: true },
|
||||
pointerId: { value: 1 },
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
act(() => scrollContainer.dispatchEvent(scrollbarPointerDown));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 700 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1800 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1300 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
Object.defineProperty(scrollContainer, "offsetWidth", { configurable: true, value: 500 });
|
||||
scrollContainer.getBoundingClientRect = () =>
|
||||
({
|
||||
bottom: 500,
|
||||
height: 500,
|
||||
left: 0,
|
||||
right: 500,
|
||||
top: 0,
|
||||
width: 500,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => {},
|
||||
}) as DOMRect;
|
||||
const overlayScrollbarPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
buttons: 1,
|
||||
clientX: 495,
|
||||
clientY: 400,
|
||||
});
|
||||
Object.defineProperties(overlayScrollbarPointerDown, {
|
||||
isPrimary: { value: true },
|
||||
pointerId: { value: 2 },
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
act(() => scrollContainer.dispatchEvent(overlayScrollbarPointerDown));
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 900 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1900 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
...renderInput.segments,
|
||||
liveHead: [userMessage(3), userMessage(4)],
|
||||
},
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1400 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const autoscrollPointerDown = new MouseEvent("pointerdown", {
|
||||
bubbles: true,
|
||||
button: 1,
|
||||
buttons: 4,
|
||||
clientX: 100,
|
||||
clientY: 400,
|
||||
});
|
||||
const autoscrollPointerUp = new MouseEvent("pointerup", {
|
||||
bubbles: true,
|
||||
button: 1,
|
||||
buttons: 0,
|
||||
clientX: 100,
|
||||
clientY: 400,
|
||||
});
|
||||
const autoscrollPointerMove = new MouseEvent("pointermove", {
|
||||
bubbles: true,
|
||||
buttons: 0,
|
||||
clientX: 100,
|
||||
clientY: 300,
|
||||
});
|
||||
for (const event of [autoscrollPointerDown, autoscrollPointerUp, autoscrollPointerMove]) {
|
||||
Object.defineProperties(event, {
|
||||
isPrimary: { value: true },
|
||||
pointerId: { value: 3 },
|
||||
pointerType: { value: "mouse" },
|
||||
});
|
||||
}
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(autoscrollPointerDown);
|
||||
window.dispatchEvent(autoscrollPointerUp);
|
||||
window.dispatchEvent(autoscrollPointerMove);
|
||||
});
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
scrollTo.mockClear();
|
||||
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2000 });
|
||||
act(() => {
|
||||
root?.render(
|
||||
strategy.render({
|
||||
...renderInput,
|
||||
segments: {
|
||||
...renderInput.segments,
|
||||
liveHead: [userMessage(3), userMessage(4), userMessage(5)],
|
||||
},
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the retained viewport mounted while inactive and reconciles it once on return", () => {
|
||||
const observed = new Map<Element, ReturnType<typeof vi.fn>>();
|
||||
Object.defineProperty(globalThis, "ResizeObserver", {
|
||||
configurable: true,
|
||||
value: class ResizeObserver {
|
||||
readonly disconnect = vi.fn();
|
||||
|
||||
observe(target: Element) {
|
||||
observed.set(target, this.disconnect);
|
||||
}
|
||||
},
|
||||
});
|
||||
const scrollTo = vi.fn(function (this: HTMLElement, options?: ScrollToOptions | number) {
|
||||
const top = typeof options === "object" ? (options.top ?? 0) : 0;
|
||||
Object.defineProperty(this, "scrollTop", { configurable: true, value: top });
|
||||
});
|
||||
HTMLElement.prototype.scrollTo = scrollTo;
|
||||
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
|
||||
const viewportRef = React.createRef<StreamViewportHandle>();
|
||||
const renderInput: StreamRenderInput = {
|
||||
agentId: "agent",
|
||||
segments: {
|
||||
historyVirtualized: [],
|
||||
historyMounted: [userMessage(1), userMessage(2)],
|
||||
liveHead: [],
|
||||
},
|
||||
boundary: {
|
||||
hasVirtualizedHistory: false,
|
||||
hasMountedHistory: true,
|
||||
hasLiveHead: false,
|
||||
},
|
||||
renderers: createRenderers(vi.fn()),
|
||||
listEmptyComponent: null,
|
||||
viewportRef,
|
||||
routeBottomAnchorRequest: null,
|
||||
isAuthoritativeHistoryReady: true,
|
||||
onNearBottomChange: vi.fn(),
|
||||
onNearHistoryStart: vi.fn().mockReturnValue(true),
|
||||
isLoadingOlderHistory: false,
|
||||
hasOlderHistory: false,
|
||||
olderHistoryProgressKey: null,
|
||||
scrollEnabled: true,
|
||||
listStyle: null,
|
||||
baseListContentContainerStyle: null,
|
||||
forwardListContentContainerStyle: null,
|
||||
};
|
||||
const renderWithActivity = (active: boolean, input: StreamRenderInput = renderInput) => (
|
||||
<RetainedPanelActivity active={active}>{strategy.render(input)}</RetainedPanelActivity>
|
||||
);
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
act(() => root?.render(renderWithActivity(true)));
|
||||
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
|
||||
if (!(scrollContainer instanceof HTMLElement)) {
|
||||
throw new Error("Expected agent chat scroll container");
|
||||
}
|
||||
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1500 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 1000 });
|
||||
act(() => scrollContainer.dispatchEvent(new Event("scroll")));
|
||||
const viewportObserverDisconnect = observed.get(scrollContainer);
|
||||
expect(viewportObserverDisconnect).toBeDefined();
|
||||
|
||||
act(() => root?.render(renderWithActivity(false)));
|
||||
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
|
||||
expect(viewportObserverDisconnect).toHaveBeenCalledOnce();
|
||||
scrollTo.mockClear();
|
||||
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2200 });
|
||||
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
|
||||
act(() => {
|
||||
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -100 }));
|
||||
scrollContainer.dispatchEvent(new Event("scroll"));
|
||||
root?.render(
|
||||
renderWithActivity(false, {
|
||||
...renderInput,
|
||||
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(scrollTo).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
root?.render(
|
||||
renderWithActivity(true, {
|
||||
...renderInput,
|
||||
segments: { ...renderInput.segments, liveHead: [userMessage(3)] },
|
||||
boundary: { ...renderInput.boundary, hasLiveHead: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('[data-testid="agent-chat-scroll"]')).toBe(scrollContainer);
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 2200, behavior: "auto" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,11 +9,9 @@ import React, {
|
||||
} from "react";
|
||||
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import { useRetainedPanelActive } from "@/components/retained-panel";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar";
|
||||
import { estimateStreamItemHeight } from "./web-virtualization";
|
||||
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy";
|
||||
import { createStreamStrategy } from "./strategy";
|
||||
@@ -53,7 +51,6 @@ const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
|
||||
const HISTORY_START_SETTLE_FRAMES = 2;
|
||||
const HISTORY_START_SLOT_HEIGHT_PX = 32;
|
||||
const CONTENT_PADDING_TOP_PX = 16;
|
||||
const UPWARD_INPUT_EVIDENCE_TIMEOUT_MS = 100;
|
||||
const VIRTUALIZER_SCROLL_MARGIN_PX = HISTORY_START_SLOT_HEIGHT_PX + CONTENT_PADDING_TOP_PX;
|
||||
// A row has to clear this much of the viewport top before the next one takes over as the
|
||||
// reading position, so a row resting exactly on the edge does not flip back and forth.
|
||||
@@ -108,77 +105,6 @@ function isScrollContainerAtBottom(
|
||||
return isScrollContainerNearBottom(scrollContainer, AUTO_SCROLL_RESUME_THRESHOLD_PX);
|
||||
}
|
||||
|
||||
function isScrollContainerMeasurable(
|
||||
scrollContainer: Pick<HTMLElement, "clientHeight" | "scrollHeight">,
|
||||
): boolean {
|
||||
return scrollContainer.clientHeight > 0 && scrollContainer.scrollHeight > 0;
|
||||
}
|
||||
|
||||
function isEditableEventTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
const editableRoot = target.closest("input, textarea, [contenteditable]");
|
||||
if (!editableRoot) {
|
||||
return false;
|
||||
}
|
||||
const tagName = editableRoot.tagName.toLowerCase();
|
||||
if (tagName === "input" || tagName === "textarea") {
|
||||
return true;
|
||||
}
|
||||
return editableRoot.getAttribute("contenteditable")?.toLowerCase() !== "false";
|
||||
}
|
||||
|
||||
function isUpwardViewportScrollKey(event: KeyboardEvent): boolean {
|
||||
if (isEditableEventTarget(event.target)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
event.key === "ArrowUp" ||
|
||||
event.key === "PageUp" ||
|
||||
event.key === "Home" ||
|
||||
(event.key === " " && event.shiftKey)
|
||||
);
|
||||
}
|
||||
|
||||
function canNestedScrollerConsumeUpwardInput(
|
||||
target: EventTarget | null,
|
||||
scrollContainer: HTMLElement,
|
||||
): boolean {
|
||||
let element = target instanceof Element ? target : null;
|
||||
while (element && element !== scrollContainer) {
|
||||
if (element instanceof HTMLElement) {
|
||||
const overflowY = window.getComputedStyle(element).overflowY;
|
||||
const canScroll = overflowY === "auto" || overflowY === "scroll";
|
||||
if (canScroll && element.scrollHeight > element.clientHeight && element.scrollTop > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
element = element.parentElement;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVerticalScrollbarGutterPress(
|
||||
event: PointerEvent,
|
||||
scrollContainer: HTMLElement,
|
||||
): boolean {
|
||||
if (event.target !== scrollContainer) {
|
||||
return false;
|
||||
}
|
||||
if (scrollContainer.scrollHeight <= scrollContainer.clientHeight) {
|
||||
return false;
|
||||
}
|
||||
const scrollbarWidth = Math.max(
|
||||
scrollContainer.offsetWidth - scrollContainer.clientWidth,
|
||||
WEB_SCROLLBAR_SIZE_PX,
|
||||
);
|
||||
const bounds = scrollContainer.getBoundingClientRect();
|
||||
return window.getComputedStyle(scrollContainer).direction === "rtl"
|
||||
? event.clientX <= bounds.left + scrollbarWidth
|
||||
: event.clientX >= bounds.right - scrollbarWidth;
|
||||
}
|
||||
|
||||
function scrollElementToBottom(
|
||||
scrollContainer: HTMLElement,
|
||||
behavior: ScrollBehaviorLike = "auto",
|
||||
@@ -233,8 +159,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
scrollEnabled,
|
||||
isMobileBreakpoint,
|
||||
} = props;
|
||||
const isActive = useRetainedPanelActive();
|
||||
const isActiveRef = useRef(isActive);
|
||||
const scrollContainerRef = useRef<HTMLElement | null>(null);
|
||||
const contentRef = useRef<HTMLElement | null>(null);
|
||||
const handleScrollContainerRef = useCallback((node: HTMLElement | null) => {
|
||||
@@ -251,18 +175,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return value;
|
||||
};
|
||||
const lastKnownScrollTopRef = useRef(0);
|
||||
const mouseScrollGestureRef = useRef<
|
||||
| { kind: "scrollbar"; pointerId: number }
|
||||
| {
|
||||
kind: "autoscroll";
|
||||
pointerId: number;
|
||||
lastClientY: number;
|
||||
hasUpwardEvidence: boolean;
|
||||
evidenceExpiryFrame: number | null;
|
||||
}
|
||||
| null
|
||||
>(null);
|
||||
const upwardInputEvidenceUntilRef = useRef(0);
|
||||
const pendingUserScrollUpIntentRef = useRef(false);
|
||||
const lastTouchClientYRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollFrameRef = useRef<number | null>(null);
|
||||
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
|
||||
@@ -283,7 +196,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
renderLiveAuxiliary,
|
||||
} = renderers;
|
||||
|
||||
isActiveRef.current = isActive;
|
||||
followOutputRef.current = followOutput;
|
||||
|
||||
const hasRouteBottomAnchorRequest = routeBottomAnchorRequest !== null;
|
||||
@@ -322,7 +234,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const virtualTotalSize = rowVirtualizer.getTotalSize();
|
||||
const getHistoryStartPaginationInput = useStableEvent((): HistoryStartPaginationInput | null => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||
if (!scrollContainer) {
|
||||
return null;
|
||||
}
|
||||
const bottomAnchorSettled =
|
||||
@@ -459,9 +371,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
});
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
const anchor = historyStartPrependAnchorRef.current;
|
||||
if (!anchor || anchor.progressKey === olderHistoryProgressKey) {
|
||||
return;
|
||||
@@ -493,7 +402,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
pendingFrames.delete(node);
|
||||
if (isActiveRef.current && node.isConnected) {
|
||||
if (node.isConnected) {
|
||||
rowVirtualizer.measureElement(node);
|
||||
}
|
||||
});
|
||||
@@ -525,47 +434,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearMouseScrollGesture = useCallback(() => {
|
||||
const gesture = mouseScrollGestureRef.current;
|
||||
const evidenceExpiryFrame = gesture?.kind === "autoscroll" ? gesture.evidenceExpiryFrame : null;
|
||||
if (evidenceExpiryFrame !== null && evidenceExpiryFrame !== undefined) {
|
||||
window.cancelAnimationFrame(evidenceExpiryFrame);
|
||||
}
|
||||
mouseScrollGestureRef.current = null;
|
||||
}, []);
|
||||
|
||||
const clearUpwardInputEvidence = useCallback(() => {
|
||||
upwardInputEvidenceUntilRef.current = 0;
|
||||
}, []);
|
||||
|
||||
const markUpwardInputEvidence = useCallback(() => {
|
||||
upwardInputEvidenceUntilRef.current =
|
||||
window.performance.now() + UPWARD_INPUT_EVIDENCE_TIMEOUT_MS;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (isActive) {
|
||||
return;
|
||||
}
|
||||
cancelPendingStickToBottom();
|
||||
historyStartSettleSchedulerRef.current?.cancel();
|
||||
for (const frame of pendingVirtualRowMeasureFramesRef.current.values()) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
}
|
||||
pendingVirtualRowMeasureFramesRef.current.clear();
|
||||
clearMouseScrollGesture();
|
||||
clearUpwardInputEvidence();
|
||||
lastTouchClientYRef.current = null;
|
||||
}, [cancelPendingStickToBottom, clearMouseScrollGesture, clearUpwardInputEvidence, isActive]);
|
||||
|
||||
const scrollMessagesToBottom = useCallback(
|
||||
(behavior: ScrollBehaviorLike = "auto") => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (
|
||||
!isActiveRef.current ||
|
||||
!scrollContainer ||
|
||||
!isScrollContainerMeasurable(scrollContainer)
|
||||
) {
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
if (isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
@@ -580,9 +452,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
);
|
||||
|
||||
const scheduleStickToBottom = useCallback(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (scrollContainer && isScrollContainerOverscrolledPastBottom(scrollContainer)) {
|
||||
return;
|
||||
@@ -592,7 +461,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}
|
||||
pendingAutoScrollFrameRef.current = window.requestAnimationFrame(() => {
|
||||
pendingAutoScrollFrameRef.current = null;
|
||||
if (!isActiveRef.current || !followOutputRef.current) {
|
||||
if (!followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
scrollMessagesToBottom("auto");
|
||||
@@ -608,9 +477,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Rows are laid out in DOM order, virtualized block first, so the first row whose bottom
|
||||
// clears the reading line is the one the reader is looking at.
|
||||
const reportReadingPosition = useStableEvent(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!onReadingPositionChange) {
|
||||
return;
|
||||
}
|
||||
@@ -636,9 +502,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
});
|
||||
|
||||
const updateScrollMetrics = useCallback(() => {
|
||||
if (!isActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
onNearBottomChange(true);
|
||||
@@ -649,7 +512,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [onNearBottomChange, reportReadingPosition]);
|
||||
|
||||
const { isJumpSettling, scrollToMessage } = useScrollToMessage({
|
||||
active: isActive,
|
||||
scrollContainerRef,
|
||||
rowVirtualizer,
|
||||
historyVirtualized: segments.historyVirtualized,
|
||||
@@ -658,17 +520,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
onNearBottomChange,
|
||||
});
|
||||
|
||||
const stopFollowingOutputFromUserIntent = useStableEvent(() => {
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
setFollowOutput(false);
|
||||
}
|
||||
rearmHistoryStartFromUserIntent();
|
||||
});
|
||||
|
||||
const handleDomScroll = useCallback(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!isActiveRef.current || !scrollContainer || !isScrollContainerMeasurable(scrollContainer)) {
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -677,25 +531,33 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
|
||||
const scrolledDown =
|
||||
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
|
||||
const mouseGesture = mouseScrollGestureRef.current;
|
||||
const hasUpwardInputEvidence =
|
||||
mouseGesture?.kind === "scrollbar" ||
|
||||
(mouseGesture?.kind === "autoscroll" && mouseGesture.hasUpwardEvidence) ||
|
||||
window.performance.now() < upwardInputEvidenceUntilRef.current;
|
||||
|
||||
if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
if (scrolledUp) {
|
||||
cancelPendingStickToBottom();
|
||||
if (followOutputRef.current) {
|
||||
setFollowOutput(false);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
rearmHistoryStartFromUserIntent();
|
||||
} else if (!followOutputRef.current && isAtBottom && scrolledDown && !isJumpSettling()) {
|
||||
setFollowOutput(true);
|
||||
} else if (followOutputRef.current && scrolledUp && hasUpwardInputEvidence) {
|
||||
stopFollowingOutputFromUserIntent();
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
|
||||
if (!isAtBottom) {
|
||||
cancelPendingStickToBottom();
|
||||
setFollowOutput(false);
|
||||
}
|
||||
pendingUserScrollUpIntentRef.current = false;
|
||||
}
|
||||
|
||||
lastKnownScrollTopRef.current = currentScrollTop;
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
evaluateHistoryStart,
|
||||
isJumpSettling,
|
||||
stopFollowingOutputFromUserIntent,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
@@ -718,7 +580,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [evaluateHistoryStart, props.agentId]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActiveRef.current || !isActivationReady) {
|
||||
if (!isActivationReady) {
|
||||
return;
|
||||
}
|
||||
if (hasRouteBottomAnchorRequest && !followOutputRef.current) {
|
||||
@@ -753,14 +615,13 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
// Following output is a layout invariant: rows, footer, and bottom offset must
|
||||
// reach the browser in the same paint.
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive || !followOutputRef.current) {
|
||||
if (!followOutputRef.current) {
|
||||
return;
|
||||
}
|
||||
cancelPendingStickToBottom();
|
||||
scrollMessagesToBottom("auto");
|
||||
}, [
|
||||
cancelPendingStickToBottom,
|
||||
isActive,
|
||||
renderLiveAuxiliary,
|
||||
scrollMessagesToBottom,
|
||||
segments.historyMounted,
|
||||
@@ -770,9 +631,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
updateScrollMetrics();
|
||||
evaluateHistoryStart();
|
||||
if (historyStartPaginationStateRef.current.status === "settling") {
|
||||
@@ -781,7 +639,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
evaluateHistoryStart,
|
||||
hasOlderHistory,
|
||||
isActive,
|
||||
isLoadingOlderHistory,
|
||||
olderHistoryProgressKey,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
@@ -792,10 +649,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
virtualTotalSize,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
const contentNode = contentRef.current;
|
||||
if (!scrollContainer || typeof ResizeObserver === "undefined") {
|
||||
@@ -828,96 +682,22 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
}, [
|
||||
applyHistoryStartPrependAnchor,
|
||||
evaluateHistoryStart,
|
||||
isActive,
|
||||
scheduleHistoryStartPrependSettle,
|
||||
scheduleStickToBottom,
|
||||
updateScrollMetrics,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isActive) {
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef.current;
|
||||
if (!scrollContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const markUpwardViewportInput = () => {
|
||||
markUpwardInputEvidence();
|
||||
if (scrollContainer.scrollTop <= USER_SCROLL_DELTA_EPSILON) {
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
};
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (
|
||||
!event.ctrlKey &&
|
||||
event.deltaY < 0 &&
|
||||
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
|
||||
) {
|
||||
markUpwardViewportInput();
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isUpwardViewportScrollKey(event)) {
|
||||
return;
|
||||
}
|
||||
if (canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)) {
|
||||
return;
|
||||
}
|
||||
markUpwardViewportInput();
|
||||
};
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
clearMouseScrollGesture();
|
||||
if (
|
||||
event.pointerType !== "mouse" ||
|
||||
!event.isPrimary ||
|
||||
(event.button !== 0 && event.button !== 1) ||
|
||||
isEditableEventTarget(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (event.button === 0) {
|
||||
if (isVerticalScrollbarGutterPress(event, scrollContainer)) {
|
||||
mouseScrollGestureRef.current = { kind: "scrollbar", pointerId: event.pointerId };
|
||||
}
|
||||
return;
|
||||
}
|
||||
mouseScrollGestureRef.current = {
|
||||
kind: "autoscroll",
|
||||
pointerId: event.pointerId,
|
||||
lastClientY: event.clientY,
|
||||
hasUpwardEvidence: false,
|
||||
evidenceExpiryFrame: null,
|
||||
};
|
||||
};
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
const gesture = mouseScrollGestureRef.current;
|
||||
if (!gesture || gesture.kind !== "autoscroll" || gesture.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
if (event.clientY < gesture.lastClientY - USER_SCROLL_DELTA_EPSILON) {
|
||||
gesture.hasUpwardEvidence = true;
|
||||
if (gesture.evidenceExpiryFrame !== null) {
|
||||
window.cancelAnimationFrame(gesture.evidenceExpiryFrame);
|
||||
}
|
||||
gesture.evidenceExpiryFrame = window.requestAnimationFrame(() => {
|
||||
if (mouseScrollGestureRef.current === gesture) {
|
||||
mouseScrollGestureRef.current = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
gesture.lastClientY = event.clientY;
|
||||
};
|
||||
const handlePointerEnd = (event: PointerEvent) => {
|
||||
const gesture = mouseScrollGestureRef.current;
|
||||
if (gesture?.pointerId === event.pointerId && gesture.kind === "scrollbar") {
|
||||
clearMouseScrollGesture();
|
||||
}
|
||||
};
|
||||
const handlePointerCancel = (event: PointerEvent) => {
|
||||
if (mouseScrollGestureRef.current?.pointerId === event.pointerId) {
|
||||
clearMouseScrollGesture();
|
||||
if (event.deltaY < 0) {
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
};
|
||||
const handleTouchStart = (event: TouchEvent) => {
|
||||
@@ -933,12 +713,10 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return;
|
||||
}
|
||||
const previousTouchY = lastTouchClientYRef.current;
|
||||
if (
|
||||
previousTouchY !== null &&
|
||||
touch.clientY > previousTouchY + 1 &&
|
||||
!canNestedScrollerConsumeUpwardInput(event.target, scrollContainer)
|
||||
) {
|
||||
markUpwardViewportInput();
|
||||
if (previousTouchY !== null && touch.clientY > previousTouchY + 1) {
|
||||
pendingUserScrollUpIntentRef.current = true;
|
||||
cancelPendingStickToBottom();
|
||||
rearmHistoryStartFromUserIntent();
|
||||
}
|
||||
lastTouchClientYRef.current = touch.clientY;
|
||||
};
|
||||
@@ -948,12 +726,6 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
|
||||
scrollContainer.addEventListener("scroll", handleDomScroll, { passive: true });
|
||||
scrollContainer.addEventListener("wheel", handleWheel, { passive: true });
|
||||
scrollContainer.addEventListener("keydown", handleKeyDown, { passive: true });
|
||||
scrollContainer.addEventListener("pointerdown", handlePointerDown, { passive: true });
|
||||
window.addEventListener("pointermove", handlePointerMove, { passive: true });
|
||||
window.addEventListener("pointerup", handlePointerEnd, { passive: true });
|
||||
window.addEventListener("pointercancel", handlePointerCancel, { passive: true });
|
||||
window.addEventListener("blur", clearMouseScrollGesture);
|
||||
scrollContainer.addEventListener("touchstart", handleTouchStart, { passive: true });
|
||||
scrollContainer.addEventListener("touchmove", handleTouchMove, { passive: true });
|
||||
scrollContainer.addEventListener("touchend", handleTouchEnd, { passive: true });
|
||||
@@ -962,26 +734,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
|
||||
return () => {
|
||||
scrollContainer.removeEventListener("scroll", handleDomScroll);
|
||||
scrollContainer.removeEventListener("wheel", handleWheel);
|
||||
scrollContainer.removeEventListener("keydown", handleKeyDown);
|
||||
scrollContainer.removeEventListener("pointerdown", handlePointerDown);
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerEnd);
|
||||
window.removeEventListener("pointercancel", handlePointerCancel);
|
||||
window.removeEventListener("blur", clearMouseScrollGesture);
|
||||
scrollContainer.removeEventListener("touchstart", handleTouchStart);
|
||||
scrollContainer.removeEventListener("touchmove", handleTouchMove);
|
||||
scrollContainer.removeEventListener("touchend", handleTouchEnd);
|
||||
scrollContainer.removeEventListener("touchcancel", handleTouchEnd);
|
||||
clearUpwardInputEvidence();
|
||||
};
|
||||
}, [
|
||||
clearMouseScrollGesture,
|
||||
clearUpwardInputEvidence,
|
||||
handleDomScroll,
|
||||
isActive,
|
||||
markUpwardInputEvidence,
|
||||
rearmHistoryStartFromUserIntent,
|
||||
]);
|
||||
}, [cancelPendingStickToBottom, handleDomScroll, rearmHistoryStartFromUserIntent]);
|
||||
|
||||
useEffect(() => {
|
||||
const handle: StreamViewportHandle = {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type React from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import type { Virtualizer } from "@tanstack/react-virtual";
|
||||
import { createPromptJumpSettleController, PROMPT_JUMP_TOP_INSET_PX } from "./prompt-jump-settle";
|
||||
|
||||
interface UseScrollToMessageInput {
|
||||
active: boolean;
|
||||
scrollContainerRef: React.RefObject<HTMLElement | null>;
|
||||
rowVirtualizer: Virtualizer<HTMLElement, Element>;
|
||||
historyVirtualized: readonly { id: string }[];
|
||||
@@ -24,7 +23,6 @@ const SCROLL_AFFECTING_KEYS = new Set([
|
||||
]);
|
||||
|
||||
export function useScrollToMessage({
|
||||
active,
|
||||
scrollContainerRef,
|
||||
rowVirtualizer,
|
||||
historyVirtualized,
|
||||
@@ -81,15 +79,9 @@ export function useScrollToMessage({
|
||||
);
|
||||
|
||||
useEffect(() => () => settleController.cancel(), [settleController]);
|
||||
useLayoutEffect(() => {
|
||||
if (!active) {
|
||||
settleController.cancel();
|
||||
}
|
||||
}, [active, settleController]);
|
||||
|
||||
const scrollToMessage = useCallback(
|
||||
(itemId: string) => {
|
||||
if (!active) return;
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container) return;
|
||||
cancelPendingStickToBottom();
|
||||
@@ -117,7 +109,6 @@ export function useScrollToMessage({
|
||||
}
|
||||
},
|
||||
[
|
||||
active,
|
||||
cancelPendingStickToBottom,
|
||||
historyVirtualized,
|
||||
onNearBottomChange,
|
||||
|
||||
@@ -269,14 +269,6 @@ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
|
||||
];
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
|
||||
function useRetainedValue<T>(value: T, active: boolean): T {
|
||||
const retainedRef = useRef(value);
|
||||
if (active) {
|
||||
retainedRef.current = value;
|
||||
}
|
||||
return active ? value : retainedRef.current;
|
||||
}
|
||||
const EMPTY_PENDING_MESSAGE_SUBMISSIONS: readonly PendingMessageSubmission[] = [];
|
||||
const GROUPED_TOOL_CALL_DETAIL_MAX_HEIGHT = 200;
|
||||
|
||||
@@ -473,15 +465,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
});
|
||||
});
|
||||
|
||||
// Freeze stream presentation while this tab slot is hidden to prevent offscreen
|
||||
// cell-window and turn-lifecycle renders from background agents.
|
||||
// Freeze stream data while this tab slot is hidden to prevent offscreen FlatList
|
||||
// cell-window renders on every 48ms flush from background agents.
|
||||
// When isActive flips back to true, the context change triggers a re-render and
|
||||
// the component reads the current (fresh) streamItems/streamHead from props.
|
||||
const isActive = useRetainedPanelActive();
|
||||
const effectiveStreamItems = useRetainedValue(streamItems, isActive);
|
||||
const effectiveStreamHead = useRetainedValue(streamHead, isActive);
|
||||
const effectiveTurnPresentation = useRetainedValue(turnPresentation, isActive);
|
||||
const isTurnActive = effectiveTurnPresentation.isActive;
|
||||
const frozenStreamItemsRef = useRef(streamItems);
|
||||
const frozenStreamHeadRef = useRef(streamHead);
|
||||
if (isActive) {
|
||||
frozenStreamItemsRef.current = streamItems;
|
||||
frozenStreamHeadRef.current = streamHead;
|
||||
}
|
||||
const effectiveStreamItems = isActive ? streamItems : frozenStreamItemsRef.current;
|
||||
const effectiveStreamHead = isActive ? streamHead : frozenStreamHeadRef.current;
|
||||
const isTurnActive = turnPresentation.isActive;
|
||||
// Keep retained history outside the 48ms live-head flush path.
|
||||
const preparedToolCallHistory = useMemo(
|
||||
() => prepareToolCallHistory(toolCallDetailLevel, effectiveStreamItems),
|
||||
@@ -508,7 +505,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
isTurnActive,
|
||||
activeTurnStartedAt: effectiveTurnPresentation.startedAt,
|
||||
activeTurnStartedAt: turnPresentation.startedAt,
|
||||
tail: projectedToolCalls.tail,
|
||||
head: projectedToolCalls.head,
|
||||
platform: isWeb ? "web" : "native",
|
||||
@@ -519,7 +516,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
isTurnActive,
|
||||
projectedToolCalls.head,
|
||||
projectedToolCalls.tail,
|
||||
effectiveTurnPresentation.startedAt,
|
||||
turnPresentation.startedAt,
|
||||
]);
|
||||
const streamLayout = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -940,7 +940,9 @@ function RuntimeProviders({ children }: { children: ReactNode }) {
|
||||
<HostRuntimeBootstrapProvider>
|
||||
<PushNotificationRouter />
|
||||
<SidebarCalloutProvider>
|
||||
<ProvidersWrapper>{children}</ProvidersWrapper>
|
||||
<ToastProvider>
|
||||
<ProvidersWrapper>{children}</ProvidersWrapper>
|
||||
</ToastProvider>
|
||||
</SidebarCalloutProvider>
|
||||
</HostRuntimeBootstrapProvider>
|
||||
);
|
||||
@@ -958,11 +960,9 @@ function RootProviders({ children }: { children: ReactNode }) {
|
||||
<WindowChromeProvider>
|
||||
<KeyboardProvider>
|
||||
<KeyboardShiftProvider>
|
||||
<ToastProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</ToastProvider>
|
||||
<PortalProvider>
|
||||
<BottomSheetModalProvider>{children}</BottomSheetModalProvider>
|
||||
</PortalProvider>
|
||||
</KeyboardShiftProvider>
|
||||
</KeyboardProvider>
|
||||
</WindowChromeProvider>
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import { useLocalSearchParams } from "expo-router";
|
||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
||||
import { SchedulesScreen } from "@/screens/schedules-screen";
|
||||
import { parseScheduleFocus } from "@/utils/host-routes";
|
||||
|
||||
export default function SchedulesRoute() {
|
||||
const params = useLocalSearchParams<{ serverId?: string; scheduleId?: string }>();
|
||||
const serverId = typeof params.serverId === "string" ? params.serverId : undefined;
|
||||
const scheduleId = typeof params.scheduleId === "string" ? params.scheduleId : undefined;
|
||||
const focus = useMemo(() => parseScheduleFocus({ serverId, scheduleId }), [serverId, scheduleId]);
|
||||
|
||||
return (
|
||||
<HostRouteBootstrapBoundary>
|
||||
<SchedulesScreen />
|
||||
<SchedulesScreen focus={focus} />
|
||||
</HostRouteBootstrapBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -592,7 +592,6 @@ export function CommandCenter() {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={COMMAND_CENTER_SNAP_POINTS}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { keyboardActionDispatcher } from "@/keyboard/keyboard-action-dispatcher";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { resolveShortcutKeysForAction } from "@/keyboard/keyboard-shortcuts";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
@@ -78,7 +77,6 @@ function HomeIcon({ size }: CommandCenterIconProps) {
|
||||
export function CommandCenterRootActions() {
|
||||
const { t } = useTranslation();
|
||||
const { overrides } = useKeyboardShortcutOverrides();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const settingsRoute = useMemo<Href>(() => buildSettingsRoute(), []);
|
||||
const homeRoute = useMemo<Href>(() => buildOpenProjectRoute(), []);
|
||||
@@ -89,8 +87,8 @@ export function CommandCenterRootActions() {
|
||||
() => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }),
|
||||
[],
|
||||
);
|
||||
const actions = useMemo<CommandCenterContribution[]>(() => {
|
||||
const availableActions: CommandCenterContribution[] = [
|
||||
const actions = useMemo<CommandCenterContribution[]>(
|
||||
() => [
|
||||
{
|
||||
id: "add-project",
|
||||
group: "actions",
|
||||
@@ -206,10 +204,7 @@ export function CommandCenterRootActions() {
|
||||
undefined,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (shortcutsAvailable) {
|
||||
availableActions.push({
|
||||
{
|
||||
id: "keyboard-shortcuts",
|
||||
group: "actions",
|
||||
groupRank: 0,
|
||||
@@ -226,22 +221,20 @@ export function CommandCenterRootActions() {
|
||||
resolveShortcutKeysForAction("show-shortcuts", overrides, shortcutPlatform) ??
|
||||
undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return availableActions;
|
||||
}, [
|
||||
homeRoute,
|
||||
openAddProject,
|
||||
overrides,
|
||||
schedulesRoute,
|
||||
sessionsRoute,
|
||||
setShortcutsDialogOpen,
|
||||
settingsRoute,
|
||||
shortcutPlatform,
|
||||
shortcutsAvailable,
|
||||
t,
|
||||
]);
|
||||
},
|
||||
],
|
||||
[
|
||||
homeRoute,
|
||||
openAddProject,
|
||||
overrides,
|
||||
schedulesRoute,
|
||||
sessionsRoute,
|
||||
setShortcutsDialogOpen,
|
||||
settingsRoute,
|
||||
shortcutPlatform,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
useCommandCenterActions({ sourceId: "root", enabled: true, actions });
|
||||
return null;
|
||||
|
||||
@@ -674,7 +674,6 @@ export function AdaptiveModalSheet({
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={sheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={resolvedSnapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
type ProjectPickerOption,
|
||||
} from "@/components/project-picker-options";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { getIsElectronRuntime } from "@/constants/layout";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { pickDirectory } from "@/desktop/pick-directory";
|
||||
@@ -288,9 +287,6 @@ function FlowRow({ option, active }: { option: FlowRowOption; active: boolean })
|
||||
}
|
||||
|
||||
function FlowHint({ keys, action }: { keys: string[]; action: string }) {
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
if (!shortcutsAvailable) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.footerHint}>
|
||||
<Shortcut keys={keys} textStyle={styles.footerKeyText} />
|
||||
|
||||
@@ -36,11 +36,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
additions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.statusMutedSuccess,
|
||||
color: theme.colors.diffStatAddition,
|
||||
},
|
||||
deletions: {
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.statusMutedDanger,
|
||||
color: theme.colors.diffStatDeletion,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import { useMemo, type ReactElement, type ReactNode } from "react";
|
||||
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, type Theme } from "@/styles/theme";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu";
|
||||
Copy,
|
||||
Download,
|
||||
FileText,
|
||||
MessageSquarePlus,
|
||||
MoreVertical,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-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;
|
||||
@@ -18,7 +33,7 @@ interface FileAction {
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
interface FileActionsContextMenuContentProps {
|
||||
interface FileActionsMenuProps {
|
||||
fileKind: "file" | "directory";
|
||||
fileExists?: boolean;
|
||||
onOpenFile?: () => void;
|
||||
@@ -27,14 +42,32 @@ interface FileActionsContextMenuContentProps {
|
||||
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 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.
|
||||
* 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.
|
||||
*/
|
||||
export function FileActionsContextMenuContent({
|
||||
export function FileActionsMenu({
|
||||
fileKind,
|
||||
fileExists = true,
|
||||
onOpenFile,
|
||||
@@ -42,8 +75,12 @@ export function FileActionsContextMenuContent({
|
||||
onDownload,
|
||||
onAddToChat,
|
||||
header,
|
||||
open,
|
||||
onOpenChange,
|
||||
hitSlop = 12,
|
||||
accessibilityLabel,
|
||||
testIDPrefix,
|
||||
}: FileActionsContextMenuContentProps): ReactElement | null {
|
||||
}: FileActionsMenuProps): ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const actions = useMemo<FileAction[]>(() => {
|
||||
const availableFile = fileKind === "file" && fileExists;
|
||||
@@ -89,21 +126,28 @@ export function FileActionsContextMenuContent({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,8 +159,27 @@ function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
|
||||
[ThemedIcon],
|
||||
);
|
||||
return (
|
||||
<ContextMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
|
||||
<DropdownMenuItem leading={leading} onSelect={action.onSelect} testID={action.testID}>
|
||||
{action.label}
|
||||
</ContextMenuItem>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
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")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native";
|
||||
import { MaterialFileIcon } from "@/components/material-file-icon";
|
||||
@@ -20,17 +20,9 @@ import {
|
||||
TreeChevron,
|
||||
TreeIndentGuides,
|
||||
treeRowPaddingLeft,
|
||||
WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
WORKSPACE_TREE_ICON_LABEL_GAP,
|
||||
WORKSPACE_TREE_ICON_SIZE,
|
||||
WORKSPACE_TREE_LOADING_ICON_SIZE,
|
||||
} from "@/components/tree-primitives";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
useOverlayFlatListScrollbar,
|
||||
type OverlayFlatListScrollbar,
|
||||
} from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type {
|
||||
AgentFileExplorerState,
|
||||
@@ -38,8 +30,7 @@ import type {
|
||||
ExplorerEntry,
|
||||
} from "@/stores/session-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
|
||||
import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
|
||||
import { FileActionsMenu } from "@/components/file-actions-menu";
|
||||
import { useFileDownload } from "@/hooks/use-file-download";
|
||||
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
|
||||
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
|
||||
@@ -181,40 +172,34 @@ function TreeRowItem({
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger 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={WORKSPACE_TREE_ICON_SIZE} />;
|
||||
}
|
||||
if (loading) {
|
||||
return (
|
||||
<ThemedLoadingSpinner
|
||||
size={WORKSPACE_TREE_LOADING_ICON_SIZE}
|
||||
uniProps={foregroundMutedColorMapping}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <TreeChevron expanded={isExpanded} />;
|
||||
})()}
|
||||
</View>
|
||||
<Text style={styles.entryName} numberOfLines={1}>
|
||||
{entry.name}
|
||||
</Text>
|
||||
<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} />;
|
||||
})()}
|
||||
</View>
|
||||
</ContextMenuTrigger>
|
||||
<FileActionsContextMenuContent
|
||||
<Text style={styles.entryName} numberOfLines={1}>
|
||||
{entry.name}
|
||||
</Text>
|
||||
</View>
|
||||
<FileActionsMenu
|
||||
fileKind={entry.kind}
|
||||
onCopyPath={handleCopy}
|
||||
onDownload={handleDownload}
|
||||
onAddToChat={onAddToChat ? handleAddToChat : undefined}
|
||||
header={metaHeader}
|
||||
accessibilityLabel={t("workspace.fileActions.moreActions")}
|
||||
testIDPrefix={testID}
|
||||
/>
|
||||
</ContextMenu>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,7 +219,6 @@ export function FileExplorerPane({
|
||||
onAddToChat,
|
||||
}: FileExplorerPaneProps) {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
|
||||
const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]);
|
||||
const workspaceStateKey = useMemo(
|
||||
@@ -287,7 +271,6 @@ export function FileExplorerPane({
|
||||
);
|
||||
|
||||
const treeListRef = useRef<FlatList<ExplorerTreeRow>>(null);
|
||||
const scrollbar = useOverlayFlatListScrollbar(treeListRef, { enabled: !isCompact });
|
||||
|
||||
const hasInitializedRef = useRef(false);
|
||||
|
||||
@@ -528,7 +511,6 @@ export function FileExplorerPane({
|
||||
currentSortLabel={currentSortLabel}
|
||||
isRefreshFetching={isRefreshFetching}
|
||||
treeListRef={treeListRef}
|
||||
scrollbar={scrollbar}
|
||||
renderTreeRow={renderTreeRow}
|
||||
handleSortCycle={handleSortCycle}
|
||||
handleToggleHiddenFiles={handleToggleHiddenFiles}
|
||||
@@ -550,7 +532,6 @@ interface FileExplorerPaneContentProps {
|
||||
currentSortLabel: string;
|
||||
isRefreshFetching: boolean;
|
||||
treeListRef: RefObject<FlatList<ExplorerTreeRow> | null>;
|
||||
scrollbar: OverlayFlatListScrollbar;
|
||||
renderTreeRow: (info: ListRenderItemInfo<ExplorerTreeRow>) => ReactElement;
|
||||
handleSortCycle: () => void;
|
||||
handleToggleHiddenFiles: () => void;
|
||||
@@ -572,7 +553,6 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
currentSortLabel,
|
||||
isRefreshFetching,
|
||||
treeListRef,
|
||||
scrollbar,
|
||||
renderTreeRow,
|
||||
handleSortCycle,
|
||||
handleToggleHiddenFiles,
|
||||
@@ -695,17 +675,12 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) {
|
||||
keyExtractor={treeRowKeyExtractor}
|
||||
testID="file-explorer-tree-scroll"
|
||||
contentContainerStyle={styles.entriesContent}
|
||||
onLayout={scrollbar.onLayout}
|
||||
onScroll={scrollbar.onScroll}
|
||||
onContentSizeChange={scrollbar.onContentSizeChange}
|
||||
scrollEventThrottle={16}
|
||||
showsVerticalScrollIndicator={!scrollbar.enabled}
|
||||
showsVerticalScrollIndicator
|
||||
initialNumToRender={24}
|
||||
maxToRenderPerBatch={40}
|
||||
windowSize={12}
|
||||
/>
|
||||
)}
|
||||
{treeRows.length > 0 ? scrollbar.overlay : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1073,7 +1048,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING,
|
||||
paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING,
|
||||
paddingRight: theme.spacing[3],
|
||||
},
|
||||
entryRowActive: {
|
||||
backgroundColor: theme.colors.surfaceSidebarHover,
|
||||
@@ -1082,14 +1057,10 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: WORKSPACE_TREE_ICON_LABEL_GAP,
|
||||
gap: theme.spacing[2],
|
||||
minWidth: 0,
|
||||
},
|
||||
entryIcon: {
|
||||
width: WORKSPACE_TREE_ICON_SIZE,
|
||||
height: WORKSPACE_TREE_ICON_SIZE,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
entryName: {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -1,39 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -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/display-preferences/menu";
|
||||
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/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";
|
||||
@@ -988,11 +988,11 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: theme.spacing[2],
|
||||
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title
|
||||
// lands at spacing[2] left to align with project icons. Settings2's painted path stops
|
||||
// inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail.
|
||||
// Rendered inside the scroll's listContent (paddingHorizontal spacing[2]), so the
|
||||
// title lands at spacing[2] left to align with project icons, and the trailing
|
||||
// pill sits flush with the list edge on the right.
|
||||
paddingLeft: theme.spacing[2],
|
||||
paddingRight: 4,
|
||||
paddingRight: 0,
|
||||
paddingTop: theme.spacing[1],
|
||||
paddingBottom: theme.spacing[1],
|
||||
},
|
||||
@@ -1030,9 +1030,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
pointerEvents: "box-none",
|
||||
},
|
||||
mobileCloseButton: {
|
||||
// The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph.
|
||||
// This optical inset puts their painted right edges on the same sidebar rail.
|
||||
marginRight: theme.spacing[2] + 1.5,
|
||||
marginRight: theme.spacing[4],
|
||||
width: 32,
|
||||
height: 32,
|
||||
alignItems: "center",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { SvgXml } from "react-native-svg";
|
||||
import { getFileIconSvg } from "@/components/file-icon-svg";
|
||||
import { getFileIconSvg } from "@/components/material-file-icons";
|
||||
import type { PanelIconProps } from "@/panels/panel-registry";
|
||||
|
||||
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
// 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.
|
||||
// Auto-generated from material-icon-theme. Do not edit manually.
|
||||
|
||||
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>`,
|
||||
@@ -129,7 +125,7 @@ const EXTENSION_TO_ICON: Record<string, string> = {
|
||||
zig: "zig",
|
||||
};
|
||||
|
||||
export function getRawFileIconSvg(fileName: string): string {
|
||||
export function getFileIconSvg(fileName: string): string {
|
||||
const ext = getExtension(fileName);
|
||||
if (ext) {
|
||||
const iconName = EXTENSION_TO_ICON[ext];
|
||||
|
||||
@@ -162,7 +162,8 @@ export function ProjectEditSheet({
|
||||
iconDataUri={state.previewDataUri}
|
||||
initial={projectInitial(snapshot.projectName)}
|
||||
projectViewKey={projectViewKey}
|
||||
size={40}
|
||||
imageStyle={styles.preview}
|
||||
fallbackStyle={styles.previewFallback}
|
||||
textStyle={styles.previewText}
|
||||
/>
|
||||
<Button
|
||||
@@ -272,6 +273,18 @@ 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,62 +1,44 @@
|
||||
import { useMemo } from "react";
|
||||
import { Image, type StyleProp, Text, type TextStyle, View } from "react-native";
|
||||
import {
|
||||
Image,
|
||||
type ImageStyle,
|
||||
type StyleProp,
|
||||
Text,
|
||||
type TextStyle,
|
||||
View,
|
||||
type ViewStyle,
|
||||
} 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,
|
||||
size,
|
||||
imageStyle,
|
||||
fallbackStyle,
|
||||
textStyle,
|
||||
}: {
|
||||
iconDataUri: string | null;
|
||||
initial: string;
|
||||
projectViewKey: string;
|
||||
size: number;
|
||||
imageStyle: StyleProp<ImageStyle>;
|
||||
fallbackStyle: StyleProp<ViewStyle>;
|
||||
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(
|
||||
() => [
|
||||
box,
|
||||
{ borderRadius: projectIconRadius(size) },
|
||||
FALLBACK_LAYOUT,
|
||||
fallbackStyle,
|
||||
{ backgroundColor: identityColor(deriveIdentityColorName(projectViewKey)) },
|
||||
],
|
||||
[box, size, projectViewKey],
|
||||
[fallbackStyle, projectViewKey],
|
||||
);
|
||||
const textStyles = useMemo(() => [textStyle, WHITE_TEXT], [textStyle]);
|
||||
|
||||
if (iconDataUri) {
|
||||
return <Image source={imageSource} style={box} />;
|
||||
return <Image source={imageSource} style={imageStyle} />;
|
||||
}
|
||||
return (
|
||||
<View style={fallbackStyles}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, memo, type ReactNode, useContext } from "react";
|
||||
import { createContext, memo, type ReactNode, useContext } from "react";
|
||||
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
||||
|
||||
const RetainedPanelActiveContext = createContext(true);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { StyleSheet } from "react-native-unistyles";
|
||||
import { ScheduleRow, type ScheduleRowPending } from "@/components/schedules/schedule-row";
|
||||
import { useScheduleMutations } from "@/hooks/use-schedule-mutations";
|
||||
import type { AggregatedSchedule } from "@/hooks/use-schedules";
|
||||
import { resolveDeleteScheduleDialog } from "@/schedules/delete-schedule";
|
||||
import type { ScheduleDerivedState } from "@/schedules/schedule-derivation";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
@@ -113,13 +114,12 @@ function SchedulesTableRow({
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
void (async () => {
|
||||
const productName = scheduleProductName(schedule);
|
||||
const confirmed = await confirmDialog({
|
||||
title: `Delete ${productName.toLowerCase()}`,
|
||||
message: `Delete "${resolveScheduleTitle(schedule)}"? This cannot be undone.`,
|
||||
confirmLabel: "Delete",
|
||||
destructive: true,
|
||||
});
|
||||
const confirmed = await confirmDialog(
|
||||
resolveDeleteScheduleDialog({
|
||||
productName: scheduleProductName(schedule),
|
||||
title: resolveScheduleTitle(schedule),
|
||||
}),
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
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}`);
|
||||
@@ -119,10 +116,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
projectIcon: {
|
||||
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),
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
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,11 +111,6 @@ 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";
|
||||
@@ -147,7 +142,6 @@ 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;
|
||||
|
||||
@@ -169,13 +163,13 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const redColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.statusMutedDanger,
|
||||
color: theme.colors.palette.red[500],
|
||||
});
|
||||
const greenColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.statusMutedSuccess,
|
||||
color: theme.colors.palette.green[500],
|
||||
});
|
||||
const purpleColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.statusMutedMerged,
|
||||
color: theme.colors.palette.purple[500],
|
||||
});
|
||||
|
||||
function getPrIconUniMapping(state: PrHint["state"]) {
|
||||
@@ -359,6 +353,7 @@ 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>
|
||||
@@ -495,7 +490,7 @@ function ProjectKebabMenu({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={projectKebabStyle}
|
||||
@@ -505,7 +500,7 @@ function ProjectKebabMenu({
|
||||
>
|
||||
{renderKebabTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} sheetTitle={t("sidebar.project.actions.menu")}>
|
||||
<DropdownMenuContent align="end" width={220}>
|
||||
<ProjectMenuItems
|
||||
surface="dropdown"
|
||||
projectViewKey={projectViewKey}
|
||||
@@ -647,32 +642,34 @@ function WorkspaceRowRightGroup({
|
||||
}) {
|
||||
const workspacePath = workspace.workspaceDirectory ?? workspace.projectRootPath;
|
||||
const { t } = useTranslation();
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const {
|
||||
showTrailing,
|
||||
showDiffStat,
|
||||
showKebab: showKebabInSlot,
|
||||
showScrim,
|
||||
renderSlot,
|
||||
reserveSlotWidth,
|
||||
} = resolveTrailingActionVisibility({
|
||||
workspace,
|
||||
trailing,
|
||||
hasDiffStat: Boolean(workspace.diffStat),
|
||||
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}
|
||||
{renderSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
{shouldRenderActionSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
|
||||
{onArchive ? (
|
||||
@@ -931,7 +928,6 @@ function ProjectHeaderRow({
|
||||
iconDataUri={iconDataUri}
|
||||
statusBucket={statusBucket}
|
||||
projectViewKey={project.viewKey}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
chevron={chevron}
|
||||
showChevron={isHovered && chevron !== null}
|
||||
isArchiving={isArchiving}
|
||||
@@ -1082,7 +1078,14 @@ function WorkspaceRowInner({
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
|
||||
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 workspaceRowStyle = getProjectWorkspaceRowStyle({
|
||||
isDragging,
|
||||
selected,
|
||||
@@ -1130,8 +1133,7 @@ function WorkspaceRowInner({
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={leadingProjectName}
|
||||
leadingProjectIconDataUri={leadingProjectIconDataUri}
|
||||
scriptSummary={scriptSummary}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
@@ -1890,20 +1892,15 @@ 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: rowItems.host && shouldShowSidebarHostLabels(projects),
|
||||
enabled: shouldShowSidebarHostLabels(projects),
|
||||
}),
|
||||
[hosts, localDaemon.status, localServerId, projects, rowItems.host],
|
||||
[hosts, localDaemon.status, localServerId, projects],
|
||||
);
|
||||
const serverIds = useMemo(() => hosts.map((host) => host.serverId), [hosts]);
|
||||
const supportsMultiplicityByServerId = useHostFeatureMap(serverIds, "workspaceMultiplicity");
|
||||
@@ -2541,9 +2538,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
gap: 2,
|
||||
flexShrink: 0,
|
||||
// MoreVertical paints only around the center of its 14px SVG. Keep the 24px controls,
|
||||
// but pull their painted edge through the unused view-box space onto the row rail.
|
||||
marginRight: -6,
|
||||
},
|
||||
projectKebabButton: {
|
||||
width: 24,
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -1,112 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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 { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import 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 = 12;
|
||||
const STATUS_BADGE_SIZE = 14;
|
||||
const STATUS_BADGE_BORDER_WIDTH = 1;
|
||||
// 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 = -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.
|
||||
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.
|
||||
//
|
||||
// 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;
|
||||
// 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;
|
||||
// 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,7 +55,6 @@ export function ProjectLeadingVisual({
|
||||
iconDataUri,
|
||||
statusBucket,
|
||||
projectViewKey,
|
||||
backdrop,
|
||||
chevron = null,
|
||||
showChevron = false,
|
||||
isArchiving = false,
|
||||
@@ -65,8 +64,6 @@ 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;
|
||||
@@ -93,7 +90,6 @@ export function ProjectLeadingVisual({
|
||||
displayName={displayName}
|
||||
projectViewKey={projectViewKey}
|
||||
statusBucket={statusBucket}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -108,7 +104,6 @@ export function ProjectStatusIndicator({
|
||||
displayName,
|
||||
projectViewKey,
|
||||
statusBucket,
|
||||
backdrop,
|
||||
loading = false,
|
||||
testID,
|
||||
}: {
|
||||
@@ -116,15 +111,13 @@ 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 this size
|
||||
// A row that's still starting up and a row that's working are both "busy", and at 8pt
|
||||
// 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;
|
||||
@@ -147,11 +140,7 @@ export function ProjectStatusIndicator({
|
||||
projectViewKey={projectViewKey}
|
||||
/>
|
||||
{badgeContent === null || badgeBucket === null ? null : (
|
||||
<ProjectStatusBadge
|
||||
content={badgeContent}
|
||||
statusBucket={badgeBucket}
|
||||
backdrop={backdrop}
|
||||
/>
|
||||
<ProjectStatusBadge content={badgeContent} statusBucket={badgeBucket} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
@@ -161,17 +150,15 @@ 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, getStatusBadgeBackdropStyle(backdrop)]}
|
||||
style={styles.statusBadge}
|
||||
testID="project-status-badge"
|
||||
>
|
||||
{content.kind === "alert" ? (
|
||||
@@ -183,17 +170,6 @@ 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)} />;
|
||||
@@ -215,7 +191,8 @@ function ProjectIcon({
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderInitial}
|
||||
projectViewKey={projectViewKey}
|
||||
size={ICON_SIZE.md}
|
||||
imageStyle={styles.projectIcon}
|
||||
fallbackStyle={styles.projectIconFallback}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
/>
|
||||
);
|
||||
@@ -268,13 +245,24 @@ 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. 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.
|
||||
// (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.
|
||||
statusBadge: {
|
||||
position: "absolute",
|
||||
right: STATUS_BADGE_OFFSET,
|
||||
@@ -282,13 +270,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"),
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -15,8 +15,9 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
@@ -77,10 +78,11 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const isCompactLayout = useIsCompactFormFactor();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen);
|
||||
const [open, setOpen] = useState(false);
|
||||
const showKeyboardShortcuts = !isNative && !isCompactLayout;
|
||||
const version = formatVersionWithPrefix(resolveAppVersion());
|
||||
const hosts = useHosts();
|
||||
|
||||
@@ -126,7 +128,7 @@ export function SidebarHelpMenu() {
|
||||
</Tooltip>
|
||||
<DropdownMenuContent side="top" align="end" offset={8} width={280} testID="sidebar-help-menu">
|
||||
<DropdownMenuLabel>{t("sidebar.help.sectionHelp")}</DropdownMenuLabel>
|
||||
{shortcutsAvailable ? (
|
||||
{showKeyboardShortcuts ? (
|
||||
<DropdownMenuItem
|
||||
testID="sidebar-help-shortcuts"
|
||||
leading={shortcutsLeadingIcon}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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,6 +19,7 @@ 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";
|
||||
@@ -39,15 +40,7 @@ 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,
|
||||
@@ -128,7 +121,6 @@ export function SidebarStatusWorkspaceList({
|
||||
projectIconByProjectViewKey,
|
||||
hostBadgeByServerId,
|
||||
})}
|
||||
inStatusGroup={false}
|
||||
shortcutNumber={statusShortcutIndex.get(workspace.workspaceKey) ?? null}
|
||||
showShortcutBadge={showShortcutBadges}
|
||||
canPin={supportsPinningByServerId.get(workspace.serverId) === true}
|
||||
@@ -407,7 +399,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onWorkspacePress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -419,11 +410,6 @@ 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();
|
||||
@@ -449,7 +435,6 @@ const StatusWorkspaceRow = memo(function StatusWorkspaceRow({
|
||||
canPin={canPin}
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
@@ -466,7 +451,6 @@ function StatusWorkspaceRowWithMenu({
|
||||
canPin,
|
||||
onToggleWorkspacePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
onPress,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
@@ -479,11 +463,6 @@ 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();
|
||||
@@ -605,7 +584,6 @@ function StatusWorkspaceRowWithMenu({
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
inStatusGroup={inStatusGroup}
|
||||
/>
|
||||
<AdaptiveRenameModal
|
||||
visible={isRenameOpen}
|
||||
@@ -643,7 +621,6 @@ function StatusWorkspaceRowInner({
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
reserveIdleStatusIndicatorSpace = true,
|
||||
inStatusGroup = true,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
hostBadge: HostBadgeModel | null;
|
||||
@@ -666,17 +643,18 @@ 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 scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
|
||||
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 accessibilityState = useMemo(() => ({ selected }), [selected]);
|
||||
|
||||
@@ -685,24 +663,18 @@ function StatusWorkspaceRowInner({
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const {
|
||||
showTrailing,
|
||||
showDiffStat,
|
||||
showKebab: showKebabInSlot,
|
||||
showScrim,
|
||||
renderSlot,
|
||||
reserveSlotWidth,
|
||||
} = resolveTrailingActionVisibility({
|
||||
workspace,
|
||||
trailing,
|
||||
hasDiffStat: Boolean(workspace.diffStat),
|
||||
hasArchiveAction: Boolean(onArchive),
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
});
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({
|
||||
selected,
|
||||
isHovered,
|
||||
inStatusGroup,
|
||||
});
|
||||
const shouldRenderActionSlot = Boolean(onArchive || workspace.diffStat);
|
||||
const workspaceRowStyle = getStatusWorkspaceRowStyle({ selected, isHovered });
|
||||
return (
|
||||
<View style={styles.workspaceRowContainer} {...hoverHandlers}>
|
||||
<SidebarWorkspaceContextMenu
|
||||
@@ -711,7 +683,7 @@ function StatusWorkspaceRowInner({
|
||||
workspace={workspace}
|
||||
leadingProjectName={projectName}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
scriptSummary={scriptSummary}
|
||||
scriptIconKind={scriptIconKind}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -737,22 +709,19 @@ function StatusWorkspaceRowInner({
|
||||
hostBadge={hostBadge}
|
||||
leadingProjectName={projectName}
|
||||
leadingProjectIconDataUri={projectIconDataUri}
|
||||
scriptSummary={scriptSummary}
|
||||
backdrop={getSidebarRowBackdrop({ selected, isHovered })}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving}
|
||||
shortcutNumber={shortcutNumber}
|
||||
showShortcutBadge={showShortcutBadge}
|
||||
reserveIdleStatusIndicatorSpace={reserveIdleStatusIndicatorSpace}
|
||||
>
|
||||
{renderSlot ? (
|
||||
{shouldRenderActionSlot ? (
|
||||
<StatusWorkspaceActionSlot
|
||||
workspace={workspace}
|
||||
trailing={trailing}
|
||||
showBase={showTrailing}
|
||||
showBase={showDiffStat}
|
||||
showKebab={showKebabInSlot}
|
||||
showScrim={showScrim}
|
||||
reserveSlotWidth={reserveSlotWidth}
|
||||
isPinned={isPinned}
|
||||
onTogglePin={onTogglePin}
|
||||
onCopyPath={onCopyPath}
|
||||
@@ -777,11 +746,9 @@ function StatusWorkspaceRowInner({
|
||||
|
||||
function StatusWorkspaceActionSlot({
|
||||
workspace,
|
||||
trailing,
|
||||
showBase,
|
||||
showKebab,
|
||||
showScrim,
|
||||
reserveSlotWidth,
|
||||
isPinned,
|
||||
onTogglePin,
|
||||
onCopyPath,
|
||||
@@ -795,11 +762,9 @@ function StatusWorkspaceActionSlot({
|
||||
archiveShortcutKeys,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
showBase: boolean;
|
||||
showKebab: boolean;
|
||||
showScrim: boolean;
|
||||
reserveSlotWidth: boolean;
|
||||
isPinned?: boolean;
|
||||
onTogglePin?: () => void;
|
||||
onCopyPath?: () => void;
|
||||
@@ -813,9 +778,14 @@ function StatusWorkspaceActionSlot({
|
||||
archiveShortcutKeys?: ShortcutKey[][] | null;
|
||||
}) {
|
||||
return (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showBase}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebab} scrim={showScrim}>
|
||||
{showKebab && onArchive ? (
|
||||
@@ -842,15 +812,12 @@ 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,
|
||||
];
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Archive, CircleCheck, Copy, MoreVertical, Pencil, Pin, PinOff } from "lucide-react-native";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
import { getForgePresentation, normalizeForge } from "@/git/forge";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
@@ -24,7 +24,6 @@ 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) => ({
|
||||
@@ -110,7 +109,7 @@ function SidebarWorkspaceMenuItems({
|
||||
}: SidebarWorkspaceMenuItemsProps & { surface: MenuSurface }): ReactNode {
|
||||
const { t } = useTranslation();
|
||||
const archiveTrailing = useMemo(
|
||||
() => (archiveShortcutKeys ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
() => (archiveShortcutKeys && !isNative ? <Shortcut chord={archiveShortcutKeys} /> : null),
|
||||
[archiveShortcutKeys],
|
||||
);
|
||||
|
||||
@@ -205,7 +204,7 @@ export function SidebarWorkspaceMenu({
|
||||
}: SidebarWorkspaceMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
hitSlop={8}
|
||||
style={triggerStyle}
|
||||
@@ -215,7 +214,7 @@ export function SidebarWorkspaceMenu({
|
||||
>
|
||||
{renderTriggerIcon}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={260} sheetTitle={t("sidebar.workspace.actions.menu")}>
|
||||
<DropdownMenuContent align="end" width={260}>
|
||||
<SidebarWorkspaceMenuItems
|
||||
surface="dropdown"
|
||||
workspaceKey={workspaceKey}
|
||||
@@ -249,7 +248,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
workspace,
|
||||
leadingProjectName,
|
||||
hostBadgeLabel,
|
||||
scriptSummary,
|
||||
scriptIconKind,
|
||||
workspaceKey,
|
||||
onCopyPath,
|
||||
onCopyBranchName,
|
||||
@@ -273,7 +272,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
leadingProjectName?: string | null;
|
||||
hostBadgeLabel?: string | null;
|
||||
scriptSummary?: WorkspaceScriptSummary | null;
|
||||
scriptIconKind?: "service" | "command" | null;
|
||||
}
|
||||
>) {
|
||||
const {
|
||||
@@ -292,7 +291,7 @@ export function SidebarWorkspaceContextMenu({
|
||||
leadingProjectName,
|
||||
hostBadgeLabel,
|
||||
pullRequestLabel,
|
||||
scriptLabel: scriptSummary ? t("workspace.status.scriptsAvailable") : null,
|
||||
scriptLabel: scriptIconKind ? t("workspace.status.scriptsAvailable") : null,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -339,9 +338,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
padding: 2,
|
||||
borderRadius: 4,
|
||||
marginLeft: 2,
|
||||
// MoreVertical paints only around the center of its SVG. Keep the padded hit box, but
|
||||
// pull the painted dots through that unused view-box space onto the trailing-content rail.
|
||||
marginRight: -7,
|
||||
},
|
||||
triggerHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
|
||||
@@ -1,27 +1,38 @@
|
||||
import { memo, useId, useMemo, useCallback, useState, type ReactNode } from "react";
|
||||
import { Text, View, type ViewStyle } from "react-native";
|
||||
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 { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
|
||||
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";
|
||||
CircleAlert,
|
||||
Folder,
|
||||
FolderGit2,
|
||||
GitPullRequest,
|
||||
Globe,
|
||||
Monitor,
|
||||
SquareTerminal,
|
||||
} from "lucide-react-native";
|
||||
import { HostBadge } from "@/components/sidebar/host-badge";
|
||||
import { ProjectStatusIndicator } from "@/components/sidebar/project-leading-visual";
|
||||
import { PulsingStatusDot } from "@/components/sidebar/pulsing-status-dot";
|
||||
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
|
||||
@@ -29,17 +40,6 @@ 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,11 +47,18 @@ 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,
|
||||
@@ -80,6 +87,18 @@ 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,
|
||||
@@ -131,8 +150,7 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
hostBadge,
|
||||
leadingProjectName = null,
|
||||
leadingProjectIconDataUri = null,
|
||||
scriptSummary = null,
|
||||
backdrop,
|
||||
scriptIconKind = null,
|
||||
isHovered,
|
||||
isLoading,
|
||||
isCreating = false,
|
||||
@@ -146,9 +164,7 @@ 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;
|
||||
scriptSummary?: WorkspaceScriptSummary | null;
|
||||
/** The row's current background, so the project status badge can knock out of it. */
|
||||
backdrop: SurfaceBackdrop;
|
||||
scriptIconKind?: SidebarWorkspaceScriptIconKind | null;
|
||||
isHovered: boolean;
|
||||
isLoading: boolean;
|
||||
isCreating?: boolean;
|
||||
@@ -162,13 +178,17 @@ 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,
|
||||
],
|
||||
[isHovered, isCreating],
|
||||
[hasTitleAccessory, isHovered, isCreating],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -180,7 +200,6 @@ 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}`}
|
||||
/>
|
||||
@@ -194,16 +213,23 @@ export const SidebarWorkspaceRowContent = memo(function SidebarWorkspaceRowConte
|
||||
)}
|
||||
<View style={styles.workspaceContentColumn}>
|
||||
<View style={styles.workspaceTitleRow}>
|
||||
<Text style={workspaceBranchTextStyle} numberOfLines={1}>
|
||||
{workspaceLabel}
|
||||
</Text>
|
||||
<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>
|
||||
<View style={sidebarWorkspaceRowStyles.rowRight}>{children}</View>
|
||||
</View>
|
||||
<WorkspaceMetaRow
|
||||
hostBadge={hostBadge ?? null}
|
||||
prHint={workspace.prHint}
|
||||
scriptSummary={scriptSummary}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
{showShortcutBadge && shortcutNumber !== null ? (
|
||||
@@ -215,6 +241,22 @@ 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,
|
||||
@@ -326,6 +368,47 @@ 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":
|
||||
@@ -341,12 +424,19 @@ function getStatusDotColorStyle(bucket: SidebarStateBucket) {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
const pullRequestIconStyles = StyleSheet.create(() => ({
|
||||
pressable: {
|
||||
height: 20,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
pressableActive: {
|
||||
opacity: 0.82,
|
||||
},
|
||||
}));
|
||||
|
||||
export const sidebarWorkspaceRowStyles = StyleSheet.create((theme) => ({
|
||||
rowRight: {
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
@@ -372,16 +462,7 @@ 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,
|
||||
@@ -417,68 +498,30 @@ 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 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.
|
||||
* 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.
|
||||
*/
|
||||
export function resolveTrailingActionVisibility({
|
||||
workspace,
|
||||
trailing,
|
||||
hasDiffStat,
|
||||
hasArchiveAction,
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
showShortcut,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
hasDiffStat: boolean;
|
||||
hasArchiveAction: boolean;
|
||||
isHovered: boolean;
|
||||
isTouchPlatform: boolean;
|
||||
showShortcut: boolean;
|
||||
}): {
|
||||
showTrailing: boolean;
|
||||
showKebab: boolean;
|
||||
showScrim: boolean;
|
||||
renderSlot: boolean;
|
||||
reserveSlotWidth: boolean;
|
||||
} {
|
||||
const hasTrailing = hasSidebarWorkspaceTrailing({ workspace, trailing });
|
||||
}): { showDiffStat: boolean; showKebab: boolean; showScrim: boolean } {
|
||||
const showKebab = Boolean(hasArchiveAction && (isHovered || isTouchPlatform)) && !showShortcut;
|
||||
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),
|
||||
};
|
||||
const showDiffStat = hasDiffStat && !showShortcut && (isHovered || !showKebab);
|
||||
return { showDiffStat, showKebab, showScrim: showDiffStat && showKebab };
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceTrailingActionSlot({
|
||||
reserveWidth,
|
||||
children,
|
||||
}: {
|
||||
reserveWidth: boolean;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
reserveWidth
|
||||
? sidebarWorkspaceRowStyles.trailingActionSlotReserved
|
||||
: sidebarWorkspaceRowStyles.trailingActionSlot
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
export function SidebarWorkspaceTrailingActionSlot({ children }: { children: ReactNode }) {
|
||||
return <View style={sidebarWorkspaceRowStyles.trailingActionSlot}>{children}</View>;
|
||||
}
|
||||
|
||||
export function SidebarWorkspaceTrailingActionBase({
|
||||
@@ -552,6 +595,13 @@ 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,
|
||||
@@ -590,17 +640,26 @@ 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,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceBranchTextFlexible: {
|
||||
flex: 1,
|
||||
},
|
||||
workspaceBranchTextWithAccessory: {
|
||||
flexShrink: 1,
|
||||
},
|
||||
workspaceTitleAccessory: {
|
||||
height: 20,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
workspaceBranchTextCreating: {
|
||||
opacity: 0.92,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -32,13 +33,6 @@ 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() {}
|
||||
|
||||
@@ -263,7 +257,6 @@ function WorkspaceRowBody({
|
||||
archiveShortcutKeys,
|
||||
}: WorkspaceRowBodyProps) {
|
||||
const isTouchPlatform = platformIsNative;
|
||||
const trailing = useSidebarWorkspaceTrailing();
|
||||
const draggable = Boolean(drag);
|
||||
const interaction = useLongPressDragInteraction({
|
||||
drag: drag ?? noop,
|
||||
@@ -290,7 +283,14 @@ function WorkspaceRowBody({
|
||||
<SidebarWorkspaceRowFrame workspace={workspace} isDragging={isDragging}>
|
||||
{({ isHovered, contextMenuOpen, onContextMenuOpenChange, hoverHandlers }) => {
|
||||
const isDesktop = !isTouchPlatform;
|
||||
const scriptSummary = isDesktop ? selectWorkspaceScriptSummary(workspace.scripts) : null;
|
||||
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 workspaceRowStyle = getWorkspaceRowStyle({ isDragging, selected, isHovered });
|
||||
return (
|
||||
<View
|
||||
@@ -307,7 +307,7 @@ function WorkspaceRowBody({
|
||||
onContextMenuOpenChange={onContextMenuOpenChange}
|
||||
workspace={workspace}
|
||||
hostBadgeLabel={hostBadge?.label}
|
||||
scriptSummary={scriptSummary}
|
||||
scriptIconKind={scriptIconKind}
|
||||
workspaceKey={workspace.workspaceKey}
|
||||
onCopyPath={onCopyPath}
|
||||
onCopyBranchName={onCopyBranchName}
|
||||
@@ -333,8 +333,7 @@ function WorkspaceRowBody({
|
||||
<SidebarWorkspaceRowContent
|
||||
workspace={workspace}
|
||||
hostBadge={hostBadge}
|
||||
scriptSummary={scriptSummary}
|
||||
backdrop={getSidebarRowBackdrop({ isDragging, selected, isHovered })}
|
||||
scriptIconKind={scriptIconKind}
|
||||
isHovered={isHovered}
|
||||
isLoading={isArchiving || isCreating}
|
||||
isCreating={isCreating}
|
||||
@@ -343,7 +342,6 @@ function WorkspaceRowBody({
|
||||
>
|
||||
<WorkspaceRowTrailingActions
|
||||
workspace={workspace}
|
||||
trailing={trailing}
|
||||
isHovered={isHovered}
|
||||
isTouchPlatform={isTouchPlatform}
|
||||
isCreating={isCreating}
|
||||
@@ -370,7 +368,6 @@ function WorkspaceRowBody({
|
||||
|
||||
function WorkspaceRowTrailingActions({
|
||||
workspace,
|
||||
trailing,
|
||||
isHovered,
|
||||
isTouchPlatform,
|
||||
isCreating,
|
||||
@@ -387,7 +384,6 @@ function WorkspaceRowTrailingActions({
|
||||
onRename,
|
||||
}: {
|
||||
workspace: SidebarWorkspaceEntry;
|
||||
trailing: SidebarWorkspaceTrailing;
|
||||
isHovered: boolean;
|
||||
isTouchPlatform: boolean;
|
||||
isCreating: boolean;
|
||||
@@ -406,29 +402,32 @@ function WorkspaceRowTrailingActions({
|
||||
const { t } = useTranslation();
|
||||
const showShortcut = showShortcutBadge && shortcutNumber !== null;
|
||||
const {
|
||||
showTrailing,
|
||||
showDiffStat,
|
||||
showKebab: showKebabInSlot,
|
||||
showScrim,
|
||||
renderSlot,
|
||||
reserveSlotWidth,
|
||||
} = resolveTrailingActionVisibility({
|
||||
workspace,
|
||||
trailing,
|
||||
hasDiffStat: Boolean(workspace.diffStat),
|
||||
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}
|
||||
{renderSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot reserveWidth={reserveSlotWidth}>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showTrailing}>
|
||||
<SidebarWorkspaceTrailingContent workspace={workspace} trailing={trailing} />
|
||||
{shouldRenderActionSlot ? (
|
||||
<SidebarWorkspaceTrailingActionSlot>
|
||||
<SidebarWorkspaceTrailingActionBase visible={showDiffStat}>
|
||||
{workspace.diffStat ? (
|
||||
<DiffStat
|
||||
additions={workspace.diffStat.additions}
|
||||
deletions={workspace.diffStat.deletions}
|
||||
/>
|
||||
) : null}
|
||||
</SidebarWorkspaceTrailingActionBase>
|
||||
<SidebarWorkspaceTrailingActionOverlay visible={showKebabInSlot} scrim={showScrim}>
|
||||
{onArchive ? (
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,72 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
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;
|
||||
|
||||
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,
|
||||
},
|
||||
// The host is the only unbounded item, so it consumes the remaining width and gives way
|
||||
// before the bounded change request, CI, and service items.
|
||||
hostItem: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 3,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
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;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -1,26 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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} testID="sidebar-workspace-timestamp">
|
||||
{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,
|
||||
},
|
||||
}));
|
||||
@@ -34,7 +34,6 @@ export interface TerminalEmulatorProps {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
keyboardInset?: number;
|
||||
isKeyboardVisible?: boolean;
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
|
||||
@@ -168,7 +168,6 @@ function NativeTerminalEmulator({
|
||||
fontFamily,
|
||||
fontSize,
|
||||
keyboardInset = 0,
|
||||
isKeyboardVisible = false,
|
||||
initialSnapshot = null,
|
||||
onInput,
|
||||
onTerminalKey,
|
||||
@@ -994,7 +993,6 @@ function NativeTerminalEmulator({
|
||||
{terminalGrid}
|
||||
<TerminalInput
|
||||
ref={inputRef}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
onFocus={handleTerminalFocus}
|
||||
onInput={handleTerminalInput}
|
||||
onTerminalKey={handleNativeTerminalKey}
|
||||
|
||||
@@ -106,7 +106,6 @@ interface TerminalEmulatorProps {
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
keyboardInset?: number;
|
||||
isKeyboardVisible?: boolean;
|
||||
swipeGesturesEnabled?: boolean;
|
||||
onSwipeLeft?: () => void;
|
||||
onSwipeRight?: () => void;
|
||||
|
||||
@@ -222,7 +222,6 @@ export function TerminalPane({
|
||||
enabled: isMobile,
|
||||
});
|
||||
const [keyboardInset, setKeyboardInset] = useState(0);
|
||||
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
|
||||
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
@@ -299,8 +298,8 @@ export function TerminalPane({
|
||||
}, [keyboardInset, refreshClipboardAvailability]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsKeyboardToggleVisible(isKeyboardVisible);
|
||||
}, [isKeyboardVisible]);
|
||||
setIsKeyboardToggleVisible(keyboardInset > 0);
|
||||
}, [keyboardInset]);
|
||||
|
||||
const handleSelectionChange = useCallback((nextHasSelection: boolean) => {
|
||||
setHasSelection(nextHasSelection);
|
||||
@@ -419,13 +418,12 @@ export function TerminalPane({
|
||||
);
|
||||
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
|
||||
|
||||
const handleKeyboardChange = useCallback(
|
||||
(nextShift: number) => {
|
||||
setKeyboardInset(isMobile ? nextShift : 0);
|
||||
setIsKeyboardVisible(nextShift > 0);
|
||||
const handleKeyboardInsetChange = useCallback(
|
||||
(nextInset: number) => {
|
||||
setKeyboardInset(nextInset);
|
||||
pulseKeyboardRefits();
|
||||
},
|
||||
[isMobile, pulseKeyboardRefits],
|
||||
[pulseKeyboardRefits],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -433,14 +431,14 @@ export function TerminalPane({
|
||||
}, [clearKeyboardRefitTimeouts]);
|
||||
|
||||
useAnimatedReaction(
|
||||
() => Math.round(keyboardShift.value),
|
||||
() => (isMobile ? Math.round(keyboardShift.value) : 0),
|
||||
(next, prev) => {
|
||||
if (next === prev) {
|
||||
return;
|
||||
}
|
||||
runOnJS(handleKeyboardChange)(next);
|
||||
runOnJS(handleKeyboardInsetChange)(next);
|
||||
},
|
||||
[handleKeyboardChange],
|
||||
[isMobile, handleKeyboardInsetChange],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -844,8 +842,9 @@ export function TerminalPane({
|
||||
|
||||
setIsKeyboardToggleVisible(true);
|
||||
emulatorRef.current?.showKeyboard();
|
||||
requestTerminalFocus();
|
||||
requestTerminalReflow();
|
||||
}, [isKeyboardToggleVisible, requestTerminalReflow]);
|
||||
}, [isKeyboardToggleVisible, requestTerminalFocus, requestTerminalReflow]);
|
||||
|
||||
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
|
||||
inputModeRef.current = state;
|
||||
@@ -1015,7 +1014,6 @@ export function TerminalPane({
|
||||
fontFamily={terminalFontFamily}
|
||||
fontSize={settings.codeFontSize}
|
||||
keyboardInset={keyboardInset}
|
||||
isKeyboardVisible={isKeyboardVisible}
|
||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||
initialSnapshot={initialSnapshot}
|
||||
onRendererReadyChange={handleRendererReadyChange}
|
||||
|
||||
@@ -114,7 +114,6 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps)
|
||||
{children}
|
||||
<IsolatedBottomSheetModal
|
||||
ref={bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -12,15 +12,6 @@ import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
|
||||
// drift apart.
|
||||
export const TREE_INDENT_PER_LEVEL = 16;
|
||||
export const WORKSPACE_FILE_ROW_VERTICAL_PADDING = SPACING[1.5];
|
||||
export const WORKSPACE_TREE_ICON_SIZE = 16;
|
||||
export const WORKSPACE_TREE_LOADING_ICON_SIZE = 14;
|
||||
export const WORKSPACE_TREE_ICON_LABEL_GAP = SPACING[2];
|
||||
/**
|
||||
* Trailing glyph rail shared with the explorer X and Changes options chevron.
|
||||
* The extra 2px is optical: text ink ends inside its layout box, while the
|
||||
* header icons' strokes extend to theirs.
|
||||
*/
|
||||
export const WORKSPACE_FILE_ROW_TRAILING_PADDING = SPACING[4] + 2;
|
||||
|
||||
/** Left padding for a tree row at `depth`. Shared by folder rows and file headers
|
||||
* in the Changes tree so their indentation can't drift apart. */
|
||||
@@ -28,9 +19,7 @@ export function treeRowPaddingLeft(depth: number): number {
|
||||
return SPACING[3] + depth * TREE_INDENT_PER_LEVEL;
|
||||
}
|
||||
|
||||
const foregroundExtraMutedIconColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundExtraMuted,
|
||||
});
|
||||
const foregroundMutedIconColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
|
||||
@@ -63,17 +52,8 @@ export function TreeIndentGuides({ depth }: { depth: number }) {
|
||||
/** Rotating disclosure chevron for a directory row (points right; rotates down when expanded). */
|
||||
export function TreeChevron({ expanded }: { expanded: boolean }) {
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
expanded
|
||||
? [styles.chevron, styles.chevronExpanded]
|
||||
: [styles.chevron, styles.chevronCollapsed]
|
||||
}
|
||||
>
|
||||
<ThemedChevronRight
|
||||
size={WORKSPACE_TREE_ICON_SIZE}
|
||||
uniProps={foregroundExtraMutedIconColorMapping}
|
||||
/>
|
||||
<View style={expanded ? [styles.chevron, styles.chevronExpanded] : styles.chevron}>
|
||||
<ThemedChevronRight size={16} uniProps={foregroundMutedIconColorMapping} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -87,19 +67,13 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
chevron: {
|
||||
width: WORKSPACE_TREE_ICON_SIZE,
|
||||
height: WORKSPACE_TREE_ICON_SIZE,
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
// Lucide leaves space around the chevron path. Align its painted right edge inside the fixed
|
||||
// slot; the rotated glyph is wider, so it needs half the optical offset.
|
||||
chevronCollapsed: {
|
||||
left: 4,
|
||||
},
|
||||
chevronExpanded: {
|
||||
left: 2,
|
||||
transform: [{ rotate: "90deg" }],
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -994,7 +994,6 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
return (
|
||||
<IsolatedBottomSheetModal
|
||||
ref={props.bottomSheetRef}
|
||||
contextBridge={null}
|
||||
snapPoints={props.snapPoints}
|
||||
index={0}
|
||||
enableDynamicSizing={false}
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
useCallback,
|
||||
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,
|
||||
@@ -16,50 +28,150 @@ import {
|
||||
type StyleProp,
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { isNative, isWeb } from "@/constants/platform";
|
||||
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 {
|
||||
MenuHint,
|
||||
MenuItem,
|
||||
MenuLabel,
|
||||
MenuRoot,
|
||||
MenuSeparator,
|
||||
MenuSurface,
|
||||
useMenuContext,
|
||||
type MenuSurfaceProps,
|
||||
type MenuTriggerState,
|
||||
} from "@/components/ui/menu";
|
||||
IsolatedBottomSheetModal,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import { FloatingScrollView, FloatingSurface } from "@/components/ui/floating";
|
||||
import { isWeb, isNative } from "@/constants/platform";
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
// Keep parity with dropdown-menu action statuses.
|
||||
export type ActionStatus = "idle" | "pending" | "success";
|
||||
|
||||
export { MenuItem as ContextMenuItem };
|
||||
export { MenuLabel as ContextMenuLabel };
|
||||
export { MenuSeparator as ContextMenuSeparator };
|
||||
export { MenuHint as ContextMenuHint };
|
||||
export type { ActionStatus } from "@/components/ui/menu";
|
||||
type Placement = "top" | "bottom" | "left" | "right";
|
||||
type Alignment = "start" | "center" | "end";
|
||||
type MobileMenuMode = "dropdown" | "sheet";
|
||||
|
||||
/**
|
||||
* Context menus use the mobile menu convention by default: long press opens a bottom sheet on a
|
||||
* compact layout, while right click opens an anchored popover on a wide layout.
|
||||
*/
|
||||
export function ContextMenu({
|
||||
compactMode = "sheet",
|
||||
...props
|
||||
}: ComponentProps<typeof MenuRoot>): ReactElement {
|
||||
return <MenuRoot {...props} compactMode={compactMode} />;
|
||||
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;
|
||||
}
|
||||
|
||||
export function useContextMenu() {
|
||||
return useMenuContext("useContextMenu");
|
||||
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 };
|
||||
}
|
||||
|
||||
function isCallable(fn: unknown): fn is (...args: unknown[]) => void {
|
||||
@@ -67,18 +179,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 source = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
|
||||
const pageX = Reflect.get(source, "pageX");
|
||||
const pageY = Reflect.get(source, "pageY");
|
||||
const native = typeof nativeEvent === "object" && nativeEvent !== null ? nativeEvent : event;
|
||||
const pageX = Reflect.get(native, "pageX");
|
||||
const pageY = Reflect.get(native, "pageY");
|
||||
if (typeof pageX === "number" && typeof pageY === "number") {
|
||||
return { pageX, pageY };
|
||||
}
|
||||
|
||||
const clientX = Reflect.get(source, "clientX");
|
||||
const clientY = Reflect.get(source, "clientY");
|
||||
const clientX = Reflect.get(native, "clientX");
|
||||
const clientY = Reflect.get(native, "clientY");
|
||||
if (typeof clientX === "number" && typeof clientY === "number") {
|
||||
return { pageX: clientX, pageY: clientY };
|
||||
}
|
||||
@@ -86,22 +198,66 @@ 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;
|
||||
}
|
||||
(ref as { current: T }).current = value;
|
||||
if (ref && typeof ref === "object") {
|
||||
Object.assign(ref, { current: value });
|
||||
}
|
||||
}
|
||||
|
||||
type TriggerStyleProp = StyleProp<ViewStyle> | ((state: MenuTriggerState) => StyleProp<ViewStyle>);
|
||||
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>);
|
||||
|
||||
export function ContextMenuTrigger({
|
||||
children,
|
||||
disabled,
|
||||
style,
|
||||
enabled = true,
|
||||
enabledOnMobile = true,
|
||||
enabledOnMobile = false,
|
||||
enabledOnWeb = true,
|
||||
longPressDelayMs,
|
||||
triggerRef,
|
||||
@@ -116,16 +272,19 @@ export function ContextMenuTrigger({
|
||||
triggerRef?: Ref<View | null>;
|
||||
}
|
||||
>): ReactElement {
|
||||
const ctx = useMenuContext("ContextMenuTrigger");
|
||||
const ctx = useContextMenuContext("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,
|
||||
@@ -161,7 +320,9 @@ 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");
|
||||
@@ -199,3 +360,572 @@ 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
@@ -1,75 +1,34 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import React, { act, createContext, useContext, useSyncExternalStore } from "react";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* A faithful stand-in for `@gorhom/portal`: it renders *nothing* where it sits and hands its
|
||||
* element to a host somewhere else in the tree, which is what makes the real thing lose context.
|
||||
* A passthrough fake would keep context and quietly pass every test in this file — which is
|
||||
* exactly what happened, and why the menu sheet shipped broken.
|
||||
*/
|
||||
const portalStore = vi.hoisted(() => {
|
||||
const listeners = new Set<() => void>();
|
||||
let node: React.ReactNode = null;
|
||||
return {
|
||||
put(next: React.ReactNode) {
|
||||
node = next;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
read: () => node,
|
||||
subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
reset() {
|
||||
node = null;
|
||||
listeners.clear();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@gorhom/portal", () => ({
|
||||
Portal: ({ children }: { children?: React.ReactNode }) => {
|
||||
React.useEffect(() => {
|
||||
portalStore.put(children);
|
||||
}, [children]);
|
||||
return null;
|
||||
},
|
||||
PortalHost: () => React.createElement("div", { "data-host": true }),
|
||||
Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) =>
|
||||
React.createElement("div", { "data-portal-host": hostName }, children),
|
||||
PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }),
|
||||
}));
|
||||
|
||||
vi.mock("@gorhom/bottom-sheet", async () => {
|
||||
const { Portal } = await import("@gorhom/portal");
|
||||
return {
|
||||
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
|
||||
BottomSheetModal: React.forwardRef(
|
||||
({ children, stackBehavior }: { children?: React.ReactNode; stackBehavior?: string }, _ref) =>
|
||||
React.createElement(
|
||||
Portal,
|
||||
null,
|
||||
React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
|
||||
),
|
||||
),
|
||||
};
|
||||
});
|
||||
vi.mock("@gorhom/bottom-sheet", () => ({
|
||||
BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) =>
|
||||
React.createElement("div", { "data-bottom-sheet-provider": true }, children),
|
||||
BottomSheetModal: React.forwardRef(
|
||||
(
|
||||
{
|
||||
children,
|
||||
stackBehavior,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
stackBehavior?: string;
|
||||
},
|
||||
_ref,
|
||||
) => React.createElement("div", { "data-stack-behavior": stackBehavior }, children),
|
||||
),
|
||||
}));
|
||||
|
||||
import { IsolatedBottomSheetModal, type ContextBridge } from ".";
|
||||
|
||||
/** Renders whatever the portal is currently holding, from its own place in the tree. */
|
||||
function PortalHostProbe() {
|
||||
const node = useSyncExternalStore(portalStore.subscribe, portalStore.read, portalStore.read);
|
||||
return <div data-portal-host>{node}</div>;
|
||||
}
|
||||
|
||||
const LabelContext = createContext("unbridged");
|
||||
|
||||
function Label() {
|
||||
return <div data-label={useContext(LabelContext)} />;
|
||||
}
|
||||
import { IsolatedBottomSheetModal } from ".";
|
||||
|
||||
describe("IsolatedBottomSheetModal presentation", () => {
|
||||
let container: HTMLDivElement;
|
||||
@@ -84,94 +43,34 @@ describe("IsolatedBottomSheetModal presentation", () => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
portalStore.reset();
|
||||
});
|
||||
|
||||
function stackBehaviors(): (string | null)[] {
|
||||
return Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
|
||||
node.getAttribute("data-stack-behavior"),
|
||||
);
|
||||
}
|
||||
|
||||
it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<IsolatedBottomSheetModal contextBridge={null}>Settings</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
<IsolatedBottomSheetModal>Settings</IsolatedBottomSheetModal>
|
||||
<IsolatedBottomSheetModal>Diagnostic</IsolatedBottomSheetModal>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(stackBehaviors()).toEqual(["push"]);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<IsolatedBottomSheetModal contextBridge={null}>Diagnostic</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
expect(stackBehaviors()).toEqual(["push"]);
|
||||
expect(
|
||||
Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) =>
|
||||
node.getAttribute("data-stack-behavior"),
|
||||
),
|
||||
).toEqual(["push", "push"]);
|
||||
});
|
||||
|
||||
it("only replaces when the callsite asks for replacement", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<IsolatedBottomSheetModal presentation="replace" contextBridge={null}>
|
||||
Selector
|
||||
</IsolatedBottomSheetModal>
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
<IsolatedBottomSheetModal presentation="replace">Selector</IsolatedBottomSheetModal>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(stackBehaviors()).toEqual(["replace"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IsolatedBottomSheetModal context bridging", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
portalStore.reset();
|
||||
});
|
||||
|
||||
function renderSheet(contextBridge: ContextBridge | null) {
|
||||
act(() => {
|
||||
root.render(
|
||||
<>
|
||||
<LabelContext.Provider value="from callsite">
|
||||
<IsolatedBottomSheetModal contextBridge={contextBridge}>
|
||||
<Label />
|
||||
</IsolatedBottomSheetModal>
|
||||
</LabelContext.Provider>
|
||||
{/* Outside the provider, standing in for the app-root portal host. */}
|
||||
<PortalHostProbe />
|
||||
</>,
|
||||
);
|
||||
});
|
||||
return container.querySelector("[data-label]")?.getAttribute("data-label");
|
||||
}
|
||||
|
||||
it("loses callsite context without a bridge", () => {
|
||||
expect(renderSheet(null)).toBe("unbridged");
|
||||
});
|
||||
|
||||
it("carries callsite context across the portal when bridged", () => {
|
||||
const bridge: ContextBridge = (children) => (
|
||||
<LabelContext.Provider value="from callsite">{children}</LabelContext.Provider>
|
||||
);
|
||||
expect(renderSheet(bridge)).toBe("from callsite");
|
||||
expect(
|
||||
container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"),
|
||||
).toBe("replace");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import React from "react";
|
||||
import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { ElementRef, ReactNode } from "react";
|
||||
import type { ElementRef } from "react";
|
||||
import {
|
||||
type BottomSheetController,
|
||||
createBottomSheetVisibilityTracker,
|
||||
@@ -12,42 +12,11 @@ import {
|
||||
|
||||
type GorhomBottomSheetModalMethods = ElementRef<typeof GorhomBottomSheetModal>;
|
||||
|
||||
/**
|
||||
* Re-establishes React context on the far side of the portal.
|
||||
*
|
||||
* `@gorhom/portal` is not a React portal. It stores the element and a host elsewhere in the tree
|
||||
* renders it, so context resolves at the *host's* position: everything provided between
|
||||
* `PortalProvider` (see `app/_layout.tsx`) and this sheet is invisible to its content. React
|
||||
* cannot copy contexts reflectively, so the only way across is to render the providers again —
|
||||
* with values captured out here, where they are still readable.
|
||||
*
|
||||
* Write it as a closure over what you already have:
|
||||
*
|
||||
* ```tsx
|
||||
* const contextBridge = useCallback<ContextBridge>(
|
||||
* (content) => <ThingContext.Provider value={thing}>{content}</ThingContext.Provider>,
|
||||
* [thing],
|
||||
* );
|
||||
* ```
|
||||
*/
|
||||
export type ContextBridge = (children: ReactNode) => ReactNode;
|
||||
|
||||
type IsolatedBottomSheetModalProps = Omit<
|
||||
BottomSheetModalProps,
|
||||
"enableDismissOnClose" | "stackBehavior" | "children"
|
||||
"enableDismissOnClose" | "stackBehavior"
|
||||
> & {
|
||||
/**
|
||||
* Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge
|
||||
* would have to reach around it.
|
||||
*/
|
||||
children?: ReactNode;
|
||||
presentation?: "push" | "replace";
|
||||
/**
|
||||
* Required, and `null` is a real answer: a sheet that needs nothing from its call site should
|
||||
* have to say so. The failure it prevents is invisible until someone adds a `useContext` deep
|
||||
* inside the sheet and it throws on device only.
|
||||
*/
|
||||
contextBridge: ContextBridge | null;
|
||||
};
|
||||
|
||||
export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods;
|
||||
@@ -56,7 +25,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
IsolatedBottomSheetModalRef,
|
||||
IsolatedBottomSheetModalProps
|
||||
>(function IsolatedBottomSheetModal(props, ref) {
|
||||
const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props;
|
||||
const { children, presentation = "push", ...bottomSheetProps } = props;
|
||||
const modal = (
|
||||
<GorhomBottomSheetModal
|
||||
{...bottomSheetProps}
|
||||
@@ -64,7 +33,7 @@ export const IsolatedBottomSheetModal = forwardRef<
|
||||
enableDismissOnClose
|
||||
stackBehavior={presentation}
|
||||
>
|
||||
{contextBridge ? contextBridge(children) : children}
|
||||
{children}
|
||||
</GorhomBottomSheetModal>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -1,77 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,159 +0,0 @@
|
||||
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}`;
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,357 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -1,75 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,472 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -1,95 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user