diff --git a/CLAUDE.md b/CLAUDE.md
index 44e1a7ae88..c7c5f1f4d8 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -34,6 +34,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/hover.md](docs/hover.md) | Hover — the canonical pattern (plain View + onPointerEnter/Leave, separate inner Pressable) and the three ways agents break it |
| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order |
| [docs/floating-panels.md](docs/floating-panels.md) | Anchored popovers — Portal/Modal escape for Android, lifecycle gates, keyboard-shared-value, status-bar offset, the flash |
+| [docs/menus.md](docs/menus.md) | The menu engine — popover vs sheet, submenu pages, hover intent, when a decision earns a submenu |
| [docs/expo-router.md](docs/expo-router.md) | Expo Router route ownership, startup restore, and native blank-screen gotchas |
| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer |
| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end |
diff --git a/docs/file-icons.md b/docs/file-icons.md
index eb1e56f020..7ff98b6acc 100644
--- a/docs/file-icons.md
+++ b/docs/file-icons.md
@@ -2,20 +2,32 @@
The file explorer uses colored SVG icons from [`material-icon-theme`](https://github.com/material-extensions/vscode-material-icon-theme) (installed as a dev dependency in `packages/app`).
-Icons are inlined as SVG strings in:
-
-```
-packages/app/src/components/material-file-icons.ts
-```
-
-This file is auto-generated. Do not edit it by hand.
-
## How it works
-- `SVG_ICONS` maps icon names (e.g. `"typescript"`) to raw SVG strings
-- `EXTENSION_TO_ICON` maps file extensions (e.g. `"ts"`) to icon names
-- `getFileIconSvg(fileName)` returns the SVG string for a given filename, falling back to a generic file icon
-- `packages/app/src/components/file-explorer-pane.tsx` is the only consumer; it renders the SVG with `SvgXml` from `react-native-svg`
+Two files:
+
+- `packages/app/src/components/material-file-icons.ts` — the vendor table. `SVG_ICONS` maps icon
+ names to SVG strings copied verbatim from the theme, `EXTENSION_TO_ICON` maps extensions to icon
+ names, and `getRawFileIconSvg(fileName)` looks one up with a generic fallback. Keep it a faithful
+ copy so re-copying an icon stays a mechanical edit.
+- `packages/app/src/components/file-icon-svg.ts` — `getFileIconSvg(fileName)`, the only thing
+ anyone should import. It desaturates the vendor colours and caches the result.
+
+Render with `` rather than reaching for `SvgXml` yourself.
+
+## Why the colours are toned down
+
+material-icon-theme picks its palette for VS Code's file tree, where the icon is the only colour on
+the row. Ours share rows with status dots, diff stats, and check badges, and a column of them at
+full chroma is the loudest thing in the panel.
+
+Every hex in the SVG is pulled toward neutral at the same perceived lightness (`desaturateHexColor`
+in `packages/app/src/utils/color.ts`, OKLab — scaling HSL saturation instead would darken the
+yellows and barely touch the blues). Hue survives, which is the only part of an icon's colour that
+carries meaning at 16pt.
+
+`ICON_CHROMA` in `file-icon-svg.ts` is the single knob. Move it; do not add per-icon overrides, or
+we are back to hand-picking 53 colours.
## Adding a new icon
diff --git a/docs/floating-panels.md b/docs/floating-panels.md
index 12c91a0467..1141d11c66 100644
--- a/docs/floating-panels.md
+++ b/docs/floating-panels.md
@@ -258,3 +258,6 @@ Before you write a new one, ask:
bounded**. Verify before you commit.
Then copy the closest canonical file and trim.
+
+Building a **menu** rather than a bare panel? Don't. Use the menu engine — it already solves
+everything above, plus submenus, sheets, and hover intent. See [menus.md](menus.md).
diff --git a/docs/glossary.md b/docs/glossary.md
index bb917b8a5a..418bacdcd7 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -10,7 +10,7 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
- **Agent** — See **Agent session**. UI still says "Agent" / "New Agent" in places, but moving toward **Agent session** as the canonical term. Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`). Forbidden: "Task", "Job", "Run".
- **Daemon** — Local Paseo server process; identified by `serverId`. UI: "Daemon" (system contexts only). Code: `serverId` in `ServerInfoStatusPayloadSchema` (`packages/protocol/src/messages.ts:1936`), `DaemonClient` (`packages/client/src/daemon-client.ts`).
- **Host** — Client-side connection profile pointing at a daemon; bundles one or more `HostConnection`s. UI: "Host" / "Add host" / "Switch host". Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Forbidden: "Connection" (means `HostConnection`, not host).
-- **Host badge** — The sidebar pill naming which host a workspace lives on. Only drawn once the visible sidebar spans more than one host. Its name, color, and display mode (**Name** / **Icon only** / **Hidden**) are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. UI: the "Appearance" group on a host's settings page. Code: `HostBadge` (`packages/app/src/components/sidebar/host-badge.tsx`), `selectHostBadges` (`packages/app/src/hosts/appearance.ts`). Don't confuse with **Status pill**, which reports state rather than identity.
+- **Host badge** — The item on a sidebar workspace row's meta row naming which host that workspace lives on. Three things gate it, and they compose as a lattice rather than competing: the sidebar must span more than one host (automatic), **Show → Host** in the sidebar's display preferences must be on (global, all hosts at once), and then that host's own display mode decides _how_ it draws — **Name** / **Icon only** / **Hidden**. Name, color, and display mode are per-host, per-device, and live client-side on `HostProfile.appearance`; nothing about it reaches the daemon. Never add a fourth way to hide it. UI: the "Appearance" group on a host's settings page, and the "Show" submenu in the sidebar's display preferences. Code: `selectHostBadges` (`packages/app/src/hosts/appearance.ts`) takes the first two as its `enabled` input; `WorkspaceMetaRow` (`packages/app/src/components/sidebar/workspace-meta-row/index.tsx`) draws it. Don't confuse with **Status pill**, which reports state rather than identity.
- **Project host entry** — One row in a project for a single (project, daemon) pair, aggregating that daemon's workspaces in the project. Internal. Code: `ProjectHostEntry` (`packages/app/src/utils/projects.ts:11`). Don't introduce "Checkout" as a synonym.
- **Placement** — One workspace's stable foreign-key relationship to its project plus its git checkout snapshot. Internal. An explicit creation `projectId` is authoritative when active.
- **Branch** — Plain git branch. UI: "Switch branch". Code: `currentBranch` in `WorkspaceGitRuntimePayloadSchema` (`packages/protocol/src/messages.ts:2136`); `BranchSwitcher` (`packages/app/src/components/branch-switcher.tsx`).
diff --git a/docs/menus.md b/docs/menus.md
new file mode 100644
index 0000000000..68ffb63506
--- /dev/null
+++ b/docs/menus.md
@@ -0,0 +1,122 @@
+# Menus
+
+One engine, in `packages/app/src/components/ui/menu/`. `dropdown-menu.tsx` and `context-menu.tsx`
+are wrappers over it and differ only in what opens them — a press, or a long press / right click.
+Import the `DropdownMenu*` or `ContextMenu*` names as before; reach for `@/components/ui/menu`
+directly only when you are building a third trigger shape.
+
+Do not add a third menu implementation. The two that existed were byte-identical from
+`computePosition` down, and the copies drifted: only one of them ever grew a sheet.
+
+## Two presentations
+
+`MenuRoot` picks one from form factor, never from platform — a tablet in a narrow split view
+sheets the same way a phone does.
+
+| Screen | Surface | Submenus |
+| ------------------------------ | ------------------------------- | -------------------------------------------------- |
+| Wide | Popover anchored to the trigger | Flyout overlapping the row, opened by hover intent |
+| Compact, `compactMode="sheet"` | Bottom sheet | The page is replaced in place, with a back header |
+| Compact, default | Popover | Same as wide |
+
+`compactMode` defaults to `"popover"`, so adopting the sheet is per-menu. That is deliberate:
+flipping every menu in the app to sheets at once is not a change anyone can review. Opt a menu in
+when you have actually looked at it on a phone.
+
+## Pages
+
+A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id`
+matches:
+
+```tsx
+
+
+ Grouping
+
+
+```
+
+Pages are data rather than nested children because the popover renders them as _siblings_ of the
+root surface — a flyout nested inside the root's box would be clipped by its `overflow: hidden`.
+Declaring them separately is what lets one model drive both presentations.
+
+`menu-navigation.ts` holds that model, and it is pure: the open flyout chain and the mobile push
+stack are the same path, so the popover renders every entry in it and the sheet renders only the
+last. Nothing else differs between the two.
+
+Opening a submenu truncates the path to the depth of the trigger that opened it. Without that,
+sliding the pointer across a row of triggers would stack up every flyout it passed instead of
+swapping between them.
+
+## Hover intent
+
+A flyout **overlaps its parent by 5pt** rather than sitting beside it. With a gap there is a strip
+of backdrop between the two that belongs to neither surface, and every pixel of it is a chance to
+dismiss the menu you are reaching for. Overlapping deletes the strip; don't reintroduce the gap
+and try to cover it with a longer timer.
+
+On top of that, a flyout opens after the pointer rests ~90ms, closes ~260ms after it leaves, and
+cancels its own pending close while the pointer is inside it. The grace still matters because the
+pointer crosses sibling rows on the way down into the flyout.
+
+Hover lives on a plain `View`, never on a `Pressable` — see [hover.md](hover.md), which owns that
+rule. Hover only fires on web, which is exactly where flyouts exist; everywhere else the page
+opens on press.
+
+## Item states
+
+`selected` and `active` are different questions and must not be merged.
+
+| Prop | Means | Draws |
+| ---------- | -------------------------- | ------------------------- |
+| `selected` | This is the chosen value | A check, and nothing else |
+| `active` | This row's submenu is open | The fill, and no check |
+
+A selected row does **not** get a background. A check and a fill are two separate claims about the
+same state, and showing both makes a chosen row compete with the row the pointer is actually on.
+`showSelectedCheck` moves the check to a reserved leading column when a group needs to stay
+aligned whether ticked or not; otherwise it sits at the trailing edge and the leading slot is free
+for the option's icon.
+
+Give options icons; leave the root rows without them. The root is labels and their current values,
+and a column of icons there is decoration competing with the values you actually came to read.
+
+## Surface details
+
+- The hover fill is **inset by the same amount on every side and rounded** — a chip inside the
+ menu, not a band across it. The inset is taken _out of_ the row, never added to it: horizontally
+ padding gives up what margin takes so labels don't move, and vertically the fill is shorter by
+ the same amount so the row pitch is unchanged. Insetting by growing the row is how the menu ends
+ up taller than it started.
+- The fill's height only holds because the label's `lineHeight` is pinned. Leave it to the platform
+ and content outgrows `minHeight`, which then does nothing.
+- **The item's inset is the menu's only spacing.** Rows sit apart because each holds itself in, and
+ a separator gets clearance from its neighbours for free. Don't give the separator a margin of its
+ own — that is two numbers controlling one gap, and they will disagree.
+- Separators use `borderAccent`, the same colour the surface outlines itself with. `border` sits
+ between `surface1` and `surface2`, which put it within a hair of the hover fill and made
+ separators disappear against a hovered row.
+
+## When a decision earns a submenu
+
+Put a decision behind a submenu when its options are not the point — the current _value_ is. The
+root row then reads as the answer (`Grouping Project ›`) and costs one line instead of one line
+per option. A menu whose every option is on screen at once does not survive its third decision.
+
+Independent toggles stay on their page as a checkmark list. A pick-one group can share that page
+below a `MenuSeparator`; make selecting the checked row clear it, so "none" doesn't need a row of
+its own.
+
+## Gotchas
+
+- **Released height.** Reanimated's web entering animation leaves an inline height snapshot on
+ the surface. `AnchoredSurface` clears it, and a `revision` prop re-clears it when content
+ identity changes — a pushed page taller than the one it replaced is clipped without that.
+- **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is
+ rarely the height of the page before it.
+- **One overlay per menu.** Submenus render inside their parent's layer and paint no second
+ backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes.
+- Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix
+ positioning bugs there, not at a call site.
+- Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates,
+ status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md).
diff --git a/packages/app/e2e/browser/diff-row-alignment.spec.ts b/packages/app/e2e/browser/diff-row-alignment.spec.ts
index b775371a36..ddeb77c5c1 100644
--- a/packages/app/e2e/browser/diff-row-alignment.spec.ts
+++ b/packages/app/e2e/browser/diff-row-alignment.spec.ts
@@ -223,19 +223,29 @@ test("changes diff keeps code rows aligned with the gutter", async ({ page }) =>
});
});
-test("changes file actions open from the kebab and right-click", async ({ page }) => {
+test("changes file actions open below the right-click without a reserved kebab", async ({
+ page,
+}) => {
const workspace = await createWorkspaceWithMountedTabDiff({ includeDeletedFile: true });
await useUnwrappedDiffLines(page);
await openWorkspaceChanges(page, workspace);
await expect(page.getByTestId("diff-file-1")).toContainText("zz-deleted.ts");
- await page.getByTestId("diff-file-1-actions").click();
+ await expect(page.getByTestId(/diff-file-\d+-actions/)).toHaveCount(0);
+ await page.getByTestId("diff-file-1-toggle").click({ button: "right" });
await expect(page.getByText("Copy path")).toBeVisible();
await expect(page.getByTestId("diff-file-1-open-file")).toHaveCount(0);
await page.keyboard.press("Escape");
- await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
+ const fileRow = page.getByTestId("diff-file-0-toggle");
+ const fileRowBounds = await fileRow.boundingBox();
+ expect(fileRowBounds).not.toBeNull();
+ await fileRow.click({ button: "right", position: { x: 80, y: 10 } });
await expect(page.getByTestId("diff-file-0-open-file")).toBeVisible();
+ const menuBounds = await page.getByTestId("diff-file-0-context-menu").boundingBox();
+ expect(menuBounds).not.toBeNull();
+ expect(menuBounds!.x).toBeCloseTo(fileRowBounds!.x + 80, 0);
+ expect(menuBounds!.y).toBeGreaterThan(fileRowBounds!.y + 10);
await page.getByTestId("diff-file-0-open-file").click();
await expect(page.getByTestId("workspace-file-pane")).toBeVisible();
@@ -303,7 +313,7 @@ test("Changes switches between inline and full-tab navigation", async ({ page })
await page.getByTestId("explorer-content-area").getByTestId("diff-file-1-toggle").click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(1);
await expect(visiblePanel.getByText("zz-deleted.ts", { exact: true })).toBeVisible();
- await expect(visiblePanel).toContainText("Deleted");
+ await expect(visiblePanel.getByRole("img", { name: "Deleted" })).toBeVisible();
await changesTabToggle.click();
await expect(page.getByTestId(/^workspace-working-diff-close-/)).toHaveCount(0);
@@ -340,6 +350,9 @@ test("changes diff switches between flat and tree file lists", async ({ page })
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
await expect(page.getByTestId("diff-file-0")).toBeVisible();
+ await page.getByTestId("diff-file-0-toggle").click({ button: "right" });
+ await expect(page.getByTestId("diff-file-0-context-menu")).toBeVisible();
+ await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Collapse all" }).click();
await expect(page.getByTestId("diff-file-0")).toHaveCount(0);
@@ -360,12 +373,7 @@ test("workspace file panes keep their controls on shared alignment rails", async
await page.getByTestId("changes-toggle-view-mode").click();
await expect(page.getByTestId("diff-folder-src")).toBeVisible();
- const changesRightRail = await Promise.all([
- readSvgRight(page, "explorer-close"),
- readSvgRight(page, "changes-options-menu"),
- readSvgRight(page, "diff-file-0-actions"),
- ]);
- expectAligned(changesRightRail);
+ await expect(page.getByTestId("diff-file-0-actions")).toHaveCount(0);
const [folderStat, fileStat] = await Promise.all([
page.getByTestId("diff-folder-src-stat").boundingBox(),
@@ -378,12 +386,17 @@ test("workspace file panes keep their controls on shared alignment rails", async
await page.getByTestId("explorer-tab-files").click();
await expect(page.getByTestId("file-explorer-row-0")).toBeVisible();
- const filesRightRail = await Promise.all([
- readSvgRight(page, "explorer-close"),
- readSvgRight(page, "files-refresh"),
- readSvgRight(page, "file-explorer-row-0-actions"),
- ]);
- expectAligned(filesRightRail);
+ await expect(page.getByTestId("file-explorer-row-0-actions")).toHaveCount(0);
+ const fileExplorerRow = page.getByTestId("file-explorer-row-0");
+ const fileExplorerRowBounds = await fileExplorerRow.boundingBox();
+ expect(fileExplorerRowBounds).not.toBeNull();
+ await fileExplorerRow.click({ button: "right", position: { x: 80, y: 10 } });
+ await expect(page.getByTestId("file-explorer-row-0-context-menu")).toBeVisible();
+ const fileMenuBounds = await page.getByTestId("file-explorer-row-0-context-menu").boundingBox();
+ expect(fileMenuBounds).not.toBeNull();
+ expect(fileMenuBounds!.x).toBeCloseTo(fileExplorerRowBounds!.x + 80, 0);
+ expect(fileMenuBounds!.y).toBeGreaterThan(fileExplorerRowBounds!.y + 10);
+ await page.keyboard.press("Escape");
const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([
page.getByTestId("files-sort-label").boundingBox(),
@@ -593,21 +606,6 @@ async function openChangesInVisibleExplorer(page: Page): Promise {
await expect(page.getByText("use-mounted-tab-set.ts")).toBeVisible({ timeout: 30_000 });
}
-async function readSvgRight(page: Page, testID: string): Promise {
- 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 {
await expect(page.getByTestId("diff-file-0-body")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("function createInitialMountedTabIds")).toBeVisible({
diff --git a/packages/app/src/components/diff-stat.tsx b/packages/app/src/components/diff-stat.tsx
index c594484fc6..9bd4ac3963 100644
--- a/packages/app/src/components/diff-stat.tsx
+++ b/packages/app/src/components/diff-stat.tsx
@@ -36,11 +36,11 @@ const styles = StyleSheet.create((theme) => ({
additions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
- color: theme.colors.diffStatAddition,
+ color: theme.colors.statusMutedSuccess,
},
deletions: {
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.normal,
- color: theme.colors.diffStatDeletion,
+ color: theme.colors.statusMutedDanger,
},
}));
diff --git a/packages/app/src/components/file-actions-menu.tsx b/packages/app/src/components/file-actions-menu.tsx
index 061a47b2ef..185a04fde3 100644
--- a/packages/app/src/components/file-actions-menu.tsx
+++ b/packages/app/src/components/file-actions-menu.tsx
@@ -1,30 +1,15 @@
import { useMemo, type ReactElement, type ReactNode } from "react";
-import { type PressableStateCallbackType } from "react-native";
-import { StyleSheet, withUnistyles } from "react-native-unistyles";
-import {
- Copy,
- Download,
- FileText,
- MessageSquarePlus,
- MoreVertical,
- type LucideIcon,
-} from "lucide-react-native";
+import { withUnistyles } from "react-native-unistyles";
+import { Copy, Download, FileText, MessageSquarePlus, type LucideIcon } from "lucide-react-native";
import { useTranslation } from "react-i18next";
-import { ICON_SIZE, SPACING, type Theme } from "@/styles/theme";
+import { ICON_SIZE, type Theme } from "@/styles/theme";
import {
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@/components/ui/dropdown-menu";
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuSeparator,
+} from "@/components/ui/context-menu";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
-const ThemedMoreVertical = withUnistyles(MoreVertical);
-
-/** Width occupied by a file action trigger, including its visual padding. */
-export const FILE_ACTIONS_MENU_WIDTH = ICON_SIZE.sm + 2 * SPACING[1];
-
interface FileAction {
key: string;
label: string;
@@ -33,7 +18,7 @@ interface FileAction {
testID?: string;
}
-interface FileActionsMenuProps {
+interface FileActionsContextMenuContentProps {
fileKind: "file" | "directory";
fileExists?: boolean;
onOpenFile?: () => void;
@@ -42,32 +27,14 @@ interface FileActionsMenuProps {
onAddToChat?: () => void;
/** Optional metadata block rendered above the actions (e.g. size/modified). */
header?: ReactNode;
- open?: boolean;
- onOpenChange?: (open: boolean) => void;
- hitSlop?: number;
- accessibilityLabel: string;
testIDPrefix?: string;
}
-// The menu lives inside pressable rows (diff header, explorer entry); stop the
-// press so opening it doesn't also trigger the row.
-function stopTriggerPropagation(event: { stopPropagation?: () => void }) {
- event.stopPropagation?.();
-}
-
-function triggerStyle({
- hovered,
- pressed,
- open,
-}: PressableStateCallbackType & { hovered?: boolean; open?: boolean }) {
- return [styles.trigger, (Boolean(hovered) || pressed || Boolean(open)) && styles.triggerActive];
-}
-
/**
- * Shared kebab (⋮) menu for per-file actions. Used by the file explorer tree and
- * git diff pane so both surfaces share action availability, ordering, and chrome.
+ * Shared context-menu content for per-file actions. The file explorer tree and git diff pane
+ * own their row triggers while sharing action availability, ordering, and chrome here.
*/
-export function FileActionsMenu({
+export function FileActionsContextMenuContent({
fileKind,
fileExists = true,
onOpenFile,
@@ -75,12 +42,8 @@ export function FileActionsMenu({
onDownload,
onAddToChat,
header,
- open,
- onOpenChange,
- hitSlop = 12,
- accessibilityLabel,
testIDPrefix,
-}: FileActionsMenuProps): ReactElement | null {
+}: FileActionsContextMenuContentProps): ReactElement | null {
const { t } = useTranslation();
const actions = useMemo(() => {
const availableFile = fileKind === "file" && fileExists;
@@ -126,28 +89,21 @@ export function FileActionsMenu({
return null;
}
return (
-
-
-
-
-
- {header ? (
- <>
- {header}
-
- >
- ) : null}
- {actions.map((action) => (
-
- ))}
-
-
+
+ {header ? (
+ <>
+ {header}
+
+ >
+ ) : null}
+ {actions.map((action) => (
+
+ ))}
+
);
}
@@ -159,27 +115,8 @@ function FileActionMenuItem({ action }: { action: FileAction }): ReactElement {
[ThemedIcon],
);
return (
-
+
{action.label}
-
+
);
}
-
-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,
- },
-}));
diff --git a/packages/app/src/components/file-change-icon.tsx b/packages/app/src/components/file-change-icon.tsx
new file mode 100644
index 0000000000..3d113682bd
--- /dev/null
+++ b/packages/app/src/components/file-change-icon.tsx
@@ -0,0 +1,43 @@
+import type { ReactElement } from "react";
+import { withUnistyles } from "react-native-unistyles";
+import { SquareMinus, SquarePlus } from "lucide-react-native";
+import { useTranslation } from "react-i18next";
+import type { Theme } from "@/styles/theme";
+
+const ThemedSquarePlus = withUnistyles(SquarePlus);
+const ThemedSquareMinus = withUnistyles(SquareMinus);
+
+const successMapping = (theme: Theme) => ({ color: theme.colors.statusMutedSuccess });
+const dangerMapping = (theme: Theme) => ({ color: theme.colors.statusMutedDanger });
+
+const ICON_SIZE = 14;
+
+/**
+ * Whether a file was added or removed outright, shown beside its diff stat.
+ *
+ * Square-plus / square-minus because that is what every forge uses for the same fact, so it needs
+ * no label — the shape is the word. It sits at the same muted weight as the "+12 −3" next to it:
+ * these are footnotes on a file header, not the diff body where colour carries the line-by-line
+ * signal.
+ */
+export function FileChangeIcon({ change }: { change: "added" | "deleted" }): ReactElement {
+ const { t } = useTranslation();
+
+ if (change === "added") {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx
index e2cc7759e4..4c9d7517c2 100644
--- a/packages/app/src/components/file-explorer-pane.tsx
+++ b/packages/app/src/components/file-explorer-pane.tsx
@@ -30,7 +30,8 @@ import type {
ExplorerEntry,
} from "@/stores/session-store";
import { useSessionStore } from "@/stores/session-store";
-import { FileActionsMenu } from "@/components/file-actions-menu";
+import { FileActionsContextMenuContent } from "@/components/file-actions-menu";
+import { ContextMenu, ContextMenuTrigger } from "@/components/ui/context-menu";
import { useFileDownload } from "@/hooks/use-file-download";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { buildWorkspaceExplorerStateKey } from "@/hooks/use-file-explorer-actions";
@@ -172,34 +173,40 @@ function TreeRowItem({
);
return (
-
-
-
-
- {(() => {
- if (!isDirectory) {
- return ;
- }
- if (loading) {
- return ;
- }
- return ;
- })()}
+
+
+
+
+
+ {(() => {
+ if (!isDirectory) {
+ return ;
+ }
+ if (loading) {
+ return ;
+ }
+ return ;
+ })()}
+
+
+ {entry.name}
+
-
- {entry.name}
-
-
-
+
-
+
);
}
diff --git a/packages/app/src/components/file-icon-svg.test.ts b/packages/app/src/components/file-icon-svg.test.ts
new file mode 100644
index 0000000000..08b904a1b9
--- /dev/null
+++ b/packages/app/src/components/file-icon-svg.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it } from "vitest";
+import { getFileIconSvg } from "./file-icon-svg";
+import { getRawFileIconSvg } from "./material-file-icons";
+
+function hexColors(svg: string): string[] {
+ return svg.match(/#[0-9a-fA-F]{6}/g) ?? [];
+}
+
+/** How far a colour is from grey, measured crudely enough not to re-derive the implementation. */
+function channelSpread(hex: string): number {
+ const [r, g, b] = [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
+ return Math.max(r, g, b) - Math.min(r, g, b);
+}
+
+describe("getFileIconSvg", () => {
+ it("keeps the vendor geometry and only rewrites colours", () => {
+ const raw = getRawFileIconSvg("index.ts");
+ const toned = getFileIconSvg("index.ts");
+ expect(toned).not.toBe(raw);
+ expect(toned.replace(/#[0-9a-fA-F]{6}/g, "#")).toBe(raw.replace(/#[0-9a-fA-F]{6}/g, "#"));
+ });
+
+ it("pulls every colour toward neutral", () => {
+ const spread = (svg: string) => hexColors(svg).map(channelSpread);
+
+ for (const file of ["main.ts", "app.jsx", "style.css", "data.json", "notes.md"]) {
+ const before = spread(getRawFileIconSvg(file));
+ const after = spread(getFileIconSvg(file));
+ expect(after).toHaveLength(before.length);
+ after.forEach((value, index) => expect(value).toBeLessThan(before[index]));
+ }
+ });
+
+ it("leaves non-colour attributes alone", () => {
+ const toned = getFileIconSvg("Main.kt");
+ expect(toned).toContain('fill="url(#a)"');
+ });
+
+ it("returns the same string for repeated lookups", () => {
+ expect(getFileIconSvg("a.ts")).toBe(getFileIconSvg("b.ts"));
+ });
+
+ it("falls back to the default icon for unknown extensions", () => {
+ expect(getFileIconSvg("thing.qqq")).toBe(getFileIconSvg("thing"));
+ });
+});
diff --git a/packages/app/src/components/file-icon-svg.ts b/packages/app/src/components/file-icon-svg.ts
new file mode 100644
index 0000000000..4dc124b9cf
--- /dev/null
+++ b/packages/app/src/components/file-icon-svg.ts
@@ -0,0 +1,39 @@
+import { getRawFileIconSvg } from "@/components/material-file-icons";
+import { desaturateHexColor } from "@/utils/color";
+
+/**
+ * File icons, toned to sit in our UI.
+ *
+ * material-icon-theme's palette is built for VS Code's file tree, where the icon is the only
+ * colour on the row. Ours share a row with status dots, diff stats, and check badges, and a
+ * column of them at full chroma is the loudest thing in the panel. Every colour in the vendor
+ * SVG is pulled toward neutral at the same perceived lightness, so hue still tells you what the
+ * file is — that is the icon's whole job — without the row competing for attention.
+ *
+ * One knob. If icons read as too grey or too loud, move this and nothing else; per-icon
+ * overrides would put us back to hand-picking 53 colours.
+ */
+const ICON_CHROMA = 0.65;
+
+/** Anything hex-shaped. `desaturateHexColor` returns lengths it does not own untouched. */
+const HEX_COLOR_PATTERN = /#[0-9a-fA-F]+/g;
+
+const toned = new Map();
+
+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;
+}
diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx
index 9b7521dd65..fa3038d59e 100644
--- a/packages/app/src/components/left-sidebar.tsx
+++ b/packages/app/src/components/left-sidebar.tsx
@@ -28,7 +28,7 @@ import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { resolveDesktopSidebarWidth } from "@/components/desktop-sidebar-layout";
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarHeaderRow } from "@/components/sidebar/sidebar-header-row";
-import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/sidebar-display-preferences-menu";
+import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { Shortcut } from "@/components/ui/shortcut";
diff --git a/packages/app/src/components/material-file-icon.tsx b/packages/app/src/components/material-file-icon.tsx
index 46430f998a..aba0ccf129 100644
--- a/packages/app/src/components/material-file-icon.tsx
+++ b/packages/app/src/components/material-file-icon.tsx
@@ -1,6 +1,6 @@
import type { ComponentType } from "react";
import { SvgXml } from "react-native-svg";
-import { getFileIconSvg } from "@/components/material-file-icons";
+import { getFileIconSvg } from "@/components/file-icon-svg";
import type { PanelIconProps } from "@/panels/panel-registry";
export function MaterialFileIcon({ fileName, size }: { fileName: string; size: number }) {
diff --git a/packages/app/src/components/material-file-icons.ts b/packages/app/src/components/material-file-icons.ts
index 03e7611983..f9fec1f998 100644
--- a/packages/app/src/components/material-file-icons.ts
+++ b/packages/app/src/components/material-file-icons.ts
@@ -1,4 +1,8 @@
-// Auto-generated from material-icon-theme. Do not edit manually.
+// Vendor icon table, transcribed from material-icon-theme. The SVGs are copied verbatim,
+// including their colours — see file-icon-svg.ts for the module that tones them down for our
+// surfaces. Keeping this file a faithful copy is what makes re-copying an icon a mechanical edit.
+//
+// See docs/file-icons.md for how to add one.
const SVG_ICONS: Record = {
_default: ``,
@@ -125,7 +129,7 @@ const EXTENSION_TO_ICON: Record = {
zig: "zig",
};
-export function getFileIconSvg(fileName: string): string {
+export function getRawFileIconSvg(fileName: string): string {
const ext = getExtension(fileName);
if (ext) {
const iconName = EXTENSION_TO_ICON[ext];
diff --git a/packages/app/src/components/project-edit-sheet.tsx b/packages/app/src/components/project-edit-sheet.tsx
index f94ebdeeea..65801dee2f 100644
--- a/packages/app/src/components/project-edit-sheet.tsx
+++ b/packages/app/src/components/project-edit-sheet.tsx
@@ -162,8 +162,7 @@ export function ProjectEditSheet({
iconDataUri={state.previewDataUri}
initial={projectInitial(snapshot.projectName)}
projectViewKey={projectViewKey}
- imageStyle={styles.preview}
- fallbackStyle={styles.previewFallback}
+ size={40}
textStyle={styles.previewText}
/>