From ab8151d33fc0c14d630ef79ae0ebcce118f56007 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 3 Aug 2026 18:55:45 +0200 Subject: [PATCH] feat(app): sidebar display preferences, a shared menu engine, and a colour pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar polish round. Menus. `dropdown-menu` and `context-menu` were two copies of one engine that differed only in how the trigger fires — identical from `computePosition` down — and neither could nest. Both are now thin wrappers over `components/ui/menu/`, which owns anchoring, a pure page-stack navigation model, and one presentation fork: flyout on pointer, pushed pages in a sheet on compact. All 31 call sites are unchanged. Items got a rounded inset fill, a visible separator, and `selected` (a check) split from `active` (a fill). Sidebar display preferences. The flat wall of four sections becomes one row per decision with its value, and per-feature toggles over what a workspace row shows: host, pull request, checks, scripts. Each is independent — a ticked toggle that draws nothing is a lie. `display-preferences/` hides that grouping lives in zustand while title source and row items are app settings. Timestamps. "Last activity" now ticks itself. The label rounds to the minute and never shows seconds; past a week it becomes a date. The timer lives in the leaf ``, at most one per resolution tier shared by every row, and a tick only re-renders when the string actually changed — so a memoized row that legitimately never re-renders still stays current. Colour. Full status tier regenerated at 90% of gamut max (was 85), same rule as the muted tier, lightness and hue untouched. File icons are pulled toward neutral in OKLab behind `getFileIconSvg` — material-icon-theme picks its palette for a tree where the icon is the only colour on the row, and ours is not. New/deleted files use the forge square-plus/square-minus glyphs at muted weight instead of tinted pills. Project icons round a little more; uploaded icons never do. The project status badge is a knockout again: it fills with the colour of the row behind it, so the ring reads as a gap in the icon rather than a white halo. File rows. The always-visible kebab is gone from the file explorer and diff tree in favour of right-click, which also reclaims the reserved action slot. --- CLAUDE.md | 1 + docs/file-icons.md | 36 +- docs/floating-panels.md | 3 + docs/glossary.md | 2 +- docs/menus.md | 122 ++ .../e2e/browser/diff-row-alignment.spec.ts | 60 +- packages/app/src/components/diff-stat.tsx | 4 +- .../app/src/components/file-actions-menu.tsx | 121 +- .../app/src/components/file-change-icon.tsx | 43 + .../app/src/components/file-explorer-pane.tsx | 49 +- .../app/src/components/file-icon-svg.test.ts | 46 + packages/app/src/components/file-icon-svg.ts | 39 + packages/app/src/components/left-sidebar.tsx | 2 +- .../app/src/components/material-file-icon.tsx | 2 +- .../app/src/components/material-file-icons.ts | 8 +- .../app/src/components/project-edit-sheet.tsx | 15 +- .../app/src/components/project-icon-view.tsx | 50 +- .../sidebar-agent-list-skeleton.tsx | 10 +- .../src/components/sidebar-workspace-list.tsx | 59 +- .../sidebar/display-preferences/menu.tsx | 406 +++++++ .../sidebar/display-preferences/model.ts | 112 ++ .../display-preferences/row-items.test.ts | 44 + .../sidebar/display-preferences/row-items.ts | 47 + .../app/src/components/sidebar/host-badge.tsx | 116 -- .../sidebar/project-leading-visual.tsx | 82 +- .../sidebar-display-preferences-menu.tsx | 220 ---- .../sidebar/sidebar-row-backdrop.ts | 27 + .../sidebar/sidebar-status-list.tsx | 81 +- .../sidebar/sidebar-workspace-menu.tsx | 7 +- .../sidebar/sidebar-workspace-row-content.tsx | 265 ++-- .../sidebar/sidebar-workspace-row.tsx | 47 +- .../workspace-meta-row/check-indicator.tsx | 108 ++ .../workspace-meta-row/check-summary.test.ts | 108 ++ .../workspace-meta-row/check-summary.ts | 72 ++ .../sidebar/workspace-meta-row/index.tsx | 359 ++++++ .../workspace-meta-row/meta-items.test.ts | 76 ++ .../sidebar/workspace-meta-row/meta-items.ts | 59 + .../workspace-meta-row/script-summary.test.ts | 66 + .../workspace-meta-row/script-summary.ts | 26 + .../sidebar/workspace-trailing/index.tsx | 80 ++ .../app/src/components/ui/context-menu.tsx | 836 +------------ .../app/src/components/ui/dropdown-menu.tsx | 1073 +---------------- packages/app/src/components/ui/menu/index.ts | 35 + .../components/ui/menu/menu-anchor.test.ts | 77 ++ .../app/src/components/ui/menu/menu-anchor.ts | 159 +++ .../src/components/ui/menu/menu-context.ts | 203 ++++ .../app/src/components/ui/menu/menu-item.tsx | 357 ++++++ .../ui/menu/menu-navigation.test.ts | 75 ++ .../src/components/ui/menu/menu-navigation.ts | 48 + .../src/components/ui/menu/menu-overlay.tsx | 472 ++++++++ .../app/src/components/ui/menu/menu-root.tsx | 95 ++ .../app/src/components/ui/menu/menu-sub.tsx | 136 +++ .../src/components/ui/menu/menu-surface.tsx | 422 +++++++ .../src/components/workspace-hover-card.tsx | 12 +- .../src/components/workspace-setup-dialog.tsx | 5 +- packages/app/src/git/diff-folder-row.tsx | 5 - packages/app/src/git/diff-pane.tsx | 114 +- .../app/src/hooks/use-compact-time-ago.ts | 68 ++ packages/app/src/hooks/use-settings/index.ts | 63 +- .../app/src/hooks/use-settings/storage.ts | 64 +- packages/app/src/i18n/resources.test.ts | 4 + packages/app/src/i18n/resources/ar.ts | 37 +- packages/app/src/i18n/resources/en.ts | 37 +- packages/app/src/i18n/resources/es.ts | 37 +- packages/app/src/i18n/resources/fr.ts | 37 +- packages/app/src/i18n/resources/ja.ts | 37 +- packages/app/src/i18n/resources/pt-BR.ts | 37 +- packages/app/src/i18n/resources/ru.ts | 37 +- packages/app/src/i18n/resources/zh-CN.ts | 37 +- .../app/src/screens/new-workspace-screen.tsx | 18 +- .../src/screens/project-settings-screen.tsx | 15 +- packages/app/src/screens/projects-screen.tsx | 15 +- .../settings/host-appearance-section.tsx | 30 +- packages/app/src/styles/surface-backdrop.ts | 14 + packages/app/src/styles/theme.ts | 64 +- packages/app/src/utils/color.test.ts | 62 + packages/app/src/utils/color.ts | 95 ++ .../src/utils/relative-time-ticker.test.ts | 99 ++ .../app/src/utils/relative-time-ticker.ts | 96 ++ packages/app/src/utils/time.test.ts | 49 +- packages/app/src/utils/time.ts | 104 +- 81 files changed, 5457 insertions(+), 2903 deletions(-) create mode 100644 docs/menus.md create mode 100644 packages/app/src/components/file-change-icon.tsx create mode 100644 packages/app/src/components/file-icon-svg.test.ts create mode 100644 packages/app/src/components/file-icon-svg.ts create mode 100644 packages/app/src/components/sidebar/display-preferences/menu.tsx create mode 100644 packages/app/src/components/sidebar/display-preferences/model.ts create mode 100644 packages/app/src/components/sidebar/display-preferences/row-items.test.ts create mode 100644 packages/app/src/components/sidebar/display-preferences/row-items.ts delete mode 100644 packages/app/src/components/sidebar/host-badge.tsx delete mode 100644 packages/app/src/components/sidebar/sidebar-display-preferences-menu.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-row-backdrop.ts create mode 100644 packages/app/src/components/sidebar/workspace-meta-row/check-indicator.tsx create mode 100644 packages/app/src/components/sidebar/workspace-meta-row/check-summary.test.ts create mode 100644 packages/app/src/components/sidebar/workspace-meta-row/check-summary.ts create mode 100644 packages/app/src/components/sidebar/workspace-meta-row/index.tsx create mode 100644 packages/app/src/components/sidebar/workspace-meta-row/meta-items.test.ts create mode 100644 packages/app/src/components/sidebar/workspace-meta-row/meta-items.ts create mode 100644 packages/app/src/components/sidebar/workspace-meta-row/script-summary.test.ts create mode 100644 packages/app/src/components/sidebar/workspace-meta-row/script-summary.ts create mode 100644 packages/app/src/components/sidebar/workspace-trailing/index.tsx create mode 100644 packages/app/src/components/ui/menu/index.ts create mode 100644 packages/app/src/components/ui/menu/menu-anchor.test.ts create mode 100644 packages/app/src/components/ui/menu/menu-anchor.ts create mode 100644 packages/app/src/components/ui/menu/menu-context.ts create mode 100644 packages/app/src/components/ui/menu/menu-item.tsx create mode 100644 packages/app/src/components/ui/menu/menu-navigation.test.ts create mode 100644 packages/app/src/components/ui/menu/menu-navigation.ts create mode 100644 packages/app/src/components/ui/menu/menu-overlay.tsx create mode 100644 packages/app/src/components/ui/menu/menu-root.tsx create mode 100644 packages/app/src/components/ui/menu/menu-sub.tsx create mode 100644 packages/app/src/components/ui/menu/menu-surface.tsx create mode 100644 packages/app/src/hooks/use-compact-time-ago.ts create mode 100644 packages/app/src/styles/surface-backdrop.ts create mode 100644 packages/app/src/utils/color.test.ts create mode 100644 packages/app/src/utils/color.ts create mode 100644 packages/app/src/utils/relative-time-ticker.test.ts create mode 100644 packages/app/src/utils/relative-time-ticker.ts 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} />