@forgeax/interface 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package/browser/ApplicationShell.js +2 -1
- package/dist/package/browser/appHostBootstrap.js +10 -0
- package/dist/package/browser/components/MenuBar/MenuBar.d.ts +3 -2
- package/dist/package/browser/components/MenuBar/MenuBar.js +9 -33
- package/dist/package/browser/components/MenuBar/menubar-surface.d.ts +3 -2
- package/dist/package/browser/components/MenuBar/menubar-surface.js +16 -13
- package/dist/package/browser/core/app-shell/host.js +5 -3
- package/dist/package/browser/core/app-shell/types.d.ts +3 -1
- package/dist/package/browser/core/extensions/builtin-menus.js +23 -3
- package/dist/package/browser/lib/application-menu-projection.d.ts +5 -0
- package/dist/package/browser/lib/application-menu-projection.js +53 -0
- package/dist/package/browser/lib/menu-registry.d.ts +0 -49
- package/dist/package/browser/lib/menu-registry.js +3 -197
- package/dist/package/browser/lib/menu-registry.test-utils.d.ts +11 -0
- package/dist/package/browser/lib/menu-registry.test-utils.js +28 -0
- package/dist/package/browser/lib/native-menu-bridge.d.ts +17 -3
- package/dist/package/browser/lib/native-menu-bridge.js +121 -81
- package/dist/package/node/ApplicationShell.js +2 -1
- package/dist/package/node/appHostBootstrap.js +10 -0
- package/dist/package/node/components/MenuBar/MenuBar.js +9 -33
- package/dist/package/node/components/MenuBar/menubar-surface.js +16 -13
- package/dist/package/node/core/app-shell/host.js +5 -3
- package/dist/package/node/core/extensions/builtin-menus.js +23 -3
- package/dist/package/node/lib/application-menu-projection.js +53 -0
- package/dist/package/node/lib/menu-registry.js +3 -197
- package/dist/package/node/lib/menu-registry.test-utils.js +28 -0
- package/dist/package/node/lib/native-menu-bridge.js +121 -81
- package/package.json +2 -2
- package/dist/package/browser/lib/repair-builtin-menus.d.ts +0 -6
- package/dist/package/browser/lib/repair-builtin-menus.js +0 -18
- package/dist/package/node/lib/repair-builtin-menus.js +0 -18
|
@@ -46,7 +46,8 @@ export function ApplicationShell({ runtime, onboarding }) {
|
|
|
46
46
|
useEffect(() => {
|
|
47
47
|
if (!isTauri())
|
|
48
48
|
return;
|
|
49
|
-
|
|
49
|
+
return initNativeMenuBridge({
|
|
50
|
+
menus: runtime.host.menus,
|
|
50
51
|
execute: (id, args) => runtime.host.commands.execute(id, args),
|
|
51
52
|
translate: t,
|
|
52
53
|
});
|
|
@@ -61,9 +61,14 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
61
61
|
...m,
|
|
62
62
|
async setup(ctx) {
|
|
63
63
|
control.beginSetup(m);
|
|
64
|
+
const menuCleanups = [];
|
|
64
65
|
try {
|
|
65
66
|
const cleanups = [];
|
|
66
67
|
let pageCleanup;
|
|
68
|
+
for (const menu of m.contributes?.menus ?? []) {
|
|
69
|
+
menuCleanups.push(host.menus.register(menu));
|
|
70
|
+
}
|
|
71
|
+
cleanups.push(...menuCleanups);
|
|
67
72
|
if (m.contributes?.panels) {
|
|
68
73
|
cleanups.push(control.contributePanels(m.id, m.contributes.panels));
|
|
69
74
|
}
|
|
@@ -94,6 +99,11 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
94
99
|
await c();
|
|
95
100
|
};
|
|
96
101
|
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
for (const cleanup of menuCleanups.slice().reverse())
|
|
104
|
+
await cleanup();
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
97
107
|
finally {
|
|
98
108
|
control.endSetup();
|
|
99
109
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type TFunction } from '../../i18n';
|
|
2
|
-
import {
|
|
2
|
+
import type { MenuId } from '../../lib/menu-registry';
|
|
3
|
+
import type { ApplicationMenuItem as MenuItemDef } from '@forgeax/app-shell/application';
|
|
3
4
|
import './MenuBar.css';
|
|
4
5
|
/** 第三个参数 itemId 是**记账用**的稳定菜单项 id(如 file.save)。可选,所以
|
|
5
6
|
* 只传两参的既有调用点照常编译。为什么需要它:人机同账要求两侧记录形状一致 ——
|
|
@@ -9,7 +10,7 @@ import './MenuBar.css';
|
|
|
9
10
|
type Execute = (id: string, args?: unknown, itemId?: string) => void;
|
|
10
11
|
interface TopMenuProps {
|
|
11
12
|
menu: MenuId;
|
|
12
|
-
items: MenuItemDef[];
|
|
13
|
+
items: readonly MenuItemDef[];
|
|
13
14
|
t: TFunction;
|
|
14
15
|
execute: Execute;
|
|
15
16
|
/** Controlled open flag — the whole bar shares one "which menu is open"
|
|
@@ -17,20 +17,19 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
17
17
|
* time; the underlying accessor is identical). This keeps keyboard shortcuts,
|
|
18
18
|
* command palette, and menu clicks on one entry.
|
|
19
19
|
*/
|
|
20
|
-
import { useCallback, useEffect, useState, useSyncExternalStore } from 'react';
|
|
20
|
+
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
|
|
21
21
|
import { BookOpen, BoxSelect, Check, Clipboard, Clock, Code, Copy, FilePlus, FlipHorizontal2, Focus, FolderOpen, FolderSearch, Gamepad2, Github, Globe, GraduationCap, Hash, Info, Keyboard, LayoutGrid, Maximize, MessageCircle, Newspaper, Package, Pencil, Play, Redo2, RefreshCw, RotateCcw, Save, SaveAll, Scale, Scan, Scissors, ScrollText, Search, Settings, Shapes, Sparkles, Square, SquareDashed, Store, Trash2, Undo2, Upload, X, } from 'lucide-react';
|
|
22
22
|
import { useTranslation } from '../../i18n';
|
|
23
23
|
import { useHost } from '../../core/app-shell';
|
|
24
24
|
import { useBrand } from '../../brand/BrandProvider';
|
|
25
25
|
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, } from '../ui/dropdown-menu';
|
|
26
|
-
import {
|
|
26
|
+
import { projectApplicationMenus } from '../../lib/application-menu-projection';
|
|
27
27
|
import { prettyCombo } from '../../lib/global-shortcuts';
|
|
28
28
|
import { useMenubarSurface } from './menubar-surface';
|
|
29
29
|
import { openMenuFromTriggerPointerDown } from './menubar-open-state';
|
|
30
30
|
import { isTauri } from '../../lib/platform/runtime';
|
|
31
31
|
import { captureFocusedTextEditTarget } from '../../lib/text-edit-actions';
|
|
32
32
|
import { getRecentGamesRevision, subscribeRecentGames, warmRecentGames, } from '../../lib/recent-games';
|
|
33
|
-
import { repairBuiltinMenusIfEmpty } from '../../lib/repair-builtin-menus';
|
|
34
33
|
import './MenuBar.css';
|
|
35
34
|
// Icon name → lucide component. Mirrors ContextMenu.tsx's MENU_ICONS pattern
|
|
36
35
|
// (single-file lookup so an unknown name gracefully renders no icon rather
|
|
@@ -83,30 +82,7 @@ const MENU_ICONS = {
|
|
|
83
82
|
upload: Upload,
|
|
84
83
|
x: X,
|
|
85
84
|
};
|
|
86
|
-
|
|
87
|
-
// snapshotAllMenus() computes a fresh object each call; useSyncExternalStore
|
|
88
|
-
// requires that repeated getSnapshot() reads return the SAME identity until
|
|
89
|
-
// the store actually changes, else React re-renders on every read (potential
|
|
90
|
-
// infinite loop). Module-level cache + a single change-listener keeps identity
|
|
91
|
-
// stable between registrations. Registry lives as long as this module, so a
|
|
92
|
-
// single unbound listener at import time is fine (no cleanup handle needed).
|
|
93
|
-
let cachedSnapshot = snapshotAllMenus();
|
|
94
|
-
onMenuChange(() => { cachedSnapshot = snapshotAllMenus(); });
|
|
95
|
-
function getSnapshot() {
|
|
96
|
-
return cachedSnapshot;
|
|
97
|
-
}
|
|
98
|
-
// SSR/server snapshot — stable identity across calls. This app is a pure
|
|
99
|
-
// client build, so this branch never actually fires; providing it satisfies
|
|
100
|
-
// the useSyncExternalStore contract and future-proofs against SSR.
|
|
101
|
-
const EMPTY_SNAPSHOT = Object.freeze({
|
|
102
|
-
brand: [], file: [], edit: [], window: [], build: [], select: [], help: [], publish: [],
|
|
103
|
-
});
|
|
104
|
-
function getServerSnapshot() {
|
|
105
|
-
return EMPTY_SNAPSHOT;
|
|
106
|
-
}
|
|
107
|
-
function subscribe(cb) {
|
|
108
|
-
return onMenuChange(cb);
|
|
109
|
-
}
|
|
85
|
+
const EMPTY_MENU_ITEMS = Object.freeze([]);
|
|
110
86
|
// Top-level dropdowns rendered on web, in order. `publish` is excluded (TopBar
|
|
111
87
|
// owns the Publish CTA at the far right); `brand` renders separately at the
|
|
112
88
|
// far left.
|
|
@@ -130,7 +106,7 @@ function MenuItemRow({ item, t, execute }) {
|
|
|
130
106
|
if (item.when && !item.when())
|
|
131
107
|
return null;
|
|
132
108
|
const enabled = resolveEnabled(item);
|
|
133
|
-
const label =
|
|
109
|
+
const label = item.label;
|
|
134
110
|
const combo = item.keybinding ? prettyCombo(item.keybinding) : '';
|
|
135
111
|
const checked = item.checked ? item.checked() : false;
|
|
136
112
|
const hasStaticChildren = !!item.children && item.children.length > 0;
|
|
@@ -209,12 +185,12 @@ export function TopMenu({ menu, items, t, execute, open, onOpenChange, onTrigger
|
|
|
209
185
|
}
|
|
210
186
|
// ── Public component ─────────────────────────────────────────────────────
|
|
211
187
|
export function MenuBar() {
|
|
212
|
-
// StrictMode dispose / Vite HMR can leave the registry empty while chrome
|
|
213
|
-
// stays mounted — repair synchronously so the first render sees menus.
|
|
214
|
-
repairBuiltinMenusIfEmpty();
|
|
215
188
|
const { t, i18n } = useTranslation();
|
|
216
|
-
const menus = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
|
217
189
|
const host = useHost();
|
|
190
|
+
const subscribe = useCallback((listener) => host.menus.subscribe(listener), [host]);
|
|
191
|
+
const getSnapshot = useCallback(() => host.menus.snapshot(), [host]);
|
|
192
|
+
const items = useSyncExternalStore(subscribe, getSnapshot, () => EMPTY_MENU_ITEMS);
|
|
193
|
+
const menus = useMemo(() => projectApplicationMenus(items), [items]);
|
|
218
194
|
// Same command bus TopBar uses. Fire-and-forget: menu clicks never await
|
|
219
195
|
// the command result — that would block dropdown-close animation.
|
|
220
196
|
// 纯执行,不记账。AI 经 host.menubar.invoke 走的就是这一个 —— 它那一路已经由
|
|
@@ -245,7 +221,7 @@ export function MenuBar() {
|
|
|
245
221
|
// 传 i18n.language 而不是 t:`t` 是模块级恒定标识(i18n/index.ts 刻意如此,
|
|
246
222
|
// 免得列进依赖数组造成无限重渲染),用它当依赖等于永不重投影 —— 切语言后 DOM
|
|
247
223
|
// 变了、snapshot 还是旧语言,AI 按 label 导航与用户所见错位(2026-08-06 探测)。
|
|
248
|
-
useMenubarSurface(menus, t, executeForSurface, i18n.language);
|
|
224
|
+
useMenubarSurface(host.menus, t, executeForSurface, i18n.language);
|
|
249
225
|
// One shared "which top-level menu is open" value for the whole bar. This is
|
|
250
226
|
// what makes it behave like a native/UE menu bar rather than N unrelated
|
|
251
227
|
// dropdowns: only one panel is open at a time, and once ANY panel is open,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { TFunction } from '../../i18n';
|
|
2
|
+
import type { ApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
2
3
|
export interface MenuTreeNode {
|
|
3
4
|
id: string;
|
|
4
5
|
label: string;
|
|
@@ -20,10 +21,10 @@ export interface MenuTreeNode {
|
|
|
20
21
|
/** AI 路径的执行器**必须返回 promise**:run 会 await 它,命令失败 → throw →
|
|
21
22
|
* ack ok=false。人路径的 fire-and-forget 版本(不卡下拉动画)不在此签名内。 */
|
|
22
23
|
type Execute = (commandId: string, args?: unknown) => Promise<unknown>;
|
|
23
|
-
export declare function projectMenuTree(t: TFunction): Record<string, MenuTreeNode[]>;
|
|
24
|
+
export declare function projectMenuTree(t: TFunction, registry: ApplicationMenuRegistry): Record<string, MenuTreeNode[]>;
|
|
24
25
|
/** MenuBar 挂载时注册 host.menubar。menusRevision 传注册表快照对象,引用一变
|
|
25
26
|
* (注册表变更触发的重渲染)就重新投影 PUT。 */
|
|
26
|
-
export declare function useMenubarSurface(
|
|
27
|
+
export declare function useMenubarSurface(registry: ApplicationMenuRegistry, t: TFunction, execute: Execute,
|
|
27
28
|
/** 当前语言。**必须**由调用方传:`t` 的标识是模块级恒定的(见 i18n/index.ts
|
|
28
29
|
* 的注释:列进依赖会造成无限重渲染),拿它当依赖 = 永不重投影。 */
|
|
29
30
|
language?: string): void;
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { useEffect, useRef } from 'react';
|
|
19
19
|
import { useSurface } from '../../lib/surface';
|
|
20
|
-
import {
|
|
20
|
+
import { projectApplicationMenus } from '../../lib/application-menu-projection';
|
|
21
21
|
import { warmRecentGames } from '../../lib/recent-games';
|
|
22
22
|
const MENUBAR_SCHEMA = {
|
|
23
23
|
type: 'object',
|
|
@@ -42,7 +42,7 @@ function projectItem(item, t) {
|
|
|
42
42
|
.filter((node) => node !== null);
|
|
43
43
|
return {
|
|
44
44
|
id: item.id,
|
|
45
|
-
label:
|
|
45
|
+
label: item.label,
|
|
46
46
|
kind: isSub ? 'submenu' : item.commandId ? 'command' : 'placeholder',
|
|
47
47
|
...(item.commandId ? { commandId: item.commandId } : {}),
|
|
48
48
|
...(item.args !== undefined ? { args: item.args } : {}),
|
|
@@ -53,9 +53,9 @@ function projectItem(item, t) {
|
|
|
53
53
|
...(children && children.length ? { children } : {}),
|
|
54
54
|
};
|
|
55
55
|
}
|
|
56
|
-
export function projectMenuTree(t) {
|
|
56
|
+
export function projectMenuTree(t, registry) {
|
|
57
57
|
const out = {};
|
|
58
|
-
for (const [menu, defs] of Object.entries(
|
|
58
|
+
for (const [menu, defs] of Object.entries(projectApplicationMenus(registry.snapshot()))) {
|
|
59
59
|
out[menu] = defs
|
|
60
60
|
.map((def) => projectItem(def, t))
|
|
61
61
|
.filter((node) => node !== null);
|
|
@@ -64,7 +64,7 @@ export function projectMenuTree(t) {
|
|
|
64
64
|
}
|
|
65
65
|
/** 在注册表里解析一个可执行菜单项:先静态(含 children 递归),再对动态子菜单
|
|
66
66
|
* 求值按 id 或 label 匹配(动态项的 labelKey 就是显示名,如游戏名)。 */
|
|
67
|
-
function resolveInvokable(target, t) {
|
|
67
|
+
function resolveInvokable(target, t, registry) {
|
|
68
68
|
/** 与 projectItem 同一条谓词:`when()` 为假 = 该项在界面上根本不存在。
|
|
69
69
|
* 2026-08-06 探测:此前只有投影过滤 when(),解析器完全不看 —— 一个在 snapshot
|
|
70
70
|
* 和 DOM 里都不存在的项仍可被 invoke 执行。人点不到的东西 AI 不能点,否则
|
|
@@ -89,7 +89,7 @@ function resolveInvokable(target, t) {
|
|
|
89
89
|
return null;
|
|
90
90
|
}
|
|
91
91
|
return kids.filter(visible).find((kid) => (target.itemId && kid.id === target.itemId)
|
|
92
|
-
|| (target.label && (kid.
|
|
92
|
+
|| (target.label && (kid.label === target.label || kid.label === target.label))) ?? null;
|
|
93
93
|
};
|
|
94
94
|
const walk = (defs) => {
|
|
95
95
|
for (const def of defs) {
|
|
@@ -99,7 +99,7 @@ function resolveInvokable(target, t) {
|
|
|
99
99
|
continue;
|
|
100
100
|
if (target.itemId && def.id === target.itemId)
|
|
101
101
|
return def;
|
|
102
|
-
if (target.label && !target.itemId &&
|
|
102
|
+
if (target.label && !target.itemId && def.label === target.label)
|
|
103
103
|
return def;
|
|
104
104
|
if (def.children) {
|
|
105
105
|
const hit = walk(def.children);
|
|
@@ -114,7 +114,7 @@ function resolveInvokable(target, t) {
|
|
|
114
114
|
}
|
|
115
115
|
return null;
|
|
116
116
|
};
|
|
117
|
-
for (const defs of Object.values(
|
|
117
|
+
for (const defs of Object.values(projectApplicationMenus(registry.snapshot()))) {
|
|
118
118
|
const hit = walk(defs);
|
|
119
119
|
if (hit)
|
|
120
120
|
return hit;
|
|
@@ -123,10 +123,13 @@ function resolveInvokable(target, t) {
|
|
|
123
123
|
}
|
|
124
124
|
/** MenuBar 挂载时注册 host.menubar。menusRevision 传注册表快照对象,引用一变
|
|
125
125
|
* (注册表变更触发的重渲染)就重新投影 PUT。 */
|
|
126
|
-
export function useMenubarSurface(
|
|
126
|
+
export function useMenubarSurface(registry, t, execute,
|
|
127
127
|
/** 当前语言。**必须**由调用方传:`t` 的标识是模块级恒定的(见 i18n/index.ts
|
|
128
128
|
* 的注释:列进依赖会造成无限重渲染),拿它当依赖 = 永不重投影。 */
|
|
129
129
|
language) {
|
|
130
|
+
const menusRevision = registry.snapshot();
|
|
131
|
+
const registryRef = useRef(registry);
|
|
132
|
+
registryRef.current = registry;
|
|
130
133
|
const executeRef = useRef(execute);
|
|
131
134
|
executeRef.current = execute;
|
|
132
135
|
const tRef = useRef(t);
|
|
@@ -135,7 +138,7 @@ language) {
|
|
|
135
138
|
id: 'host.menubar',
|
|
136
139
|
layer: 'host',
|
|
137
140
|
schema: MENUBAR_SCHEMA,
|
|
138
|
-
initialSnapshot: { menus: projectMenuTree(t) },
|
|
141
|
+
initialSnapshot: { menus: projectMenuTree(t, registry) },
|
|
139
142
|
actions: {
|
|
140
143
|
invoke: {
|
|
141
144
|
id: 'invoke',
|
|
@@ -149,14 +152,14 @@ language) {
|
|
|
149
152
|
},
|
|
150
153
|
run: async (raw) => {
|
|
151
154
|
const target = (raw ?? {});
|
|
152
|
-
let def = resolveInvokable(target, tRef.current);
|
|
155
|
+
let def = resolveInvokable(target, tRef.current, registryRef.current);
|
|
153
156
|
if (!def) {
|
|
154
157
|
// 冷缓存(2026-08-06 外审 B6②):动态子项(最近游戏)只在人点开 File 下拉
|
|
155
158
|
// 或 Tauri 原生菜单构建时被预热;web 模式下人没点过 File,dynamicChildren()
|
|
156
159
|
// 恒空,AI 按 id/label 解析动态项必然 not found —— 桌面能用、web 不能用。
|
|
157
160
|
// AI 先到时主动预热一次再重解析,与人"点开就看见"对齐。
|
|
158
161
|
await warmRecentGames();
|
|
159
|
-
def = resolveInvokable(target, tRef.current);
|
|
162
|
+
def = resolveInvokable(target, tRef.current, registryRef.current);
|
|
160
163
|
}
|
|
161
164
|
if (!def)
|
|
162
165
|
throw new Error(`menu item not found: ${JSON.stringify(target)}`);
|
|
@@ -195,7 +198,7 @@ language) {
|
|
|
195
198
|
},
|
|
196
199
|
});
|
|
197
200
|
useEffect(() => {
|
|
198
|
-
surface.setSnapshot({ menus: projectMenuTree(tRef.current) });
|
|
201
|
+
surface.setSnapshot({ menus: projectMenuTree(tRef.current, registry) });
|
|
199
202
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
200
203
|
}, [menusRevision, language]);
|
|
201
204
|
}
|
|
@@ -13,9 +13,9 @@ import { derivePanelRenderers } from './derive-panel-renderers';
|
|
|
13
13
|
import { DEFAULT_PANEL_RENDERERS } from '../../components/DockShell/panelRenderers';
|
|
14
14
|
import { installPageNavigation } from '../page-navigation';
|
|
15
15
|
import { createContextualKeybindings } from '../contextual-keybindings';
|
|
16
|
-
import { createApplicationShortcutRegistry } from '@forgeax/app-shell/application';
|
|
16
|
+
import { createApplicationShortcutRegistry, createApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
17
17
|
const BUILT_IN_CAPS = [
|
|
18
|
-
'commands', 'keybindings', 'shortcuts', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
|
|
18
|
+
'commands', 'keybindings', 'shortcuts', 'menus', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
|
|
19
19
|
'activities', 'resourceEditors',
|
|
20
20
|
];
|
|
21
21
|
export function createAppHost(deps = {}) {
|
|
@@ -25,6 +25,7 @@ export function createAppHost(deps = {}) {
|
|
|
25
25
|
caps.add(c);
|
|
26
26
|
const commands = createCommandsRegistry();
|
|
27
27
|
const shortcuts = createApplicationShortcutRegistry();
|
|
28
|
+
const menus = createApplicationMenuRegistry();
|
|
28
29
|
const keybindings = createContextualKeybindings(commands, {
|
|
29
30
|
onCommandError(error, commandId) {
|
|
30
31
|
log.error(`[app-shell] keybinding command "${commandId}" failed`, error);
|
|
@@ -64,7 +65,7 @@ export function createAppHost(deps = {}) {
|
|
|
64
65
|
const extensionFields = {};
|
|
65
66
|
let activeSetup = null;
|
|
66
67
|
const base = {
|
|
67
|
-
commands, keybindings, shortcuts, bus, storage, contextKeys,
|
|
68
|
+
commands, keybindings, shortcuts, menus, bus, storage, contextKeys,
|
|
68
69
|
get panels() { return panelsSnapshot(); },
|
|
69
70
|
panelActions,
|
|
70
71
|
panelControls,
|
|
@@ -184,6 +185,7 @@ export function createAppHost(deps = {}) {
|
|
|
184
185
|
await pageSession.dispose();
|
|
185
186
|
keybindings.dispose();
|
|
186
187
|
shortcuts.dispose();
|
|
188
|
+
menus.dispose();
|
|
187
189
|
bus.destroy();
|
|
188
190
|
},
|
|
189
191
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ApplicationShortcutRegistry } from '@forgeax/app-shell/application';
|
|
1
|
+
import type { ApplicationShortcutRegistry, ApplicationMenuRegistry, ApplicationMenuItem } from '@forgeax/app-shell/application';
|
|
2
2
|
import type { ExtensionManifest, Cleanup } from '../extension-foundation';
|
|
3
3
|
import type { EventBus } from '../extension-foundation/bus';
|
|
4
4
|
import type { CommandsRegistry, CommandDescriptor } from '../extension-foundation/commands';
|
|
@@ -115,6 +115,7 @@ export interface AppHostBase {
|
|
|
115
115
|
readonly commands: CommandsRegistry;
|
|
116
116
|
readonly keybindings: ContextualKeybindingsApi;
|
|
117
117
|
readonly shortcuts: ApplicationShortcutRegistry;
|
|
118
|
+
readonly menus: ApplicationMenuRegistry;
|
|
118
119
|
readonly bus: EventBus<AppBusEventMap>;
|
|
119
120
|
readonly storage: StorageApi;
|
|
120
121
|
readonly contextKeys: ContextKeysApi;
|
|
@@ -160,6 +161,7 @@ export interface AppExtensionContext {
|
|
|
160
161
|
* before setup() runs and removed with the extension's cleanup; a pure-UI
|
|
161
162
|
* extension is just data (no setup at all). */
|
|
162
163
|
export interface AppExtensionContributes {
|
|
164
|
+
readonly menus?: readonly ApplicationMenuItem[];
|
|
163
165
|
readonly panels?: Partial<PanelRenderers>;
|
|
164
166
|
readonly panelActions?: readonly PanelActionContribution[];
|
|
165
167
|
readonly panelControls?: readonly PanelControlContribution[];
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// Mirrors the style of `builtin-commands.ts`: a single `AppExtension` whose
|
|
15
15
|
// `setup(ctx)` calls the registrar for every item, collects the cleanups, and
|
|
16
16
|
// returns a reverse-order cleanup runner.
|
|
17
|
-
import {
|
|
17
|
+
import { t } from '../../i18n';
|
|
18
18
|
import { isDockPanelVisible } from '@forgeax/app-shell/dock';
|
|
19
19
|
import { useShellStore } from '../../store';
|
|
20
20
|
import { getRecentGames } from '../../lib/recent-games';
|
|
@@ -28,10 +28,30 @@ const isVisible = (id) => () => isDockPanelVisible(id);
|
|
|
28
28
|
// mounted, `chatpanelCollapsed` flips its collapsed state — invert for
|
|
29
29
|
// "checked = visible".
|
|
30
30
|
const isChatpanelOpen = () => !useShellStore.getState().chatpanelCollapsed;
|
|
31
|
+
// Product section order is explicit, independent of extension activation order.
|
|
32
|
+
const SECTION_ORDER = {
|
|
33
|
+
brand: { app: 10, about: 20 }, file: { game: 10, file: 20, io: 30, recent: 10 },
|
|
34
|
+
edit: { history: 10, clipboard: 20, find: 30, version: 40 },
|
|
35
|
+
window: { panels: 10, layout: 20 }, build: { run: 10, export: 20 },
|
|
36
|
+
select: { basic: 10, byCond: 20, view: 30 },
|
|
37
|
+
help: { docs: 10, resources: 20, app: 30, about: 40 },
|
|
38
|
+
};
|
|
39
|
+
function localizeMenu(def) {
|
|
40
|
+
const { labelKey, children, dynamicChildren, ...metadata } = def;
|
|
41
|
+
const groupOrder = SECTION_ORDER[def.menu]?.[def.group];
|
|
42
|
+
if (groupOrder === undefined)
|
|
43
|
+
throw new Error(`Missing menu section order: ${def.menu}/${def.group}`);
|
|
44
|
+
return {
|
|
45
|
+
...metadata, groupOrder,
|
|
46
|
+
get label() { return t(labelKey); },
|
|
47
|
+
...(children ? { children: children.map(localizeMenu) } : {}),
|
|
48
|
+
...(dynamicChildren ? { dynamicChildren: () => dynamicChildren().map(localizeMenu) } : {}),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
31
51
|
export const builtinMenusExtension = {
|
|
32
52
|
id: 'builtin-menus',
|
|
33
53
|
version: '1.0.0',
|
|
34
|
-
setup() {
|
|
54
|
+
setup({ host }) {
|
|
35
55
|
const cleanups = [];
|
|
36
56
|
// Every item to register — sorted by menu / group / order so the shape
|
|
37
57
|
// reads top-to-bottom the way it renders. The registrar itself re-sorts
|
|
@@ -240,7 +260,7 @@ export const builtinMenusExtension = {
|
|
|
240
260
|
commandId: 'app.open_url', args: { url: 'https://www.apache.org/licenses/LICENSE-2.0' } },
|
|
241
261
|
];
|
|
242
262
|
for (const def of items)
|
|
243
|
-
cleanups.push(
|
|
263
|
+
cleanups.push(host.menus.register(localizeMenu(def)));
|
|
244
264
|
return () => {
|
|
245
265
|
// Reverse order so first-registered is torn down last — same pattern
|
|
246
266
|
// as builtin-commands.ts; `.slice()` clones the array so a repeat unload
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ApplicationMenuItem } from '@forgeax/app-shell/application';
|
|
2
|
+
import type { MenuId, NativeMenu } from './menu-registry';
|
|
3
|
+
export declare function projectApplicationMenus(items: readonly ApplicationMenuItem[]): Record<MenuId, readonly ApplicationMenuItem[]>;
|
|
4
|
+
/** Pure native projection of the same immutable snapshot used by the web menu. */
|
|
5
|
+
export declare function serializeApplicationMenusForNative(items: readonly ApplicationMenuItem[]): NativeMenu[];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** Product menu locations; contributions and their lifetime belong to the host. */
|
|
2
|
+
const MENU_IDS = ['brand', 'file', 'edit', 'window', 'build', 'select', 'help', 'publish'];
|
|
3
|
+
export function projectApplicationMenus(items) {
|
|
4
|
+
const result = {
|
|
5
|
+
brand: [], file: [], edit: [], window: [], build: [], select: [], help: [], publish: [],
|
|
6
|
+
};
|
|
7
|
+
for (const menu of MENU_IDS)
|
|
8
|
+
result[menu] = items.filter(item => item.menu === menu);
|
|
9
|
+
return result;
|
|
10
|
+
}
|
|
11
|
+
function serializeItem(item) {
|
|
12
|
+
if (item.when && !item.when())
|
|
13
|
+
return null;
|
|
14
|
+
const children = item.children?.length ? item.children : item.dynamicChildren?.();
|
|
15
|
+
const result = {
|
|
16
|
+
id: item.id,
|
|
17
|
+
// Already resolved by the owner, including live locale getters. Never
|
|
18
|
+
// reinterpret a contributor's literal label as an Interface locale key.
|
|
19
|
+
label: item.label,
|
|
20
|
+
enabled: item.enabled ? item.enabled() : children?.length ? true : !!item.commandId,
|
|
21
|
+
};
|
|
22
|
+
if (item.keybinding) {
|
|
23
|
+
result.accelerator = item.keybinding.split('+')
|
|
24
|
+
.map(part => part === 'Ctrl' ? 'CmdOrCtrl' : part).join('+');
|
|
25
|
+
}
|
|
26
|
+
if (item.danger)
|
|
27
|
+
result.danger = true;
|
|
28
|
+
if (children?.length) {
|
|
29
|
+
const projected = children.map(serializeItem).filter((child) => child !== null);
|
|
30
|
+
if (projected.length)
|
|
31
|
+
result.children = projected;
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
}
|
|
35
|
+
/** Pure native projection of the same immutable snapshot used by the web menu. */
|
|
36
|
+
export function serializeApplicationMenusForNative(items) {
|
|
37
|
+
const menus = projectApplicationMenus(items);
|
|
38
|
+
return MENU_IDS.map(menu => {
|
|
39
|
+
const projected = [];
|
|
40
|
+
let previousGroup = null;
|
|
41
|
+
for (const item of menus[menu]) {
|
|
42
|
+
const native = serializeItem(item);
|
|
43
|
+
// Retain the existing bridge's hidden-row group boundary semantics.
|
|
44
|
+
if (native) {
|
|
45
|
+
if (previousGroup !== null && previousGroup !== item.group)
|
|
46
|
+
native.separatorBefore = true;
|
|
47
|
+
projected.push(native);
|
|
48
|
+
}
|
|
49
|
+
previousGroup = item.group;
|
|
50
|
+
}
|
|
51
|
+
return { menu, items: projected };
|
|
52
|
+
});
|
|
53
|
+
}
|
|
@@ -1,25 +1,3 @@
|
|
|
1
|
-
/** menu-registry —— 顶部菜单栏的单一真值源 (SSOT for the menu bar).
|
|
2
|
-
*
|
|
3
|
-
* 一张注册表服务两个消费者 (压缩公理:消掉「Web 菜单和原生菜单是不是同一套?」):
|
|
4
|
-
* 1. Web 端渲染器 (React) —— 通过 `snapshotMenu(...)` / `snapshotAllMenus()` + `onMenuChange`
|
|
5
|
-
* 走 useSyncExternalStore 拿到当前菜单结构;
|
|
6
|
-
* 2. Tauri 原生菜单桥接 —— 通过 `serializeMenusForNative(t)` 拿到已翻译、去函数、
|
|
7
|
-
* JSON 可序列化的原生菜单契约,一次性 build native menu / 每次注册表变化时 rebuild.
|
|
8
|
-
*
|
|
9
|
-
* 设计与 action-registry.ts 对齐 (模块级 Map 页级单例 + change listener):
|
|
10
|
-
* - 菜单项按 `menu`(顶层大类) + `group`(段落分组) + `order`(段内序) 三级组织;
|
|
11
|
-
* - 同 id 重复注册 = **替换** (幂等,HMR / StrictMode 双挂载安全,cleanup
|
|
12
|
-
* 走身份检查避免误删);
|
|
13
|
-
* - 键位串遵循全仓 canonical combo 格式 (见 lib/global-shortcuts.ts `prettyCombo`),
|
|
14
|
-
* 例:'Ctrl+S' / 'Ctrl+Shift+Z' / 'Ctrl+,'。Web 显示走 prettyCombo,原生菜单走
|
|
15
|
-
* `serializeMenusForNative` 内部的 accelerator 转换 (Ctrl→CmdOrCtrl,保留 Shift/Alt)。
|
|
16
|
-
*
|
|
17
|
-
* 分隔符策略 (评审):**注册者不显式发 separator sentinel** —— 只填 `group`,
|
|
18
|
-
* 渲染器按 `snapshotMenu` 返回结果中相邻两项的 `group` 差异自行绘制分隔线;
|
|
19
|
-
* 原生序列化则在 `serializeMenusForNative` 中把段边界翻成 `separatorBefore=true`。
|
|
20
|
-
* 这样避免维护「三种分隔来源」(注册者手插 + 渲染器 + 原生桥)—— 一处派生 (§2 Derive)。
|
|
21
|
-
*/
|
|
22
|
-
/** 菜单顶层大类。品牌菜单 (brand) 在 macOS 原生菜单里通常对应 app 菜单,由 T5 决定放置策略。 */
|
|
23
1
|
export type MenuId = 'brand' | 'file' | 'edit' | 'window' | 'build' | 'select' | 'help' | 'publish';
|
|
24
2
|
export interface MenuItemDef {
|
|
25
3
|
/** 稳定唯一 id,例:'file.save' / 'window.toggle_chat'。重复注册会抛错。 */
|
|
@@ -64,23 +42,6 @@ export interface MenuItemDef {
|
|
|
64
42
|
* 原生序列化不含动态子项 (native 菜单结构在 build 时固化)。 */
|
|
65
43
|
dynamicChildren?: () => MenuItemDef[];
|
|
66
44
|
}
|
|
67
|
-
/** 注册一项菜单 —— 同 id 重复注册 = 替换 (幂等,HMR / StrictMode 双挂载安全)。
|
|
68
|
-
* 返回 cleanup 函数;cleanup 幂等 (身份检查:仅当当前项仍是本次注册的 def
|
|
69
|
-
* 才 delete,避免 A 注册→A 注销 后误删掉 B 的 same-id 覆盖)。
|
|
70
|
-
*
|
|
71
|
-
* 设计变更 (T7 集成):初版为 throw-on-duplicate,与 action-registry 的
|
|
72
|
-
* 「替换即幂等」策略不一致 —— React StrictMode 会双执行 App.tsx 的
|
|
73
|
-
* bootstrapAppHost useEffect,两次 setup 并发跑到同一个模块级 Map 时必抛
|
|
74
|
-
* 「duplicate menu item id」把整个菜单栏打空。改为 last-writer-wins 后
|
|
75
|
-
* 两次 setup 都能顺利完成,先释放的 cleanup 通过身份检查不会误删,
|
|
76
|
-
* 与 action-registry 的语义对齐 (§SSOT / 兄弟注册表一致性)。 */
|
|
77
|
-
export declare function registerMenuItem(def: MenuItemDef): () => void;
|
|
78
|
-
/** 注册表变更订阅 —— 供 useSyncExternalStore / native bridge rebuild 消费。 */
|
|
79
|
-
export declare function onMenuChange(listener: () => void): () => void;
|
|
80
|
-
/** 取单个菜单的排序后条目 —— 渲染器直接消费。 */
|
|
81
|
-
export declare function snapshotMenu(menu: MenuId): MenuItemDef[];
|
|
82
|
-
/** 取所有菜单的排序后条目,按 MenuId 组织。 */
|
|
83
|
-
export declare function snapshotAllMenus(): Record<MenuId, MenuItemDef[]>;
|
|
84
45
|
export interface NativeMenuItem {
|
|
85
46
|
/** 对应 MenuItemDef.id。 */
|
|
86
47
|
id: string;
|
|
@@ -101,13 +62,3 @@ export interface NativeMenu {
|
|
|
101
62
|
menu: MenuId;
|
|
102
63
|
items: NativeMenuItem[];
|
|
103
64
|
}
|
|
104
|
-
/** 序列化全部菜单为原生契约 —— T5 (Tauri 桥) 的唯一入口。契约要点:
|
|
105
|
-
* - `when === false` 的项被 drop;
|
|
106
|
-
* - `enabled` 缺省时默认 = `!!commandId` (无命令 = 禁用);
|
|
107
|
-
* - `labelKey` 走 `translate` 翻译,函数不出墙;
|
|
108
|
-
* - `keybinding` 转 native accelerator (Ctrl→CmdOrCtrl,Shift/Alt 保留);
|
|
109
|
-
* - 段边界翻成 `separatorBefore=true` (每个菜单首项除外);
|
|
110
|
-
* - 输出**完全 JSON 可序列化** (JSON.stringify 无损 roundtrip,无函数残留)。 */
|
|
111
|
-
export declare function serializeMenusForNative(translate: (key: string) => string): NativeMenu[];
|
|
112
|
-
/** 清空注册表 —— 仅供单测 beforeEach 使用。 */
|
|
113
|
-
export declare function __resetMenuRegistryForTest(): void;
|