diff --git a/background_scripts/all_commands.js b/background_scripts/all_commands.js index 607fb73a7..96f9e0a38 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.activateCommandSelection", + 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, }, // @@ -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,6 +543,7 @@ const allCommands = [ group: "tabs", advanced: true, background: true, + noRepeat: 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..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,6 +455,7 @@ const defaultKeyMappings = { "T": "Vomnibar.activateTabSelection", "b": "Vomnibar.activateBookmarks", "B": "Vomnibar.activateBookmarksInNewTab", + ":": "Vomnibar.activateCommandSelection", "ge": "Vomnibar.activateEditUrl", "gE": "Vomnibar.activateEditUrlInNewTab", diff --git a/background_scripts/completion/completers.js b/background_scripts/completion/completers.js index fbd45c11e..0019bea4b 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; @@ -93,6 +103,23 @@ export class Suggestion { ${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 = `\ @@ -353,6 +380,81 @@ 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: {}, + }); + }; + + const matchingCommands = allCommands.filter((command) => + ranking.matches(queryTerms, command.desc) + ); + + let suggestions = []; + 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); + + // 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: commandInfo.desc, + deDuplicate: false, + command: { + registryEntry: createUnboundRegistryEntry(commandInfo), + 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: commandInfo.desc + (options ? ` (${options})` : ""), + deDuplicate: false, + 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 @@ -673,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. diff --git a/background_scripts/main.js b/background_scripts/main.js index 7d37e6b1b..334f3f61a 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..71267b9ab 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.activateCommandSelection, the omni bar will later + // propagate this "count" to the selected command. + registryEntry.options.commandModePrefixCount = 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.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 a8405f75d..162936725 100644 --- a/content_scripts/vomnibar.js +++ b/content_scripts/vomnibar.js @@ -32,6 +32,14 @@ const Vomnibar = { this.open(sourceFrameId, options); }, + activateCommandSelection(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..8641f0a0f 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", + // 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.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..8f60cf3c9 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, + commandModePrefixCount: 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.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. @@ -81,6 +83,9 @@ class VomnibarUI { this.completerName = name; this.reset(); } + setCommandModePrefixCount(commandModePrefixCount) { + this.commandModePrefixCount = commandModePrefixCount; + } // 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; } @@ -275,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) { @@ -300,6 +310,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 "commandModePrefixCount" to the selected command. + count: this.commandModePrefixCount, + }); + }); } else { this.hide(() => this.openCompletion(completion, openInNewTab)); } @@ -341,7 +360,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 +461,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..881ca3acb 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,74 @@ 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, []); + + // 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) => commandHasNoOptions(suggestion.command)), + ); + }); + + should("create suggestions for different variations of the same command", 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( + ["Set zoom", "Set zoom (value=1.1)", "Set zoom (value=1.2)", "Reset zoom"], + suggestions.map((s) => s.title), + ); + }); +}); + 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..89bb12c19 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,25 @@ context("vomnibar page", () => { // The query should not be treated as a user search engine. assert.equal("constructor ", ui.input.value); }); + + should("create command suggestions with correct HTML for key bindings", async () => { + const multiCompleter = new MultiCompleter([new CommandCompleter()]); + + const suggestions = await filterCompleter(multiCompleter, ["go", "tab", "right"]); + stub(chrome.runtime, "sendMessage", async () => suggestions); + + await ui.updateCompletions(); + + assert.equal(1, ui.completionList.childNodes.length); + assert.equal([ + ` + K + , + `, + ` + gt + , + `, + ], Object.values(ui.completionList.querySelectorAll(".key-block")).map((x) => x.outerHTML)); + }); });