diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index 240196a3..4b2e7176 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -841,17 +841,7 @@ Item { // in JSONC (`power`, `reminder-set`). Unknown strings fall through to the // id-as-route behavior so misspellings still attempt to open the literal id. function resolveRoute(input) { - var raw = String(input || "").toLowerCase().replace(/_/g, "-") - if (!raw || raw === "go" || raw === "menu") return "root" - for (var i = 0; i < root.itemOrder.length; i++) { - var entry = root.items[root.itemOrder[i]] - if (!entry || !entry.aliases) continue - for (var j = 0; j < entry.aliases.length; j++) { - var alias = String(entry.aliases[j] || "").toLowerCase().replace(/_/g, "-") - if (alias === raw) return entry.id - } - } - return raw + return MenuModel.resolveRoute(root.items, root.itemOrder, input) } function openRoute(initialMenu) { diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index e6bd990b..1908dbe0 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -167,6 +167,28 @@ function item(items, id) { return items && items[id] ? items[id] : null } +// Routes may name a real id (`system`, `setup.power`) or an alias declared in +// JSONC (`power-menu`, `settings`). An exact id beats any alias, and app rows +// are never routable: their aliases carry .desktop Keywords and GenericName +// for search, so an installed application could otherwise shadow a menu route +// (htop ships `Keywords=system;...`). Unknown strings fall through as the +// literal input so misspellings still attempt to open that id. +function resolveRoute(items, itemOrder, input) { + var raw = String(input || "").toLowerCase().replace(/_/g, "-") + if (!raw || raw === "go" || raw === "menu") return "root" + if (item(items, raw)) return raw + var order = Array.isArray(itemOrder) ? itemOrder : [] + for (var i = 0; i < order.length; i++) { + var entry = item(items, order[i]) + if (!entry || entry.kind === "app" || !entry.aliases) continue + for (var j = 0; j < entry.aliases.length; j++) { + var alias = String(entry.aliases[j] || "").toLowerCase().replace(/_/g, "-") + if (alias === raw) return entry.id + } + } + return raw +} + function slugify(value) { return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "item" } @@ -360,6 +382,7 @@ if (typeof module !== "undefined") { mergeAppRows: mergeAppRows, swapProviderRows: swapProviderRows, item: item, + resolveRoute: resolveRoute, slugify: slugify, depthFor: depthFor, pathFor: pathFor, diff --git a/test/shell.d/menu-test.sh b/test/shell.d/menu-test.sh index 870c40ed..925a3d63 100644 --- a/test/shell.d/menu-test.sh +++ b/test/shell.d/menu-test.sh @@ -135,6 +135,24 @@ assert( rankScore('style.font', 'font') < rankScore('apps.fontforge', 'font'), 'menu keeps a better-matching menu entry above a weaker app match' ) + +// Routing: htop ships `Keywords=system;...`, which app rows carry as aliases. +// An installed app must never capture a menu route (SUPER+ESCAPE opens the +// `system` menu), while its keywords keep working for search. +const routed = menu.mergeAppRows(rankBase.items, rankBase.itemOrder, [ + { id: 'apps.htop', parent: 'apps', kind: 'app', label: 'Htop', description: 'Process Viewer', aliases: ['Process Viewer', 'system', 'process'] } +]) +assertEqual(menu.resolveRoute(routed.items, routed.itemOrder, 'system'), 'system', 'menu routes an exact id even when an app keyword matches it') +assertEqual(menu.resolveRoute(routed.items, routed.itemOrder, 'process'), 'process', 'menu never routes to an app row through its keywords') +assertEqual(menu.resolveRoute(routed.items, routed.itemOrder, 'power-menu'), 'system', 'menu routes declared aliases to their item') +assertEqual(menu.resolveRoute(routed.items, routed.itemOrder, 'power_menu'), 'system', 'menu normalizes underscores in routes') +assertEqual(menu.resolveRoute(routed.items, routed.itemOrder, ''), 'root', 'menu routes empty input to root') +assertEqual(menu.resolveRoute(routed.items, routed.itemOrder, 'no-such-route'), 'no-such-route', 'menu falls through to the literal input') +assert(menu.matchesQuery(routed.items['apps.htop'], 'system', true), 'menu still finds an app by its keywords in search') +assert( + /function resolveRoute\(input\) \{\s*\n\s*return MenuModel\.resolveRoute\(root\.items, root\.itemOrder, input\)\s*\n\s*\}/.test(menuQml), + 'menu delegates route resolution to the shared model' +) const triggerItems = defaultItems.filter(item => item.parent === 'trigger') assertEqual( triggerItems[0].id,