From 0269fe0031fb868f775cf910f082f27619df91b1 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 5 Aug 2026 15:50:41 -0500 Subject: [PATCH] Route menu ids ahead of app keyword aliases (#6563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An installed app whose .desktop Keywords contain a menu id captured the route: htop ships Keywords=system;..., so SUPER+ESCAPE opened an empty "Htop" menu instead of the System menu once the Apps menu had merged its rows. Exact ids now win, and app rows are no longer routable at all — their keywords remain search-only. Fixes #6554 Reported-by: Craig Derington (https://github.com/craigderington) Co-authored-by: Claude Fable 5 --- shell/plugins/menu/Menu.qml | 12 +----------- shell/plugins/menu/MenuModel.js | 23 +++++++++++++++++++++++ test/shell.d/menu-test.sh | 18 ++++++++++++++++++ 3 files changed, 42 insertions(+), 11 deletions(-) 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,