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} />