Desktop: custom context menus everywhere, never the browser's menu

One ContextMenu in the design system (keyboard, type-ahead, submenus, check items,
disabled reasons, Shift+F10 / Menu key, viewport placement, light and dark). The
native menu is prevented globally; text fields keep Cut / Copy / Paste / Select all.
Menus on sidebar and portfolio apps, app hero, releases, store art, live art,
reviews, checks, editor fields, catalog, prices, accounts, app folders, ignored.
This commit is contained in:
CRTkafa 2026-10-23 08:59:14 +08:00
parent 944b24fe59
commit dece83c3e8
23 changed files with 839 additions and 28 deletions

View file

@ -1,5 +1,7 @@
// Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new
// folder from the templates (`storebound init` with a profile, confirmed in a native dialog).
import { type MenuInput, useContextMenu } from '../ui/ContextMenu';
import { copyToast } from '../ui/app-menu';
import { failureText } from '../lib/results';
import { type ReactNode, useEffect, useRef, useState } from 'react';
import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api';
@ -19,9 +21,10 @@ type Profile = 'app' | 'game' | 'subscription-app';
interface Run { title: string; project: string; job: string; lines: string[]; result?: ToolResult; outcome?: WriteOutcome; error?: string }
/** Folder card wrapper carrying the app's tint (hooks cannot run inside the list map). */
function Tinted({ src, name, children }: { src: string | null | undefined; name: string; children: ReactNode }) {
function Tinted({ src, name, children, menu }: { src: string | null | undefined; name: string; children: ReactNode; menu: () => MenuInput }) {
const hue = useTint(src, name);
return <div className="li fold-card" style={tintStyle(hue)}>{children}</div>;
const cm = useContextMenu();
return <div className="li fold-card" style={tintStyle(hue)} {...cm(menu)}>{children}</div>;
}
function LocateButton({ project }: { project: Project }) {
@ -100,7 +103,21 @@ export function AppFolders() {
const e = d.statusOf(p);
const st = p.missing ? { text: t('folder.missingShort'), tone: 'bad' as const } : checkState(e);
return (
<Tinted key={p.id} src={d.icons[p.id]} name={e?.app?.name ?? p.name}>
<Tinted key={p.id} src={d.icons[p.id]} name={e?.app?.name ?? p.name} menu={() => [
!p.missing && { label: t('folders.open'), icon: 'arrowRight', onSelect: () => nav.go({ screen: 'app', appId: p.id, tab: 'releases' }) },
!p.missing && { label: t('folders.reveal'), icon: 'folder', onSelect: () => { api.reveal(p.id).catch(x => setError(errorText(x))); } },
'sep',
!p.missing && { label: t('folders.check'), icon: 'shield', disabled: busy ? t('cm.busy') : false, onSelect: () => check(p) },
!p.missing && { label: t('folders.gallery'), icon: 'image', disabled: busy ? t('cm.busy') : false, onSelect: () => gallery(p) },
'sep',
{ label: t('cm.copy'), icon: 'copy', submenu: [
{ label: t('cm.copyPath'), icon: 'folder', onSelect: () => copyToast(d, p.path) },
e?.app?.platforms.ios?.bundleId && { label: t('cm.copyBundleId'), icon: 'tag', shortcut: e.app.platforms.ios.bundleId, onSelect: () => copyToast(d, e.app!.platforms.ios!.bundleId) },
e?.app?.platforms.android?.packageName && { label: t('cm.copyPackage'), icon: 'tag', shortcut: e.app.platforms.android.packageName, onSelect: () => copyToast(d, e.app!.platforms.android!.packageName) },
] },
'sep',
{ label: t('folders.remove', { name: p.name }), icon: 'trash', danger: true, disabled: busy ? t('cm.busy') : false, onSelect: () => remove(p) },
]}>
<AppIcon name={p.name} src={d.icons[p.id]} className="fold-icon" />
<div className="li-main">
<span className="row" style={{ gap: 8 }}><span className="li-title">{e?.app?.name ?? p.name}</span><StatusPill tone={st.tone}>{st.text}</StatusPill></span>