`;
} 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) => `