From adfb5b848424299f6a676d807c3e961fe9bf73ec Mon Sep 17 00:00:00 2001 From: Charalampos Kardaris Date: Sat, 28 Mar 2026 17:29:00 +0100 Subject: [PATCH 1/7] [feature] Command mode Introduce command mode in the omni bar. Features: - Launch with ':' (default key mapping). - Support prefix counts. - Show all commands (even mapped ones). - Show all commands as specified (with any options) in user defined key mappings. - Show keys for mapped commands, similarly to the help page. Other: - Revise 'noRepeat' attribute for all commands. Disable ctrl-enter for command mode --- background_scripts/all_commands.js | 41 +++- background_scripts/commands.js | 1 + background_scripts/completion/completers.js | 130 +++++++++++- background_scripts/main.js | 9 + content_scripts/mode_normal.js | 12 ++ content_scripts/vomnibar.js | 8 + lib/types.js | 3 + pages/vomnibar_page.html | 1 + pages/vomnibar_page.js | 23 ++- tests/dom_tests/dom_tests.html | 1 + .../unit_tests/completion/completers_test.js | 194 +++++++++++++++++- tests/unit_tests/vomnibar_page_test.js | 90 +++++++- 12 files changed, 491 insertions(+), 22 deletions(-) diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 607fb73a7..6e85d33dd 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -32,12 +32,14 @@ const allCommands = [ name: "scrollToTop", desc: "Scroll to the top of the page", group: "navigation", + noRepeat: true, }, { name: "scrollToBottom", desc: "Scroll to the bottom of the page", group: "navigation", + noRepeat: true, }, { @@ -82,12 +84,15 @@ const allCommands = [ desc: "Scroll all the way to the left", group: "navigation", advanced: true, + noRepeat: true, }, { name: "scrollToRight", desc: "Scroll all the way to the right", group: "navigation", + advanced: true, + noRepeat: true, }, { @@ -98,6 +103,7 @@ const allCommands = [ options: { hard: "Perform a hard reload, forcing the browser to bypass its cache.", }, + noRepeat: true, }, { @@ -137,6 +143,7 @@ const allCommands = [ desc: "Go to the root of current URL hierarchy", group: "navigation", advanced: true, + noRepeat: true, }, { @@ -175,6 +182,7 @@ const allCommands = [ name: "focusInput", desc: "Focus the first text input on the page", group: "navigation", + noRepeat: true, }, { @@ -303,6 +311,7 @@ const allCommands = [ }, group: "vomnibar", topFrame: true, + noRepeat: true, }, { @@ -315,6 +324,7 @@ const allCommands = [ "section of the Vimium Options page. The Vomnibar will be scoped to use that search engine.", }, topFrame: true, + noRepeat: true, }, { @@ -325,6 +335,7 @@ const allCommands = [ query: "The text to prefill the Vomnibar with.", }, topFrame: true, + noRepeat: true, }, { @@ -335,6 +346,15 @@ const allCommands = [ query: "The text to prefill the Vomnibar with.", }, topFrame: true, + noRepeat: true, + }, + + { + name: "Vomnibar.activateCommand", + desc: "Execute a Vimium command", + group: "vomnibar", + topFrame: true, + noRepeat: true, }, { @@ -342,6 +362,7 @@ const allCommands = [ desc: "Search through your open tabs", group: "vomnibar", topFrame: true, + noRepeat: true, }, { @@ -349,6 +370,7 @@ const allCommands = [ desc: "Edit the current URL", group: "vomnibar", topFrame: true, + noRepeat: true, }, { @@ -356,6 +378,7 @@ const allCommands = [ desc: "Edit the current URL and open in a new tab", group: "vomnibar", topFrame: true, + noRepeat: true, }, // @@ -433,14 +456,14 @@ const allCommands = [ { name: "previousTab", - desc: "Go one tab left", + desc: "Go one tab left (vertical: up)", group: "tabs", background: true, }, { name: "nextTab", - desc: "Go one tab right", + desc: "Go one tab right (vertical: down)", group: "tabs", background: true, }, @@ -457,6 +480,7 @@ const allCommands = [ desc: "Go to the first tab", group: "tabs", background: true, + noRepeat: true, }, { @@ -464,6 +488,7 @@ const allCommands = [ desc: "Go to the last tab", group: "tabs", background: true, + noRepeat: true, }, { @@ -479,6 +504,7 @@ const allCommands = [ desc: "Pin or unpin current tab", group: "tabs", background: true, + noRepeat: true, }, { @@ -517,11 +543,12 @@ const allCommands = [ group: "tabs", advanced: true, background: true, + noRepeat: true, }, { name: "closeTabsOnLeft", - desc: "Close tabs on the left", + desc: "Close tabs on the left (vertical: up)", group: "tabs", advanced: true, background: true, @@ -529,7 +556,7 @@ const allCommands = [ { name: "closeTabsOnRight", - desc: "Close tabs on the right", + desc: "Close tabs on the right (vertical: down)", group: "tabs", advanced: true, background: true, @@ -546,7 +573,7 @@ const allCommands = [ { name: "moveTabLeft", - desc: "Move tab to the left", + desc: "Move tab to the left (vertical: up)", group: "tabs", advanced: true, background: true, @@ -554,7 +581,7 @@ const allCommands = [ { name: "moveTabRight", - desc: "Move tab to the right", + desc: "Move tab to the right (vertical: down)", group: "tabs", advanced: true, background: true, @@ -569,6 +596,7 @@ const allCommands = [ options: { level: "The zoom level. This can be a range of [0.25, 5.0]. 1.0 is the default.", }, + noRepeat: true, }, { @@ -593,6 +621,7 @@ const allCommands = [ group: "tabs", advanced: true, background: true, + noRepeat: true, }, // diff --git a/background_scripts/commands.js b/background_scripts/commands.js index faaf58497..fa769c2f3 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -455,6 +455,7 @@ const defaultKeyMappings = { "T": "Vomnibar.activateTabSelection", "b": "Vomnibar.activateBookmarks", "B": "Vomnibar.activateBookmarksInNewTab", + ":": "Vomnibar.activateCommand", "ge": "Vomnibar.activateEditUrl", "gE": "Vomnibar.activateEditUrlInNewTab", diff --git a/background_scripts/completion/completers.js b/background_scripts/completion/completers.js index fbd45c11e..807b3ab31 100644 --- a/background_scripts/completion/completers.js +++ b/background_scripts/completion/completers.js @@ -15,6 +15,8 @@ import * as bgUtils from "./../bg_utils.js"; import * as completionSearch from "./search_wrapper.js"; import * as userSearchEngines from "../user_search_engines.js"; import * as ranking from "./ranking.js"; +import { allCommands } from "../all_commands.js"; +import { Commands, RegistryEntry } from "../commands.js"; import { RegexpCache } from "./ranking.js"; // Set this to true to render relevancy when debugging the ranking scores. @@ -47,6 +49,14 @@ export class Suggestion { tabId; // Whether this is a suggestion provided by a user's custom search engine. isCustomSearch; + // Suggestion in 'command' mode. + // command = { + // // 'RegistryEntry' to execute the command in 'NormalMode.commandHandler'. + // registryEntry: RegistryEntry, + // // Key mapping to show in the omni bar suggestions + // keys: Array[string] + // } + command; // Whether this is meant to be the first suggestion from the user's custom search engine which // represents their query as typed, verbatim. isPrimarySuggestion = false; @@ -72,7 +82,8 @@ export class Suggestion { generateHtml() { if (this.html) return this.html; const relevancyHtml = showRelevancy - ? `${this.computeRelevancy()}` + ? ` + ${this.computeRelevancy()}` : ""; const insertTextClass = this.insertText ? "" : "no-insert-text"; const insertTextIndicator = "↪"; // A right hooked arrow. @@ -87,12 +98,32 @@ export class Suggestion { faviconHtml = ``; } if (this.isCustomSearch) { - this.html = `\ -
- ${insertTextIndicator}${this.description} - ${this.highlightQueryTerms(Utils.escapeHtml(this.title))} - ${relevancyHtml} -
\ + this.html = ` +
+ ${insertTextIndicator}${this.description} + ${ + this.highlightQueryTerms(Utils.escapeHtml(this.title)) + }${relevancyHtml} +
+`; + } else if (this.command) { + // Key mappings containing key-modifiers are represented in the form of '' + // (e.g ) and are parsed as HTML tags when used in a raw string. Escape them properly. + const escapeKeyForHtml = (key) => { + return key.replace(//g, ">"); + }; + const keybindings = this.command.keys.map((key) => ` + + ${escapeKeyForHtml(key)} + , + `).join("\n"); + this.html = ` +
+ ${insertTextIndicator}${this.description} + ${ + this.highlightQueryTerms(`${this.title}`) + }${keybindings}${relevancyHtml} +
`; } else { this.html = `\ @@ -103,9 +134,8 @@ export class Suggestion {
${insertTextIndicator}${faviconHtml}${ this.highlightQueryTerms(Utils.escapeHtml(this.shortenUrl())) - } - ${relevancyHtml} -
\ + }${relevancyHtml} + `; } return this.html; @@ -353,6 +383,86 @@ export class BookmarkCompleter { } } +export class CommandCompleter { + async filter({ queryTerms }) { + // Get the key mapping for a command. + // Each entry contains the user-specified options and an array of possible mappings. + // Example: + // "closeTabsOnRight" : { + // "count=2": ["c2l", "c2k"], + // "count=3": ["c3l", "c3k"], + // } + const commandToOptionsToKeys = + (await chrome.storage.session.get("commandToOptionsToKeys")).commandToOptionsToKeys; + + // Create a RegistryEntry for the default action (no options specified) of a command. + const createUnboundRegistryEntry = (command) => { + return new RegistryEntry({ + keySequence: [], + command: command.name, + noRepeat: command.noRepeat, + repeatLimit: command.repeatLimit, + background: command.background, + topFrame: command.topFrame, + options: {}, + }); + }; + + // Option suffix to add to the suggestion entry based on the command options for a mapping. + // Used in two places: + // - title: to set the actual visible text in the omni bar + // - url: used as a key of difference during the clean-up of the suggestions. + const optionsSuffix = (option) => { + return option ? ` (${option})` : ""; + }; + + let suggestions = []; + allCommands.filter((command) => ranking.matches(queryTerms, command.desc)) + .map((command) => { + const variations = commandToOptionsToKeys[command.name] || {}; + + // Indicates if the default action of the command (no additional options) is bound to a key. + const isDefaultBound = Object.keys(variations).some((option) => option.length === 0); + + // If the default action is not bound, add the entry explicitly to the suggestions. + // This makes unbound commands accessible from the omni bar in 'command' mode. + if (!isDefaultBound) { + suggestions.push( + new Suggestion({ + queryTerms, + description: "command", + title: command.desc, + url: command.name, + command: { + registryEntry: createUnboundRegistryEntry(command), + keys: [], + }, + relevancy: 1, + }), + ); + } + + // Add all bound/mapped command variations to the suggestions. + for (const [options, keys] of Object.entries(variations)) { + suggestions.push( + new Suggestion({ + queryTerms, + description: "command", + title: command.desc + optionsSuffix(options), + url: command.name + optionsSuffix(options), + command: { + registryEntry: Commands.keyToRegistryEntry[keys[0]], + keys: keys, + }, + relevancy: 1, + }), + ); + } + }); + return suggestions; + } +} + export class HistoryCompleter { // - seenTabToOpenCompletionList: true if the user has typed only , and nothing else. // We interpret this to mean that they want to see all of their history in the Vomnibar, sorted diff --git a/background_scripts/main.js b/background_scripts/main.js index 7d37e6b1b..1299f9292 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -14,6 +14,7 @@ import * as marks from "../background_scripts/marks.js"; import { BookmarkCompleter, + CommandCompleter, DomainCompleter, HistoryCompleter, MultiCompleter, @@ -43,6 +44,7 @@ chrome.storage.session.set({ vimiumSecret: secretToken }); const completionSources = { bookmarks: new BookmarkCompleter(), + commands: new CommandCompleter(), history: new HistoryCompleter(), domains: new DomainCompleter(), tabs: new TabCompleter(), @@ -58,6 +60,7 @@ const completers = { completionSources.searchEngines, ]), bookmarks: new MultiCompleter([completionSources.bookmarks]), + commands: new MultiCompleter([completionSources.commands]), tabs: new MultiCompleter([completionSources.tabs]), }; @@ -601,6 +604,12 @@ const sendRequestHandlers = { runBackgroundCommand(request, sender) { return BackgroundCommands[request.registryEntry.command](request, sender); }, + // 'runNormalModeCommand' is used as a proxy in order to execute a command as if it was run in + // normal mode. + // The 'request' must contain a 'count' and a valid 'command: RegistryEntry' parameter. + runNormalModeCommand(request, sender) { + chrome.tabs.sendMessage(sender.tab.id, request); + }, // getCurrentTabUrl is used by the content scripts to get their full URL, because window.location // cannot help with Chrome-specific URLs like "view-source:http:..". getCurrentTabUrl({ tab }) { diff --git a/content_scripts/mode_normal.js b/content_scripts/mode_normal.js index eaa649c3f..711486f94 100644 --- a/content_scripts/mode_normal.js +++ b/content_scripts/mode_normal.js @@ -22,9 +22,20 @@ class NormalMode extends KeyHandlerMode { this.setKeyMapping(changes.normalModeKeyStateMapping.newValue); } }); + + // Listen and handle 'command' events coming from the background and + // originally sent from the omnibar page. + Utils.addChromeRuntimeOnMessageListener(["runNormalModeCommand"], (request, _sender) => { + this.commandHandler(request); + }); } commandHandler({ command: registryEntry, count }) { + // Set the raw 'count' for the omni bar, before doing any additional 'count' processing. + // If the command to handle is Vomnibar.activateCommand, the omni bar will later propagate this + // 'count' to the selected command. + registryEntry.options.omniCommandCount = count; + if (registryEntry.options.count) { count = (count ?? 1) * registryEntry.options.count; } @@ -359,6 +370,7 @@ const NormalModeCommands = { "Vomnibar.activateTabSelection": Vomnibar.activateTabSelection.bind(Vomnibar), "Vomnibar.activateBookmarks": Vomnibar.activateBookmarks.bind(Vomnibar), "Vomnibar.activateBookmarksInNewTab": Vomnibar.activateBookmarksInNewTab.bind(Vomnibar), + "Vomnibar.activateCommand": Vomnibar.activateCommand.bind(Vomnibar), "Vomnibar.activateEditUrl": Vomnibar.activateEditUrl.bind(Vomnibar), "Vomnibar.activateEditUrlInNewTab": Vomnibar.activateEditUrlInNewTab.bind(Vomnibar), diff --git a/content_scripts/vomnibar.js b/content_scripts/vomnibar.js index a8405f75d..40ac24224 100644 --- a/content_scripts/vomnibar.js +++ b/content_scripts/vomnibar.js @@ -32,6 +32,14 @@ const Vomnibar = { this.open(sourceFrameId, options); }, + activateCommand(sourceFrameId, registryEntry) { + const options = Object.assign({}, registryEntry.options, { + completer: "commands", + selectFirst: true, + }); + this.open(sourceFrameId, options); + }, + activateBookmarksInNewTab(sourceFrameId, registryEntry) { const options = Object.assign({}, registryEntry.options, { completer: "bookmarks", diff --git a/lib/types.js b/lib/types.js index 1f90a60af..35f174190 100644 --- a/lib/types.js +++ b/lib/types.js @@ -11,4 +11,7 @@ globalThis.VomnibarShowOptions = { selectFirst: "boolean", // A keyword which will scope the search to a UserSearchEngine. keyword: "string", + // A prefix count number before launching the omni bar + // (used to propagate the count in 'command' mode). + omniCommandCount: "number", }; diff --git a/pages/vomnibar_page.html b/pages/vomnibar_page.html index bb7b6f10d..b84910715 100644 --- a/pages/vomnibar_page.html +++ b/pages/vomnibar_page.html @@ -7,6 +7,7 @@ +
diff --git a/pages/vomnibar_page.js b/pages/vomnibar_page.js index eaef28dfd..27738e487 100644 --- a/pages/vomnibar_page.js +++ b/pages/vomnibar_page.js @@ -32,6 +32,7 @@ export async function activate(options) { newTab: false, selectFirst: false, keyword: null, + omniCommandCount: 1, }; options = Object.assign(defaults, options); @@ -44,6 +45,7 @@ export async function activate(options) { ui.setInitialSelectionValue(options.selectFirst ? 0 : -1); ui.setForceNewTab(options.newTab); ui.setQuery(options.query); + ui.setOmniCommandCount(options.omniCommandCount); ui.setActiveUserSearchEngine(userSearchEngines.keywordToEngine[options.keyword]); // Use await here for vomnibar_test.js, so that this page doesn't get unloaded while a test is // running. @@ -81,6 +83,9 @@ class VomnibarUI { this.completerName = name; this.reset(); } + setOmniCommandCount(omniCommandCount) { + this.omniCommandCount = omniCommandCount; + } // True if the user has entered the keyword of one of their custom search engines. isUserSearchEngineActive() { @@ -217,7 +222,10 @@ class VomnibarUI { await this.handleEnterKey(event); } else if (action === "ctrl-enter") { // Populate the vomnibar with the current selection's URL. - if (!this.isUserSearchEngineActive() && (this.selection >= 0)) { + if ( + !this.isUserSearchEngineActive() && this.completerName != "commands" && + (this.selection >= 0) + ) { if (this.previousInputValue == null) { this.previousInputValue = this.input.value; } @@ -300,6 +308,15 @@ class VomnibarUI { } else if (isPrimarySearchSuggestion(completion)) { query = UrlUtils.createSearchUrl(query, completion.searchUrl); this.hide(() => this.launchUrl(query, openInNewTab)); + } else if (completion.command) { + this.hide(async () => { + await chrome.runtime.sendMessage({ + handler: "runNormalModeCommand", + command: completion.command.registryEntry, + // Propagate 'omniCommandCount' to the selected command. + count: this.omniCommandCount, + }); + }); } else { this.hide(() => this.openCompletion(completion, openInNewTab)); } @@ -341,7 +358,7 @@ class VomnibarUI { } renderCompletions(completions) { - this.completionList.innerHTML = completions.map((c) => `
  • ${c.html}
  • `).join(""); + this.completionList.innerHTML = completions.map((c) => `
  • ${c.html}
  • `).join("\n"); this.completionList.style.display = completions.length > 0 ? "block" : ""; } @@ -442,8 +459,6 @@ class VomnibarUI { } } -let vomnibarInstance; - function init() { UIComponentMessenger.init(); UIComponentMessenger.registerHandler(function (event) { diff --git a/tests/dom_tests/dom_tests.html b/tests/dom_tests/dom_tests.html index 7b2b663de..228cf3940 100644 --- a/tests/dom_tests/dom_tests.html +++ b/tests/dom_tests/dom_tests.html @@ -6,6 +6,7 @@ + diff --git a/tests/unit_tests/completion/completers_test.js b/tests/unit_tests/completion/completers_test.js index a18af89bb..28fd7f75f 100644 --- a/tests/unit_tests/completion/completers_test.js +++ b/tests/unit_tests/completion/completers_test.js @@ -6,6 +6,7 @@ import "../../../background_scripts/completion/search_wrapper.js"; import * as userSearchEngines from "../../../background_scripts/user_search_engines.js"; import { BookmarkCompleter, + CommandCompleter, DomainCompleter, HistoryCache, HistoryCompleter, @@ -17,11 +18,13 @@ import { import * as ranking from "../../../background_scripts/completion/ranking.js"; import { RegexpCache } from "../../../background_scripts/completion/ranking.js"; import "../../../lib/url_utils.js"; +import { Commands, RegistryEntry } from "../../../background_scripts/commands.js"; +import { allCommands } from "../../../background_scripts/all_commands.js"; const hours = (n) => 1000 * 60 * 60 * n; // A convenience wrapper around completer.filter() so it can be called synchronously in tests. -const filterCompleter = async (completer, queryTerms) => { +export const filterCompleter = async (completer, queryTerms) => { return await completer.filter({ queryTerms, query: queryTerms.join(" "), @@ -351,6 +354,195 @@ context("multi completer", () => { }); }); +context("command completer", () => { + const commandCompleter = new CommandCompleter(); + const multiCompleter = new MultiCompleter([commandCompleter]); + const setZoom = allCommands.filter((command) => command.name == "setZoom")[0]; + + should("return all commands with default options if no mappings are specified", async () => { + stub(chrome.storage.session, "get", async () => ({ + commandToOptionsToKeys: {}, + })); + stub(Commands, "keyToRegistryEntry", {}); + const suggestions = await filterCompleter(commandCompleter, []); + assert.equal(allCommands.length, suggestions.length); + assert.isTrue( + suggestions.every((suggestion) => + JSON.stringify(suggestion.command.registryEntry.options) === "{}" + ), + ); + }); + + should("return an empty list when the query is empty", async () => { + stub(chrome.storage.session, "get", async () => ({ + commandToOptionsToKeys: {}, + })); + stub(Commands, "keyToRegistryEntry", {}); + assert.equal([], await filterCompleter(multiCompleter, [])); + }); + + should("handle key bound commands with options", async () => { + stub(chrome.storage.session, "get", async () => ({ + commandToOptionsToKeys: { + "setZoom": { + "value=1.1": ["z1"], + "value=1.2": ["z2"], + }, + }, + })); + stub(Commands, "keyToRegistryEntry", { + "z1": new RegistryEntry({ + keySequence: ["z", "1"], + command: setZoom.name, + noRepeat: setZoom.noRepeat, + repeatLimit: setZoom.repeatLimit, + background: setZoom.background, + topFrame: setZoom.topFrame, + options: { + "value": 1.1, + }, + }), + "z2": new RegistryEntry({ + keySequence: ["z", "2"], + command: setZoom.name, + noRepeat: setZoom.noRepeat, + repeatLimit: setZoom.repeatLimit, + background: setZoom.background, + topFrame: setZoom.topFrame, + options: { + "value": 1.2, + }, + }), + }); + + const suggestions = await filterCompleter(multiCompleter, ["set", "zoom"]); + assert.equal([ + { + "queryTerms": [ + "set", + "zoom", + ], + "description": "command", + "url": "setZoom", + "shortUrl": "setzoom", + "title": "Set zoom", + "relevancy": 1, + "autoSelect": false, + "highlightTerms": true, + "deDuplicate": true, + "command": { + "registryEntry": { + "keySequence": [], + "command": "setZoom", + "noRepeat": true, + "background": true, + "options": {}, + }, + "keys": [], + }, + "isPrimarySuggestion": false, + "html": + '\n
    \n command\n Set zoom\n
    \n', + }, + { + "queryTerms": [ + "set", + "zoom", + ], + "description": "command", + "url": "setZoom (value=1.1)", + "shortUrl": "setzoom (value=1.1", + "title": "Set zoom (value=1.1)", + "relevancy": 1, + "autoSelect": false, + "highlightTerms": true, + "deDuplicate": true, + "command": { + "registryEntry": { + "keySequence": [ + "z", + "1", + ], + "command": "setZoom", + "noRepeat": true, + "background": true, + "options": { + "value": 1.1, + }, + }, + "keys": [ + "z1", + ], + }, + "isPrimarySuggestion": false, + "html": + '\n
    \n command\n Set zoom (value=1.1)\n \n z1\n , \n \n
    \n', + }, + { + "queryTerms": [ + "set", + "zoom", + ], + "description": "command", + "url": "setZoom (value=1.2)", + "shortUrl": "setzoom (value=1.2", + "title": "Set zoom (value=1.2)", + "relevancy": 1, + "autoSelect": false, + "highlightTerms": true, + "deDuplicate": true, + "command": { + "registryEntry": { + "keySequence": [ + "z", + "2", + ], + "command": "setZoom", + "noRepeat": true, + "background": true, + "options": { + "value": 1.2, + }, + }, + "keys": [ + "z2", + ], + }, + "isPrimarySuggestion": false, + "html": + '\n
    \n command\n Set zoom (value=1.2)\n \n z2\n , \n \n
    \n', + }, + { + "queryTerms": [ + "set", + "zoom", + ], + "description": "command", + "url": "zoomReset", + "shortUrl": "zoomreset", + "title": "Reset zoom", + "relevancy": 1, + "autoSelect": false, + "highlightTerms": true, + "deDuplicate": true, + "command": { + "registryEntry": { + "keySequence": [], + "command": "zoomReset", + "noRepeat": true, + "background": true, + "options": {}, + }, + "keys": [], + }, + "isPrimarySuggestion": false, + "html": + '\n
    \n command\n Reset zoom\n
    \n', + }, + ], suggestions); + }); +}); + context("tab completer", () => { const tabs = [ { url: "tab1.com", title: "tab1", id: 1 }, diff --git a/tests/unit_tests/vomnibar_page_test.js b/tests/unit_tests/vomnibar_page_test.js index 45ebda6d2..47ea78942 100644 --- a/tests/unit_tests/vomnibar_page_test.js +++ b/tests/unit_tests/vomnibar_page_test.js @@ -1,7 +1,14 @@ import * as testHelper from "./test_helper.js"; import "../../tests/unit_tests/test_chrome_stubs.js"; -import { Suggestion } from "../../background_scripts/completion/completers.js"; +import { + CommandCompleter, + MultiCompleter, + Suggestion, +} from "../../background_scripts/completion/completers.js"; import * as vomnibarPage from "../../pages/vomnibar_page.js"; +import { allCommands } from "../../background_scripts/all_commands.js"; +import { Commands, RegistryEntry } from "../../background_scripts/commands.js"; +import { filterCompleter } from "./completion/completers_test.js"; function newKeyEvent(properties) { return Object.assign( @@ -91,4 +98,85 @@ context("vomnibar page", () => { // The query should not be treated as a user search engine. assert.equal("constructor ", ui.input.value); }); + + should("fill the suggestions list with correct HTML", async () => { + const setZoom = allCommands.filter((command) => command.name == "setZoom")[0]; + const multiCompleter = new MultiCompleter([new CommandCompleter()]); + + stub(chrome.storage.session, "get", async () => ({ + commandToOptionsToKeys: { + "setZoom": { + "value=1.1": ["z1"], + "value=1.2": ["z2"], + }, + }, + })); + + stub(Commands, "keyToRegistryEntry", { + "z1": new RegistryEntry({ + keySequence: ["z", "1"], + command: setZoom.name, + noRepeat: setZoom.noRepeat, + repeatLimit: setZoom.repeatLimit, + background: setZoom.background, + topFrame: setZoom.topFrame, + options: { + "value": 1.1, + }, + }), + "z2": new RegistryEntry({ + keySequence: ["z", "2"], + command: setZoom.name, + noRepeat: setZoom.noRepeat, + repeatLimit: setZoom.repeatLimit, + background: setZoom.background, + topFrame: setZoom.topFrame, + options: { + "value": 1.2, + }, + }), + }); + + const suggestions = await filterCompleter(multiCompleter, ["set", "zoom"]); + stub(chrome.runtime, "sendMessage", async () => suggestions); + + await ui.updateCompletions(); + + assert.equal( + `\ +
  • +
    + command + Set zoom +
    +
  • +
  • +
    + command + Set zoom (value=1.1) + + z1 + , + +
    +
  • +
  • +
    + command + Set zoom (value=1.2) + + z2 + , + +
    +
  • +
  • +
    + command + Reset zoom +
    +
  • `, + ui.completionList.innerHTML, + ); + }); }); From 713a239298643c1eb9ddd21218aef1281c5abafa Mon Sep 17 00:00:00 2001 From: Charalampos Kardaris Date: Sat, 28 Mar 2026 17:29:00 +0100 Subject: [PATCH 2/7] Review resolution: philc 1.0 --- background_scripts/all_commands.js | 12 ++++---- background_scripts/completion/completers.js | 33 ++++++++++----------- background_scripts/main.js | 4 +-- content_scripts/mode_normal.js | 6 ++-- lib/types.js | 2 +- pages/vomnibar_page.js | 2 +- 6 files changed, 28 insertions(+), 31 deletions(-) diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 6e85d33dd..07ae581eb 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -456,14 +456,14 @@ const allCommands = [ { name: "previousTab", - desc: "Go one tab left (vertical: up)", + desc: "Go one tab left", group: "tabs", background: true, }, { name: "nextTab", - desc: "Go one tab right (vertical: down)", + desc: "Go one tab right", group: "tabs", background: true, }, @@ -548,7 +548,7 @@ const allCommands = [ { name: "closeTabsOnLeft", - desc: "Close tabs on the left (vertical: up)", + desc: "Close tabs on the left", group: "tabs", advanced: true, background: true, @@ -556,7 +556,7 @@ const allCommands = [ { name: "closeTabsOnRight", - desc: "Close tabs on the right (vertical: down)", + desc: "Close tabs on the right", group: "tabs", advanced: true, background: true, @@ -573,7 +573,7 @@ const allCommands = [ { name: "moveTabLeft", - desc: "Move tab to the left (vertical: up)", + desc: "Move tab to the left", group: "tabs", advanced: true, background: true, @@ -581,7 +581,7 @@ const allCommands = [ { name: "moveTabRight", - desc: "Move tab to the right (vertical: down)", + desc: "Move tab to the right", group: "tabs", advanced: true, background: true, diff --git a/background_scripts/completion/completers.js b/background_scripts/completion/completers.js index 807b3ab31..ccde91de2 100644 --- a/background_scripts/completion/completers.js +++ b/background_scripts/completion/completers.js @@ -49,11 +49,11 @@ export class Suggestion { tabId; // Whether this is a suggestion provided by a user's custom search engine. isCustomSearch; - // Suggestion in 'command' mode. + // Suggestion in "command" mode. // command = { - // // 'RegistryEntry' to execute the command in 'NormalMode.commandHandler'. + // "RegistryEntry" to execute the command in "NormalMode.commandHandler". // registryEntry: RegistryEntry, - // // Key mapping to show in the omni bar suggestions + // Key mapping to show in the omni bar suggestions. // keys: Array[string] // } command; @@ -82,8 +82,7 @@ export class Suggestion { generateHtml() { if (this.html) return this.html; const relevancyHtml = showRelevancy - ? ` - ${this.computeRelevancy()}` + ? `${this.computeRelevancy()}` : ""; const insertTextClass = this.insertText ? "" : "no-insert-text"; const insertTextIndicator = "↪"; // A right hooked arrow. @@ -98,16 +97,15 @@ export class Suggestion { faviconHtml = ``; } if (this.isCustomSearch) { - this.html = ` -
    - ${insertTextIndicator}${this.description} - ${ - this.highlightQueryTerms(Utils.escapeHtml(this.title)) - }${relevancyHtml} -
    + this.html = `\ +
    + ${insertTextIndicator}${this.description} + ${this.highlightQueryTerms(Utils.escapeHtml(this.title))} + ${relevancyHtml} +
    \ `; } else if (this.command) { - // Key mappings containing key-modifiers are represented in the form of '' + // Key mappings containing key modifiers are represented in the form of '' // (e.g ) and are parsed as HTML tags when used in a raw string. Escape them properly. const escapeKeyForHtml = (key) => { return key.replace(//g, ">"); @@ -120,9 +118,7 @@ export class Suggestion { this.html = `
    ${insertTextIndicator}${this.description} - ${ - this.highlightQueryTerms(`${this.title}`) - }${keybindings}${relevancyHtml} + ${this.highlightQueryTerms(this.title)}${keybindings}${relevancyHtml}
    `; } else { @@ -134,8 +130,9 @@ export class Suggestion {
    ${insertTextIndicator}${faviconHtml}${ this.highlightQueryTerms(Utils.escapeHtml(this.shortenUrl())) - }${relevancyHtml} -
    + } + ${relevancyHtml} +
    \ `; } return this.html; diff --git a/background_scripts/main.js b/background_scripts/main.js index 1299f9292..334f3f61a 100644 --- a/background_scripts/main.js +++ b/background_scripts/main.js @@ -604,9 +604,9 @@ const sendRequestHandlers = { runBackgroundCommand(request, sender) { return BackgroundCommands[request.registryEntry.command](request, sender); }, - // 'runNormalModeCommand' is used as a proxy in order to execute a command as if it was run in + // "runNormalModeCommand" is used as a proxy in order to execute a command as if it was run in // normal mode. - // The 'request' must contain a 'count' and a valid 'command: RegistryEntry' parameter. + // The "request" must contain a "count" and a valid "command: RegistryEntry" parameter. runNormalModeCommand(request, sender) { chrome.tabs.sendMessage(sender.tab.id, request); }, diff --git a/content_scripts/mode_normal.js b/content_scripts/mode_normal.js index 711486f94..dd4019035 100644 --- a/content_scripts/mode_normal.js +++ b/content_scripts/mode_normal.js @@ -23,7 +23,7 @@ class NormalMode extends KeyHandlerMode { } }); - // Listen and handle 'command' events coming from the background and + // Listen and handle "command" events coming from the background and // originally sent from the omnibar page. Utils.addChromeRuntimeOnMessageListener(["runNormalModeCommand"], (request, _sender) => { this.commandHandler(request); @@ -31,9 +31,9 @@ class NormalMode extends KeyHandlerMode { } commandHandler({ command: registryEntry, count }) { - // Set the raw 'count' for the omni bar, before doing any additional 'count' processing. + // Set the raw "count" for the omni bar, before doing any additional "count" processing. // If the command to handle is Vomnibar.activateCommand, the omni bar will later propagate this - // 'count' to the selected command. + // "count" to the selected command. registryEntry.options.omniCommandCount = count; if (registryEntry.options.count) { diff --git a/lib/types.js b/lib/types.js index 35f174190..ad5b1015d 100644 --- a/lib/types.js +++ b/lib/types.js @@ -12,6 +12,6 @@ globalThis.VomnibarShowOptions = { // A keyword which will scope the search to a UserSearchEngine. keyword: "string", // A prefix count number before launching the omni bar - // (used to propagate the count in 'command' mode). + // (used to propagate the count in "command" mode). omniCommandCount: "number", }; diff --git a/pages/vomnibar_page.js b/pages/vomnibar_page.js index 27738e487..cfde2a5e2 100644 --- a/pages/vomnibar_page.js +++ b/pages/vomnibar_page.js @@ -313,7 +313,7 @@ class VomnibarUI { await chrome.runtime.sendMessage({ handler: "runNormalModeCommand", command: completion.command.registryEntry, - // Propagate 'omniCommandCount' to the selected command. + // Propagate "omniCommandCount" to the selected command. count: this.omniCommandCount, }); }); From 19d6cc5a967b97fa7514e15506c7dd24ad743b63 Mon Sep 17 00:00:00 2001 From: Charalampos Kardaris Date: Sat, 28 Mar 2026 17:29:00 +0100 Subject: [PATCH 3/7] Review resolution: philc 1.1 --- background_scripts/all_commands.js | 2 +- background_scripts/commands.js | 28 +++--- background_scripts/completion/completers.js | 85 ++++++++++--------- content_scripts/mode_normal.js | 10 +-- content_scripts/vomnibar.js | 2 +- .../unit_tests/completion/completers_test.js | 8 -- 6 files changed, 65 insertions(+), 70 deletions(-) diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 07ae581eb..96f9e0a38 100644 --- a/background_scripts/all_commands.js +++ b/background_scripts/all_commands.js @@ -350,7 +350,7 @@ const allCommands = [ }, { - name: "Vomnibar.activateCommand", + name: "Vomnibar.activateCommandSelection", desc: "Execute a Vimium command", group: "vomnibar", topFrame: true, diff --git a/background_scripts/commands.js b/background_scripts/commands.js index fa769c2f3..033b6f4d9 100644 --- a/background_scripts/commands.js +++ b/background_scripts/commands.js @@ -369,19 +369,19 @@ const Commands = { // This is used by the help page and commands listing. prepareHelpPageData() { /* - Map of commands to option sets to keys to trigger that command option set. - Commands with no options will have the empty string options set. - Example: - { - "zoomReset": { - "": ["z0", "zz"] // No options, with two key maps, ie: `map zz zoomReset` - }, - "setZoom": { - "1.1": ["z1"], // `map z1 setZoom 1.1` - "1.2": ["z2"], // `map z2 setZoom 1.2` - } - } - */ + Map of commands to option sets to keys to trigger that command option set. + Commands with no options will have the empty string options set. + Example: + { + "zoomReset": { + "": ["z0", "zz"] // No options, with two key maps, ie: `map zz zoomReset` + }, + "setZoom": { + "1.1": ["z1"], // `map z1 setZoom 1.1` + "1.2": ["z2"], // `map z2 setZoom 1.2` + } + } + */ const commandToOptionsToKeys = {}; const formatOptionString = (options) => { return Object.entries(options) @@ -455,7 +455,7 @@ const defaultKeyMappings = { "T": "Vomnibar.activateTabSelection", "b": "Vomnibar.activateBookmarks", "B": "Vomnibar.activateBookmarksInNewTab", - ":": "Vomnibar.activateCommand", + ":": "Vomnibar.activateCommandSelection", "ge": "Vomnibar.activateEditUrl", "gE": "Vomnibar.activateEditUrlInNewTab", diff --git a/background_scripts/completion/completers.js b/background_scripts/completion/completers.js index ccde91de2..9bd5cffc0 100644 --- a/background_scripts/completion/completers.js +++ b/background_scripts/completion/completers.js @@ -413,49 +413,52 @@ export class CommandCompleter { return option ? ` (${option})` : ""; }; + const matchingCommands = allCommands.filter((command) => + ranking.matches(queryTerms, command.desc) + ); + let suggestions = []; - allCommands.filter((command) => ranking.matches(queryTerms, command.desc)) - .map((command) => { - const variations = commandToOptionsToKeys[command.name] || {}; - - // Indicates if the default action of the command (no additional options) is bound to a key. - const isDefaultBound = Object.keys(variations).some((option) => option.length === 0); - - // If the default action is not bound, add the entry explicitly to the suggestions. - // This makes unbound commands accessible from the omni bar in 'command' mode. - if (!isDefaultBound) { - suggestions.push( - new Suggestion({ - queryTerms, - description: "command", - title: command.desc, - url: command.name, - command: { - registryEntry: createUnboundRegistryEntry(command), - keys: [], - }, - relevancy: 1, - }), - ); - } + for (const command of matchingCommands) { + const variations = commandToOptionsToKeys[command.name] || {}; + + // Indicates if the default action of the command (no additional options) is bound to a key. + const isDefaultBound = Object.keys(variations).some((option) => option.length === 0); + + // If the default action is not bound, add the entry explicitly to the suggestions. + // This makes unbound commands accessible from the omni bar in 'command' mode. + if (!isDefaultBound) { + suggestions.push( + new Suggestion({ + queryTerms, + description: "command", + title: command.desc, + url: command.name, + command: { + registryEntry: createUnboundRegistryEntry(command), + keys: [], + }, + relevancy: 1, + }), + ); + } - // Add all bound/mapped command variations to the suggestions. - for (const [options, keys] of Object.entries(variations)) { - suggestions.push( - new Suggestion({ - queryTerms, - description: "command", - title: command.desc + optionsSuffix(options), - url: command.name + optionsSuffix(options), - command: { - registryEntry: Commands.keyToRegistryEntry[keys[0]], - keys: keys, - }, - relevancy: 1, - }), - ); - } - }); + // Add all bound/mapped command variations to the suggestions. + for (const [options, keys] of Object.entries(variations)) { + suggestions.push( + new Suggestion({ + queryTerms, + description: "command", + title: command.desc + optionsSuffix(options), + url: command.name + optionsSuffix(options), + command: { + registryEntry: Commands.keyToRegistryEntry[keys[0]], + keys: keys, + }, + relevancy: 1, + }), + ); + } + } return suggestions; } } diff --git a/content_scripts/mode_normal.js b/content_scripts/mode_normal.js index dd4019035..273f71e17 100644 --- a/content_scripts/mode_normal.js +++ b/content_scripts/mode_normal.js @@ -23,8 +23,8 @@ class NormalMode extends KeyHandlerMode { } }); - // Listen and handle "command" events coming from the background and - // originally sent from the omnibar page. + // Listen and handle "command" events coming from the background and originally sent from the + // omnibar page. Utils.addChromeRuntimeOnMessageListener(["runNormalModeCommand"], (request, _sender) => { this.commandHandler(request); }); @@ -32,8 +32,8 @@ class NormalMode extends KeyHandlerMode { commandHandler({ command: registryEntry, count }) { // Set the raw "count" for the omni bar, before doing any additional "count" processing. - // If the command to handle is Vomnibar.activateCommand, the omni bar will later propagate this - // "count" to the selected command. + // If the command to handle is Vomnibar.activateCommandSelection, the omni bar will later + // propagate this "count" to the selected command. registryEntry.options.omniCommandCount = count; if (registryEntry.options.count) { @@ -370,7 +370,7 @@ const NormalModeCommands = { "Vomnibar.activateTabSelection": Vomnibar.activateTabSelection.bind(Vomnibar), "Vomnibar.activateBookmarks": Vomnibar.activateBookmarks.bind(Vomnibar), "Vomnibar.activateBookmarksInNewTab": Vomnibar.activateBookmarksInNewTab.bind(Vomnibar), - "Vomnibar.activateCommand": Vomnibar.activateCommand.bind(Vomnibar), + "Vomnibar.activateCommandSelection": Vomnibar.activateCommandSelection.bind(Vomnibar), "Vomnibar.activateEditUrl": Vomnibar.activateEditUrl.bind(Vomnibar), "Vomnibar.activateEditUrlInNewTab": Vomnibar.activateEditUrlInNewTab.bind(Vomnibar), diff --git a/content_scripts/vomnibar.js b/content_scripts/vomnibar.js index 40ac24224..162936725 100644 --- a/content_scripts/vomnibar.js +++ b/content_scripts/vomnibar.js @@ -32,7 +32,7 @@ const Vomnibar = { this.open(sourceFrameId, options); }, - activateCommand(sourceFrameId, registryEntry) { + activateCommandSelection(sourceFrameId, registryEntry) { const options = Object.assign({}, registryEntry.options, { completer: "commands", selectFirst: true, diff --git a/tests/unit_tests/completion/completers_test.js b/tests/unit_tests/completion/completers_test.js index 28fd7f75f..0d6ff60f9 100644 --- a/tests/unit_tests/completion/completers_test.js +++ b/tests/unit_tests/completion/completers_test.js @@ -373,14 +373,6 @@ context("command completer", () => { ); }); - should("return an empty list when the query is empty", async () => { - stub(chrome.storage.session, "get", async () => ({ - commandToOptionsToKeys: {}, - })); - stub(Commands, "keyToRegistryEntry", {}); - assert.equal([], await filterCompleter(multiCompleter, [])); - }); - should("handle key bound commands with options", async () => { stub(chrome.storage.session, "get", async () => ({ commandToOptionsToKeys: { From b08a12b33a5423f20b694690a1b8367c9796b6c6 Mon Sep 17 00:00:00 2001 From: Charalampos Kardaris Date: Sat, 28 Mar 2026 17:29:00 +0100 Subject: [PATCH 4/7] Review resolution: philc 1.2 "Enter" on no-selection is a no-op for all completers apart from "omni". --- pages/vomnibar_page.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pages/vomnibar_page.js b/pages/vomnibar_page.js index cfde2a5e2..25c026d4d 100644 --- a/pages/vomnibar_page.js +++ b/pages/vomnibar_page.js @@ -283,6 +283,8 @@ class VomnibarUI { if (waitingOnCompletions || this.selection == -1) { // on an empty query is a no-op. if (query.length == 0) return; + // with no selection on a completer other than "omni" is a no-op. + if (this.completerName != "omni") return; const firstCompletion = this.completions[0]; const isPrimary = isPrimarySearchSuggestion(firstCompletion); if (isPrimary) { From 1ed1fea276be2cb908c60bfd98dd08c44013c168 Mon Sep 17 00:00:00 2001 From: Charalampos Kardaris Date: Sat, 28 Mar 2026 18:20:04 +0100 Subject: [PATCH 5/7] Review resolution: philc 1.3 --- content_scripts/mode_normal.js | 2 +- lib/types.js | 6 +- pages/vomnibar_page.js | 12 +- .../unit_tests/completion/completers_test.js | 143 ++---------------- tests/unit_tests/vomnibar_page_test.js | 82 ++-------- 5 files changed, 36 insertions(+), 209 deletions(-) diff --git a/content_scripts/mode_normal.js b/content_scripts/mode_normal.js index 273f71e17..71267b9ab 100644 --- a/content_scripts/mode_normal.js +++ b/content_scripts/mode_normal.js @@ -34,7 +34,7 @@ class NormalMode extends KeyHandlerMode { // Set the raw "count" for the omni bar, before doing any additional "count" processing. // If the command to handle is Vomnibar.activateCommandSelection, the omni bar will later // propagate this "count" to the selected command. - registryEntry.options.omniCommandCount = count; + registryEntry.options.commandModePrefixCount = count; if (registryEntry.options.count) { count = (count ?? 1) * registryEntry.options.count; diff --git a/lib/types.js b/lib/types.js index ad5b1015d..8641f0a0f 100644 --- a/lib/types.js +++ b/lib/types.js @@ -11,7 +11,7 @@ globalThis.VomnibarShowOptions = { selectFirst: "boolean", // A keyword which will scope the search to a UserSearchEngine. keyword: "string", - // A prefix count number before launching the omni bar - // (used to propagate the count in "command" mode). - omniCommandCount: "number", + // The prefix count number before launching the omni bar in command mode. + // It is used to repeat the selected command. + commandModePrefixCount: "number", }; diff --git a/pages/vomnibar_page.js b/pages/vomnibar_page.js index 25c026d4d..8f60cf3c9 100644 --- a/pages/vomnibar_page.js +++ b/pages/vomnibar_page.js @@ -32,7 +32,7 @@ export async function activate(options) { newTab: false, selectFirst: false, keyword: null, - omniCommandCount: 1, + commandModePrefixCount: 1, }; options = Object.assign(defaults, options); @@ -45,7 +45,7 @@ export async function activate(options) { ui.setInitialSelectionValue(options.selectFirst ? 0 : -1); ui.setForceNewTab(options.newTab); ui.setQuery(options.query); - ui.setOmniCommandCount(options.omniCommandCount); + ui.setCommandModePrefixCount(options.commandModePrefixCount); ui.setActiveUserSearchEngine(userSearchEngines.keywordToEngine[options.keyword]); // Use await here for vomnibar_test.js, so that this page doesn't get unloaded while a test is // running. @@ -83,8 +83,8 @@ class VomnibarUI { this.completerName = name; this.reset(); } - setOmniCommandCount(omniCommandCount) { - this.omniCommandCount = omniCommandCount; + setCommandModePrefixCount(commandModePrefixCount) { + this.commandModePrefixCount = commandModePrefixCount; } // True if the user has entered the keyword of one of their custom search engines. @@ -315,8 +315,8 @@ class VomnibarUI { await chrome.runtime.sendMessage({ handler: "runNormalModeCommand", command: completion.command.registryEntry, - // Propagate "omniCommandCount" to the selected command. - count: this.omniCommandCount, + // Propagate "commandModePrefixCount" to the selected command. + count: this.commandModePrefixCount, }); }); } else { diff --git a/tests/unit_tests/completion/completers_test.js b/tests/unit_tests/completion/completers_test.js index 0d6ff60f9..881ca3acb 100644 --- a/tests/unit_tests/completion/completers_test.js +++ b/tests/unit_tests/completion/completers_test.js @@ -364,16 +364,23 @@ context("command completer", () => { commandToOptionsToKeys: {}, })); stub(Commands, "keyToRegistryEntry", {}); + const suggestions = await filterCompleter(commandCompleter, []); + + // Checks that all available commands are returned as suggestions. assert.equal(allCommands.length, suggestions.length); + + const commandHasNoOptions = (command) => { + return JSON.stringify(command.registryEntry.options) === "{}"; + }; + + // Check that by default no options (e.g. value=1.1) are applied to each command. assert.isTrue( - suggestions.every((suggestion) => - JSON.stringify(suggestion.command.registryEntry.options) === "{}" - ), + suggestions.every((suggestion) => commandHasNoOptions(suggestion.command)), ); }); - should("handle key bound commands with options", async () => { + should("create suggestions for different variations of the same command", async () => { stub(chrome.storage.session, "get", async () => ({ commandToOptionsToKeys: { "setZoom": { @@ -408,130 +415,10 @@ context("command completer", () => { }); const suggestions = await filterCompleter(multiCompleter, ["set", "zoom"]); - assert.equal([ - { - "queryTerms": [ - "set", - "zoom", - ], - "description": "command", - "url": "setZoom", - "shortUrl": "setzoom", - "title": "Set zoom", - "relevancy": 1, - "autoSelect": false, - "highlightTerms": true, - "deDuplicate": true, - "command": { - "registryEntry": { - "keySequence": [], - "command": "setZoom", - "noRepeat": true, - "background": true, - "options": {}, - }, - "keys": [], - }, - "isPrimarySuggestion": false, - "html": - '\n
    \n command\n Set zoom\n
    \n', - }, - { - "queryTerms": [ - "set", - "zoom", - ], - "description": "command", - "url": "setZoom (value=1.1)", - "shortUrl": "setzoom (value=1.1", - "title": "Set zoom (value=1.1)", - "relevancy": 1, - "autoSelect": false, - "highlightTerms": true, - "deDuplicate": true, - "command": { - "registryEntry": { - "keySequence": [ - "z", - "1", - ], - "command": "setZoom", - "noRepeat": true, - "background": true, - "options": { - "value": 1.1, - }, - }, - "keys": [ - "z1", - ], - }, - "isPrimarySuggestion": false, - "html": - '\n
    \n command\n Set zoom (value=1.1)\n \n z1\n , \n \n
    \n', - }, - { - "queryTerms": [ - "set", - "zoom", - ], - "description": "command", - "url": "setZoom (value=1.2)", - "shortUrl": "setzoom (value=1.2", - "title": "Set zoom (value=1.2)", - "relevancy": 1, - "autoSelect": false, - "highlightTerms": true, - "deDuplicate": true, - "command": { - "registryEntry": { - "keySequence": [ - "z", - "2", - ], - "command": "setZoom", - "noRepeat": true, - "background": true, - "options": { - "value": 1.2, - }, - }, - "keys": [ - "z2", - ], - }, - "isPrimarySuggestion": false, - "html": - '\n
    \n command\n Set zoom (value=1.2)\n \n z2\n , \n \n
    \n', - }, - { - "queryTerms": [ - "set", - "zoom", - ], - "description": "command", - "url": "zoomReset", - "shortUrl": "zoomreset", - "title": "Reset zoom", - "relevancy": 1, - "autoSelect": false, - "highlightTerms": true, - "deDuplicate": true, - "command": { - "registryEntry": { - "keySequence": [], - "command": "zoomReset", - "noRepeat": true, - "background": true, - "options": {}, - }, - "keys": [], - }, - "isPrimarySuggestion": false, - "html": - '\n
    \n command\n Reset zoom\n
    \n', - }, - ], suggestions); + assert.equal( + ["Set zoom", "Set zoom (value=1.1)", "Set zoom (value=1.2)", "Reset zoom"], + suggestions.map((s) => s.title), + ); }); }); diff --git a/tests/unit_tests/vomnibar_page_test.js b/tests/unit_tests/vomnibar_page_test.js index 47ea78942..89bb12c19 100644 --- a/tests/unit_tests/vomnibar_page_test.js +++ b/tests/unit_tests/vomnibar_page_test.js @@ -99,84 +99,24 @@ context("vomnibar page", () => { assert.equal("constructor ", ui.input.value); }); - should("fill the suggestions list with correct HTML", async () => { - const setZoom = allCommands.filter((command) => command.name == "setZoom")[0]; + should("create command suggestions with correct HTML for key bindings", async () => { const multiCompleter = new MultiCompleter([new CommandCompleter()]); - stub(chrome.storage.session, "get", async () => ({ - commandToOptionsToKeys: { - "setZoom": { - "value=1.1": ["z1"], - "value=1.2": ["z2"], - }, - }, - })); - - stub(Commands, "keyToRegistryEntry", { - "z1": new RegistryEntry({ - keySequence: ["z", "1"], - command: setZoom.name, - noRepeat: setZoom.noRepeat, - repeatLimit: setZoom.repeatLimit, - background: setZoom.background, - topFrame: setZoom.topFrame, - options: { - "value": 1.1, - }, - }), - "z2": new RegistryEntry({ - keySequence: ["z", "2"], - command: setZoom.name, - noRepeat: setZoom.noRepeat, - repeatLimit: setZoom.repeatLimit, - background: setZoom.background, - topFrame: setZoom.topFrame, - options: { - "value": 1.2, - }, - }), - }); - - const suggestions = await filterCompleter(multiCompleter, ["set", "zoom"]); + const suggestions = await filterCompleter(multiCompleter, ["go", "tab", "right"]); stub(chrome.runtime, "sendMessage", async () => suggestions); await ui.updateCompletions(); - assert.equal( - `\ -
  • -
    - command - Set zoom -
    -
  • -
  • -
    - command - Set zoom (value=1.1) - - z1 + assert.equal(1, ui.completionList.childNodes.length); + assert.equal([ + ` + K , - -
    -
  • -
  • -
    - command - Set zoom (value=1.2) - - z2 + `, + ` + gt , - -
    -
  • -
  • -
    - command - Reset zoom -
    -
  • `, - ui.completionList.innerHTML, - ); + `, + ], Object.values(ui.completionList.querySelectorAll(".key-block")).map((x) => x.outerHTML)); }); }); From 29d205afbd2c40a4547065075be6039b97bbccc0 Mon Sep 17 00:00:00 2001 From: Charalampos Kardaris Date: Sat, 28 Mar 2026 18:45:38 +0100 Subject: [PATCH 6/7] Review resolution: philc 1.4 Simplify suggestion de-duplication code. --- background_scripts/completion/completers.js | 27 ++++++--------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/background_scripts/completion/completers.js b/background_scripts/completion/completers.js index 9bd5cffc0..369ff49d4 100644 --- a/background_scripts/completion/completers.js +++ b/background_scripts/completion/completers.js @@ -405,14 +405,6 @@ export class CommandCompleter { }); }; - // Option suffix to add to the suggestion entry based on the command options for a mapping. - // Used in two places: - // - title: to set the actual visible text in the omni bar - // - url: used as a key of difference during the clean-up of the suggestions. - const optionsSuffix = (option) => { - return option ? ` (${option})` : ""; - }; - const matchingCommands = allCommands.filter((command) => ranking.matches(queryTerms, command.desc) ); @@ -432,7 +424,7 @@ export class CommandCompleter { queryTerms, description: "command", title: command.desc, - url: command.name, + deDuplicate: false, command: { registryEntry: createUnboundRegistryEntry(command), keys: [], @@ -448,8 +440,8 @@ export class CommandCompleter { new Suggestion({ queryTerms, description: "command", - title: command.desc + optionsSuffix(options), - url: command.name + optionsSuffix(options), + title: command.desc + (options ? ` (${options})` : ""), + deDuplicate: false, command: { registryEntry: Commands.keyToRegistryEntry[keys[0]], keys: keys, @@ -783,17 +775,12 @@ export class MultiCompleter { } suggestions.sort((a, b) => b.relevancy - a.relevancy); - // Simplify URLs and remove duplicates (duplicate simplified URLs, that is). - let count = 0; - const seenUrls = {}; - const dedupedSuggestions = []; for (const s of suggestions) { - const url = s.shortenUrl(); - if (s.deDuplicate && seenUrls[url]) continue; - if (count++ === maxResults) break; - seenUrls[url] = s; - dedupedSuggestions.push(s); + if (dedupedSuggestions.length === maxResults) break; + if (!s.deDuplicate || !dedupedSuggestions.includes(s.shortenUrl())) { + dedupedSuggestions.push(s); + } } // Give each completer the opportunity to tweak the suggestions. From 1664c8daee089369884affdce9a5b048cf1f6974 Mon Sep 17 00:00:00 2001 From: Charalampos Kardaris Date: Sat, 28 Mar 2026 19:10:14 +0100 Subject: [PATCH 7/7] Review resolution: philc 1.5 --- background_scripts/completion/completers.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/background_scripts/completion/completers.js b/background_scripts/completion/completers.js index 369ff49d4..0019bea4b 100644 --- a/background_scripts/completion/completers.js +++ b/background_scripts/completion/completers.js @@ -410,8 +410,8 @@ export class CommandCompleter { ); let suggestions = []; - for (const command of matchingCommands) { - const variations = commandToOptionsToKeys[command.name] || {}; + for (const commandInfo of matchingCommands) { + const variations = commandToOptionsToKeys[commandInfo.name] || {}; // Indicates if the default action of the command (no additional options) is bound to a key. const isDefaultBound = Object.keys(variations).some((option) => option.length === 0); @@ -423,10 +423,10 @@ export class CommandCompleter { new Suggestion({ queryTerms, description: "command", - title: command.desc, + title: commandInfo.desc, deDuplicate: false, command: { - registryEntry: createUnboundRegistryEntry(command), + registryEntry: createUnboundRegistryEntry(commandInfo), keys: [], }, relevancy: 1, @@ -440,7 +440,7 @@ export class CommandCompleter { new Suggestion({ queryTerms, description: "command", - title: command.desc + (options ? ` (${options})` : ""), + title: commandInfo.desc + (options ? ` (${options})` : ""), deDuplicate: false, command: { registryEntry: Commands.keyToRegistryEntry[keys[0]],