Bug 1303384 - Part 3: Manage extension shortcuts page r=aswan,Gijs,flod
authorMark Striemer <mstriemer@mozilla.com>
Sat, 12 Jan 2019 06:45:17 +0000
changeset 453734 456b9b7963eb9c0815f8fa250de5424317c00001
parent 453733 d1eacc92d6d6dc2996018d1e7fb583e956bbcd41
child 453735 77c0cf8378dfcf66180f474caa49208e973e9d88
push id35372
push usercbrindusan@mozilla.com
push dateMon, 14 Jan 2019 21:49:33 +0000
treeherdermozilla-central@50b3268954b1 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, Gijs, flod
bugs1303384
milestone66.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1303384 - Part 3: Manage extension shortcuts page r=aswan,Gijs,flod MozReview-Commit-ID: KeZsoB6qj88 Differential Revision: https://phabricator.services.mozilla.com/D4507
toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
toolkit/mozapps/extensions/content/extensions.js
toolkit/mozapps/extensions/content/extensions.xul
toolkit/mozapps/extensions/content/shortcuts.css
toolkit/mozapps/extensions/content/shortcuts.html
toolkit/mozapps/extensions/content/shortcuts.js
toolkit/mozapps/extensions/jar.mn
toolkit/mozapps/extensions/test/browser/.eslintrc.js
toolkit/mozapps/extensions/test/browser/browser.ini
toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js
toolkit/themes/shared/extensions/extensions.inc.css
toolkit/themes/shared/in-content/common.inc.css
--- a/toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
+++ b/toolkit/locales/en-US/chrome/mozapps/extensions/extensions.properties
@@ -101,15 +101,16 @@ type.legacy.name=Legacy Extensions
 type.unsupported.name=Unsupported
 
 #LOCALIZATION NOTE(legacyWarning.description) %S is the brandShortName
 legacyWarning.description=Missing something? Some extensions are no longer supported by %S.
 #LOCALIZATION NOTE(legacyThemeWarning.description) %S is the brandShortName
 legacyThemeWarning.description=Missing something? Some themes are no longer supported by %S.
 
 listHeading.extension=Manage Your Extensions
+listHeading.shortcuts=Manage Extension Shortcuts
 listHeading.theme=Manage Your Themes
 listHeading.plugin=Manage Your Plugins
 listHeading.locale=Manage Your Languages
 listHeading.dictionary=Manage Your Dictionaries
 
 searchLabel.extension=Find more extensions
 searchLabel.theme=Find more themes
--- a/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
+++ b/toolkit/locales/en-US/toolkit/about/aboutAddons.ftl
@@ -270,8 +270,28 @@ extensions-updates-restart =
     .label = Restart now to complete installation
 extensions-updates-none-found =
     .value = No updates found
 extensions-updates-manual-updates-found =
     .label = View Available Updates
 extensions-updates-update-selected =
     .label = Install Updates
     .tooltiptext = Install available updates in this list
+
+## Extension shortcut management
+
+shortcuts-manage =
+  .label = Keyboard Shortcuts
+shortcuts-empty-message = There are no shortcuts for this extension.
+# TODO: Confirm this copy.
+shortcuts-no-addons = You don't have any active add-ons.
+shortcuts-input =
+  .placeholder = Type a shortcut
+
+shortcuts-browserAction = Activate extension
+shortcuts-pageAction = Activate page action
+shortcuts-sidebarAction = Toggle the sidebar
+
+shortcuts-modifier-mac = Include Ctrl, Alt, or ⌘
+shortcuts-modifier-other = Include Ctrl or Alt
+shortcuts-invalid = Invalid combination
+shortcuts-letter = Type a letter
+shortcuts-system = Can’t override a { -brand-short-name } shortcut
--- a/toolkit/mozapps/extensions/content/extensions.js
+++ b/toolkit/mozapps/extensions/content/extensions.js
@@ -275,19 +275,24 @@ function isDiscoverEnabled() {
     return false;
   }
 
   return true;
 }
 
 function setSearchLabel(type) {
   let searchLabel = document.getElementById("search-label");
-  if (type == "extension" || type == "theme") {
+  let keyMap = {
+    extension: "extension",
+    shortcuts: "extension",
+    theme: "theme",
+  };
+  if (type in keyMap) {
     searchLabel
-      .textContent = gStrings.ext.GetStringFromName(`searchLabel.${type}`);
+      .textContent = gStrings.ext.GetStringFromName(`searchLabel.${keyMap[type]}`);
     searchLabel.hidden = false;
   } else {
     searchLabel.textContent = "";
     searchLabel.hidden = true;
   }
 }
 
 /**
@@ -687,16 +692,17 @@ var gViewController = {
     this.headeredViews = document.getElementById("headered-views");
     this.headeredViewsDeck = document.getElementById("headered-views-content");
 
     this.viewObjects.discover = gDiscoverView;
     this.viewObjects.list = gListView;
     this.viewObjects.legacy = gLegacyView;
     this.viewObjects.detail = gDetailView;
     this.viewObjects.updates = gUpdatesView;
+    this.viewObjects.shortcuts = gShortcutsView;
 
     for (let type in this.viewObjects) {
       let view = this.viewObjects[type];
       view.initialize();
     }
 
     window.controllers.appendController(this);
 
@@ -1378,16 +1384,25 @@ var gViewController = {
     cmd_showAllExtensions: {
       isEnabled() {
         return true;
       },
       doCommand() {
         gViewController.loadView("addons://list/extension");
       },
     },
+
+    cmd_showShortcuts: {
+      isEnabled() {
+        return true;
+      },
+      doCommand() {
+        gViewController.loadView("addons://shortcuts/shortcuts");
+      },
+    },
   },
 
   supportsCommand(aCommand) {
     return (aCommand in this.commands);
   },
 
   isCommandEnabled(aCommand) {
     if (!this.supportsCommand(aCommand))
@@ -2443,16 +2458,19 @@ var gListView = {
           sortBy = ["uiState", "name"];
         }
         sortElements(elements, sortBy, true);
         for (let element of elements) {
           this._listBox.appendChild(element);
         }
       }
 
+      // Only show the manage shortcuts button for extensions.
+      document.getElementById("manage-shortcuts").hidden = this._type != "extension";
+
       this.filterDisabledUnsigned(showOnlyDisabledUnsigned);
       let legacyNotice = document.getElementById("legacy-extensions-notice");
       if (showLegacyInfo) {
         let el = document.getElementById("legacy-extensions-description");
         if (el.childNodes[0].nodeName == "#text") {
           el.removeChild(el.childNodes[0]);
         }
 
@@ -2469,16 +2487,17 @@ var gListView = {
       gViewController.updateCommands();
       gViewController.notifyViewChanged();
     });
   },
 
   hide() {
     gEventManager.unregisterInstallListener(this);
     doPendingUninstalls(this._listBox);
+    document.getElementById("manage-shortcuts").hidden = true;
   },
 
   filterDisabledUnsigned(aFilter = true) {
     let foundDisabledUnsigned = false;
 
     for (let item of this._listBox.childNodes) {
       if (isDisabledUnsigned(item.mAddon)) {
         foundDisabledUnsigned = true;
@@ -3466,16 +3485,49 @@ var gUpdatesView = {
   },
 
   onPropertyChanged(aAddon, aProperties) {
     if (aProperties.includes("applyBackgroundUpdates"))
       this.updateAvailableCount();
   },
 };
 
+var gShortcutsView = {
+  node: null,
+  loaded: null,
+
+  initialize() {
+    this.node = document.getElementById("shortcuts-view");
+    this.node.loadURI("chrome://mozapps/content/extensions/shortcuts.html", {
+      triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+    });
+    // Store a Promise for when the contentWindow will exist.
+    this.loaded = new Promise(resolve => this.node.addEventListener("load", resolve, {once: true}));
+  },
+
+  async show() {
+    // Ensure the Extensions category is selected in case of refresh/restart.
+    gCategories.select("addons://list/extension");
+
+    await this.loaded;
+    await this.node.contentWindow.render();
+    gViewController.notifyViewChanged();
+  },
+
+  refresh() {
+    return this.show();
+  },
+
+  hide() {},
+
+  getSelectedAddon() {
+    return null;
+  },
+};
+
 var gDragDrop = {
   onDragOver(aEvent) {
     if (!XPINSTALL_ENABLED) {
       aEvent.dataTransfer.effectAllowed = "none";
       return;
     }
     var types = aEvent.dataTransfer.types;
     if (types.includes("text/uri-list") ||
--- a/toolkit/mozapps/extensions/content/extensions.xul
+++ b/toolkit/mozapps/extensions/content/extensions.xul
@@ -100,16 +100,17 @@
     <command id="cmd_back"/>
     <command id="cmd_forward"/>
     <command id="cmd_enableCheckCompatibility"/>
     <command id="cmd_enableUpdateSecurity"/>
     <command id="cmd_toggleAutoUpdateDefault"/>
     <command id="cmd_resetAddonAutoUpdate"/>
     <command id="cmd_showUnsignedExtensions"/>
     <command id="cmd_showAllExtensions"/>
+    <command id="cmd_showShortcuts"/>
   </commandset>
 
   <!-- view commands - these act on the selected addon -->
   <commandset id="viewCommandSet"
               events="richlistbox-select" commandupdater="true">
     <command id="cmd_showItemDetails"/>
     <command id="cmd_findItemUpdates"/>
     <command id="cmd_showItemPreferences"/>
@@ -246,16 +247,18 @@
                        data-l10n-id="extensions-updates-installed"/>
                 <label id="updates-downloaded" hidden="true"
                        data-l10n-id="extensions-updates-downloaded"/>
                 <button id="updates-restart-btn" class="button-link" hidden="true"
                         data-l10n-id="extensions-updates-restart"
                         command="cmd_restartApp"/>
               </hbox>
 
+              <button id="manage-shortcuts" data-l10n-id="shortcuts-manage" command="cmd_showShortcuts" hidden="true"/>
+
               <toolbarbutton id="header-utils-btn" type="menu"
                         data-l10n-id="tools-menu">
                 <menupopup id="utils-menu">
                   <menuitem id="utils-updateNow"
                             data-l10n-id="extensions-updates-check-for-updates"
                             command="cmd_findAllUpdates"/>
                   <menuitem id="utils-viewUpdates"
                             data-l10n-id="extensions-updates-view-updates"
@@ -352,16 +355,19 @@
                           data-l10n-id="list-empty-button"
                           command="cmd_goToDiscoverPane"/>
                 </vbox>
                 <spacer class="alert-spacer-after"/>
               </vbox>
               <richlistbox id="addon-list" class="list" flex="1"/>
             </vbox>
 
+            <!-- extension shortcuts view -->
+            <browser id="shortcuts-view" type="content" flex="1" disablehistory="true"/>
+
             <!-- legacy extensions view -->
             <vbox id="legacy-view" flex="1" class="view-pane" align="stretch" tabindex="0">
               <vbox id="legacy-extensions-info">
                 <label id="legacy-extensions-heading" data-l10n-id="legacy-extensions"/>
                 <description data-l10n-id="legacy-extensions-description">
                   <label class="text-link plain" id="legacy-learnmore" data-l10n-name="legacy-learn-more"></label>
                 </description>
               </vbox>
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/shortcuts.css
@@ -0,0 +1,82 @@
+.body {
+  margin-inline-start: 28px;
+}
+
+.shortcut.card {
+  /* Preferences content is 664px and the cards have 16px of left/right padding. */
+  width: 632px;
+  margin-bottom: 16px;
+}
+
+.shortcut.card:first-of-type {
+  margin-top: 8px;
+}
+
+.shortcut.card:hover {
+  box-shadow: var(--card-shadow);
+}
+
+.card-heading-icon {
+  width: 24px;
+  height: 24px;
+  margin-inline-end: 16px;
+}
+
+.card-heading {
+  display: flex;
+  font-weight: 600;
+}
+
+.shortcuts-empty-label {
+  margin-top: 16px;
+}
+
+.shortcut-row {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-top: 10px;
+}
+
+.shortcut-input {
+  font-size: 12px;
+  padding: 4px 8px;
+}
+
+.extension-heading {
+  display: flex;
+}
+
+.error-message {
+  --error-background: var(--red-60);
+  color: white;
+  display: flex;
+  flex-direction: column;
+  position: absolute;
+  visibility: hidden;
+}
+
+.error-message-icon {
+  margin-left: 10px;
+  width: 14px;
+  height: 8px;
+  fill: var(--error-background);
+  stroke: var(--error-background);
+  -moz-context-properties: fill, stroke;
+}
+
+.error-message-label {
+  background-color: var(--error-background);
+  border-radius: 2px;
+  margin: 0;
+  padding: 5px 10px;
+}
+
+.error-message-arrow {
+  background-color: var(--error-background);
+  content: "";
+  max-height: 8px;
+  width: 8px;
+  transform: translate(4px, -6px) rotate(45deg);
+  position: absolute;
+}
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/shortcuts.html
@@ -0,0 +1,51 @@
+<!-- This Source Code Form is subject to the terms of the Mozilla Public
+   - License, v. 2.0. If a copy of the MPL was not distributed with this
+   - file, You can obtain one at http://mozilla.org/MPL/2.0/. -->
+
+<!DOCTYPE html>
+<html>
+  <head>
+    <link rel="stylesheet" href="chrome://global/skin/in-content/common.css" type="text/css"/>
+    <link rel="stylesheet" href="chrome://mozapps/content/extensions/shortcuts.css"  type="text/css"/>
+
+    <link rel="localization" href="branding/brand.ftl"/>
+    <link rel="localization" href="toolkit/about/aboutAddons.ftl"/>
+
+    <script type="application/javascript" src="chrome://mozapps/content/extensions/shortcuts.js"></script>
+  </head>
+  <body id="body">
+    <div class="body">
+      <div class="error-message">
+        <img class="error-message-icon" src="chrome://global/skin/arrow/panelarrow-vertical.svg"/>
+        <div class="error-message-label"></div>
+      </div>
+
+      <div id="addon-shortcuts"></div>
+
+      <template id="card-template">
+        <div class="card shortcut">
+          <div class="card-heading">
+            <img class="card-heading-icon addon-icon"/>
+            <span class="addon-name"></span>
+          </div>
+        </div>
+      </template>
+
+      <template id="shortcut-row-template">
+        <div class="shortcut-row">
+          <label class="shortcut-label"></label>
+          <input class="shortcut-input" data-l10n-id="shortcuts-input" type="text" readonly/>
+        </div>
+      </template>
+
+      <template id="shortcuts-empty-template">
+        <div class="shortcuts-empty-label" data-l10n-id="shortcuts-empty-message"></div>
+      </template>
+
+      <template id="shortcuts-no-addons">
+        <div data-l10n-id="shortcuts-no-addons"></div>
+      </template>
+
+    </div>
+  </body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/content/shortcuts.js
@@ -0,0 +1,321 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+/* exported render */
+
+"use strict";
+
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  AddonManager: "resource://gre/modules/AddonManager.jsm",
+  AppConstants: "resource://gre/modules/AppConstants.jsm",
+  ShortcutUtils: "resource://gre/modules/ShortcutUtils.jsm",
+});
+
+let templatesLoaded = false;
+const templates = {};
+
+function loadTemplates() {
+  if (templatesLoaded) return;
+  templatesLoaded = true;
+
+  templates.card = document.getElementById("card-template");
+  templates.row = document.getElementById("shortcut-row-template");
+  templates.empty = document.getElementById("shortcuts-empty-template");
+  templates.noAddons = document.getElementById("shortcuts-no-addons");
+}
+
+function extensionForAddonId(id) {
+  let policy = WebExtensionPolicy.getByID(id);
+  return policy && policy.extension;
+}
+
+let builtInNames = new Map([
+  ["_execute_browser_action", "shortcuts-browserAction"],
+  ["_execute_page_action", "shortcuts-pageAction"],
+  ["_execute_sidebar_action", "shortcuts-sidebarAction"],
+]);
+let getCommandDescriptionId = (command) => {
+  if (!command.description && builtInNames.has(command.name)) {
+    return builtInNames.get(command.name);
+  }
+  return null;
+};
+
+const _functionKeys = [
+  "F1", "F2", "F3", "F4", "F5", "F6", "F7", "F8", "F9", "F10", "F11", "F12",
+];
+const functionKeys = new Set(_functionKeys);
+const validKeys = new Set([
+  "Home", "End", "PageUp", "PageDown", "Insert", "Delete",
+  "0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
+  ..._functionKeys,
+  "MediaNextTrack", "MediaPlayPause", "MediaPrevTrack", "MediaStop",
+  "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M",
+  "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z",
+  "Up", "Down", "Left", "Right",
+  "Comma", "Period", "Space",
+]);
+
+/**
+ * Trim a valid prefix from an event string.
+ *
+ *     "Digit3" ~> "3"
+ *     "ArrowUp" ~> "Up"
+ *     "W" ~> "W"
+ *
+ * @param {string} string The input string.
+ * @returns {string} The trimmed string, or unchanged.
+ */
+function trimPrefix(string) {
+  return string.replace(/^(?:Digit|Numpad|Arrow)/, "");
+}
+
+const remapKeys = {
+  ",": "Comma",
+  ".": "Period",
+  " ": "Space",
+};
+/**
+ * Map special keys to their shortcut name.
+ *
+ *     "," ~> "Comma"
+ *     " " ~> "Space"
+ *
+ * @param {string} string The input string.
+ * @returns {string} The remapped string, or unchanged.
+ */
+function remapKey(string) {
+  if (remapKeys.hasOwnProperty(string)) {
+    return remapKeys[string];
+  }
+  return string;
+}
+
+const keyOptions = [
+  e => String.fromCharCode(e.which), // A letter?
+  e => e.code.toUpperCase(), // A letter.
+  e => trimPrefix(e.code), // Digit3, ArrowUp, Numpad9.
+  e => trimPrefix(e.key), // Digit3, ArrowUp, Numpad9.
+  e => remapKey(e.key), // Comma, Period, Space.
+];
+/**
+ * Map a DOM event to a shortcut string character.
+ *
+ * For example:
+ *
+ *    "a" ~> "A"
+ *    "Digit3" ~> "3"
+ *    "," ~> "Comma"
+ *
+ * @param {object} event A KeyboardEvent.
+ * @returns {string} A string corresponding to the pressed key.
+ */
+function getStringForEvent(event) {
+  for (let option of keyOptions) {
+    let value = option(event);
+    if (validKeys.has(value)) {
+      return value;
+    }
+  }
+
+  return "";
+}
+
+function getShortcutValue(shortcut) {
+  if (!shortcut) {
+    // Ensure the shortcut is a string, even if it is unset.
+    return null;
+  }
+
+  let modifiers = shortcut.split("+");
+  let key = modifiers.pop();
+
+  if (modifiers.length > 0) {
+    let modifiersAttribute = ShortcutUtils.getModifiersAttribute(modifiers);
+    let displayString =
+      ShortcutUtils.getModifierString(modifiersAttribute) + key;
+    return displayString;
+  }
+
+  if (functionKeys.has(key)) {
+    return key;
+  }
+
+  return null;
+}
+
+let error;
+
+function setError(input, messageId) {
+  if (!error) error = document.querySelector(".error-message");
+
+  let {x, y, height} = input.getBoundingClientRect();
+  error.style.top = `${y + window.scrollY + height - 5}px`;
+  error.style.left = `${x}px`;
+  document.l10n.setAttributes(
+    error.querySelector(".error-message-label"), messageId);
+  error.style.visibility = "visible";
+}
+
+function inputBlurred(e) {
+  if (!error) error = document.querySelector(".error-message");
+
+  error.style.visibility = "hidden";
+  e.target.value = getShortcutValue(e.target.getAttribute("shortcut"));
+}
+
+function clearValue(e) {
+  e.target.value = "";
+}
+
+function getShortcutForEvent(e) {
+  let modifierMap;
+
+  if (AppConstants.platform == "macosx") {
+    modifierMap = {
+      MacCtrl: e.ctrlKey,
+      Alt: e.altKey,
+      Command: e.metaKey,
+      Shift: e.shiftKey,
+    };
+  } else {
+    modifierMap = {
+      Ctrl: e.ctrlKey,
+      Alt: e.altKey,
+      Shift: e.shiftKey,
+    };
+  }
+
+  return Object.entries(modifierMap)
+    .filter(([key, isDown]) => isDown)
+    .map(([key]) => key)
+    .concat(getStringForEvent(e))
+    .join("+");
+}
+
+function onShortcutChange(e) {
+  let input = e.target;
+
+  if (e.key == "Escape") {
+    input.blur();
+    return;
+  }
+
+  if (e.key == "Tab") {
+    return;
+  }
+
+  e.preventDefault();
+  e.stopPropagation();
+
+  let shortcutString = getShortcutForEvent(e);
+  input.value = getShortcutValue(shortcutString);
+
+  if (e.type == "keyup" || shortcutString.length == 0) {
+    return;
+  }
+
+  let validation = ShortcutUtils.validate(shortcutString);
+  switch (validation) {
+    case ShortcutUtils.IS_VALID:
+      // Show an error if this is already a system shortcut.
+      let chromeWindow = window.windowRoot.ownerGlobal;
+      if (ShortcutUtils.isSystem(chromeWindow, shortcutString)) {
+        setError(input, "shortcuts-system");
+        break;
+      }
+
+      // Update the shortcut if it isn't reserved.
+      let addonId = input.closest(".card").getAttribute("addon-id");
+      let extension = extensionForAddonId(addonId);
+
+      // This is async, but we're not awaiting it to keep the handler sync.
+      extension.shortcuts.updateCommand({
+        name: input.getAttribute("name"),
+        shortcut: shortcutString,
+      });
+      input.setAttribute("shortcut", shortcutString);
+      input.blur();
+      break;
+    case ShortcutUtils.MODIFIER_REQUIRED:
+      if (AppConstants.platform == "macosx")
+        setError(input, "shortcuts-modifier-mac");
+      else
+        setError(input, "shortcuts-modifier-other");
+      break;
+    case ShortcutUtils.INVALID_COMBINATION:
+      setError(input, "shortcuts-invalid");
+      break;
+    case ShortcutUtils.INVALID_KEY:
+      setError(input, "shortcuts-letter");
+      break;
+  }
+}
+
+async function renderAddons(addons) {
+  let frag = document.createDocumentFragment();
+  for (let addon of addons) {
+    let extension = extensionForAddonId(addon.id);
+
+    // Skip this extension if it isn't a webextension.
+    if (!extension) continue;
+
+    let card = document.importNode(
+      templates.card.content, true).firstElementChild;
+    let icon = AddonManager.getPreferredIconURL(addon, 24, window);
+    card.setAttribute("addon-id", addon.id);
+    card.querySelector(".addon-icon").src = icon;
+    card.querySelector(".addon-name").textContent = addon.name;
+
+    if (extension.shortcuts) {
+      let commands = await extension.shortcuts.allCommands();
+
+      for (let command of commands) {
+        let row = document.importNode(templates.row.content, true);
+        let label = row.querySelector(".shortcut-label");
+        let descriptionId = getCommandDescriptionId(command);
+        if (descriptionId) {
+          document.l10n.setAttributes(label, descriptionId);
+        } else {
+          label.textContent = command.description || command.name;
+        }
+        let input = row.querySelector(".shortcut-input");
+        input.value = getShortcutValue(command.shortcut);
+        input.setAttribute("name", command.name);
+        input.setAttribute("shortcut", command.shortcut);
+        input.addEventListener("keydown", onShortcutChange);
+        input.addEventListener("keyup", onShortcutChange);
+        input.addEventListener("blur", inputBlurred);
+        input.addEventListener("focus", clearValue);
+
+        card.appendChild(row);
+      }
+    } else {
+      card.appendChild(document.importNode(templates.empty.content, true));
+    }
+
+    frag.appendChild(card);
+  }
+  return frag;
+}
+
+async function render() {
+  loadTemplates();
+  let allAddons = await AddonManager.getAddonsByTypes(["extension"]);
+  let addons = allAddons
+    .filter(addon => !addon.isSystem && addon.isActive)
+    .sort((a, b) => a.name.localeCompare(b.name));
+  let frag;
+
+  if (addons.length > 0) {
+    frag = await renderAddons(addons);
+  } else {
+    frag = document.importNode(templates.noAddons.content, true);
+  }
+
+  let container = document.getElementById("addon-shortcuts");
+  container.textContent = "";
+  container.appendChild(frag);
+}
--- a/toolkit/mozapps/extensions/jar.mn
+++ b/toolkit/mozapps/extensions/jar.mn
@@ -1,15 +1,18 @@
 # This Source Code Form is subject to the terms of the Mozilla Public
 # License, v. 2.0. If a copy of the MPL was not distributed with this
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 toolkit.jar:
 % content mozapps %content/mozapps/
   content/mozapps/extensions/default-theme-icon.svg             (content/default-theme-icon.svg)
+  content/mozapps/extensions/shortcuts.html                     (content/shortcuts.html)
+  content/mozapps/extensions/shortcuts.css                      (content/shortcuts.css)
+  content/mozapps/extensions/shortcuts.js                       (content/shortcuts.js)
 #ifndef MOZ_FENNEC
 * content/mozapps/extensions/extensions.xul                     (content/extensions.xul)
   content/mozapps/extensions/extensions.css                     (content/extensions.css)
   content/mozapps/extensions/extensions.js                      (content/extensions.js)
 * content/mozapps/extensions/extensions.xml                     (content/extensions.xml)
   content/mozapps/extensions/updateinfo.xsl                     (content/updateinfo.xsl)
   content/mozapps/extensions/blocklist.xul                      (content/blocklist.xul)
   content/mozapps/extensions/blocklist.js                       (content/blocklist.js)
--- a/toolkit/mozapps/extensions/test/browser/.eslintrc.js
+++ b/toolkit/mozapps/extensions/test/browser/.eslintrc.js
@@ -1,11 +1,15 @@
 "use strict";
 
 module.exports = {
   "extends": [
     "plugin:mozilla/browser-test"
   ],
 
+  "env": {
+    "webextensions": true,
+  },
+
   "rules": {
     "no-unused-vars": ["error", {"args": "none", "varsIgnorePattern": "^end_test$"}],
   }
 };
--- a/toolkit/mozapps/extensions/test/browser/browser.ini
+++ b/toolkit/mozapps/extensions/test/browser/browser.ini
@@ -76,27 +76,28 @@ skip-if = os == 'linux' && !debug # Bug 
 [browser_inlinesettings_browser.js]
 skip-if = os == 'mac' || os == 'linux' # Bug 1483347
 [browser_installssl.js]
 skip-if = verify
 [browser_langpack_signing.js]
 [browser_legacy.js]
 [browser_legacy_pre57.js]
 [browser_list.js]
-[browser_theme_previews.js]
+[browser_manage_shortcuts.js]
 [browser_manualupdates.js]
 [browser_pluginprefs.js]
 [browser_pluginprefs_is_not_disabled.js]
 [browser_plugin_enabled_state_locked.js]
 [browser_recentupdates.js]
 [browser_reinstall.js]
 [browser_sorting.js]
 [browser_sorting_plugins.js]
 [browser_tabsettings.js]
 [browser_task_next_test.js]
+[browser_theme_previews.js]
 [browser_types.js]
 [browser_uninstalling.js]
 [browser_updateid.js]
 [browser_updatessl.js]
 [browser_webapi.js]
 [browser_webapi_access.js]
 [browser_webapi_addon_listener.js]
 [browser_webapi_enable.js]
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/browser/browser_manage_shortcuts.js
@@ -0,0 +1,126 @@
+"use strict";
+
+let gManagerWindow;
+let gCategoryUtilities;
+
+const {PromiseTestUtils} = ChromeUtils.import("resource://testing-common/PromiseTestUtils.jsm", {});
+PromiseTestUtils.whitelistRejectionsGlobally(/Message manager disconnected/);
+
+add_task(async function testUpdatingCommands() {
+  let commands = {
+    commandOne: {
+      suggested_key: {default: "Shift+Alt+4"},
+    },
+    commandTwo: {
+      description: "Command Two!",
+      suggested_key: {default: "Alt+4"},
+    },
+    _execute_browser_action: {
+      suggested_key: {default: "Shift+Alt+5"},
+    },
+  };
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      commands,
+      browser_action: {default_popup: "popup.html"},
+    },
+    background() {
+      browser.commands.onCommand.addListener(commandName => {
+        browser.test.sendMessage("oncommand", commandName);
+      });
+      browser.test.sendMessage("ready");
+    },
+    useAddonManager: "temporary",
+  });
+
+  await extension.startup();
+  await extension.awaitMessage("ready");
+
+  gManagerWindow = await open_manager(null);
+  gCategoryUtilities = new CategoryUtilities(gManagerWindow);
+  await gCategoryUtilities.openType("extension");
+
+  async function checkShortcut(name, key, modifiers) {
+    EventUtils.synthesizeKey(key, modifiers);
+    let message = await extension.awaitMessage("oncommand");
+    is(message, name, `Expected onCommand listener to fire with the correct name: ${name}`);
+  }
+
+  // Check that the original shortcuts work.
+  await checkShortcut("commandOne", "4", {shiftKey: true, altKey: true});
+  await checkShortcut("commandTwo", "4", {altKey: true});
+
+  // There should be a manage shortcuts link.
+  let doc = gManagerWindow.document;
+  let shortcutsLink = doc.getElementById("manage-shortcuts");
+  ok(!shortcutsLink.hidden, "The shortcuts link is visible");
+
+  // Open the shortcuts view.
+  shortcutsLink.click();
+  await wait_for_view_load(gManagerWindow);
+
+  doc = doc.getElementById("shortcuts-view").contentDocument;
+
+  let card = doc.querySelector(`.card[addon-id="${extension.id}"]`);
+  ok(card, `There is a card for the extension`);
+
+  let inputs = card.querySelectorAll(".shortcut-input");
+  is(inputs.length, Object.keys(commands).length, "There is an input for each command");
+
+  for (let input of inputs) {
+    // Change the shortcut.
+    input.focus();
+    EventUtils.synthesizeKey("7", {shiftKey: true, altKey: true});
+
+    // Wait for the shortcut attribute to change.
+    await BrowserTestUtils.waitForCondition(
+      () => input.getAttribute("shortcut") == "Alt+Shift+7");
+
+    // Check that the change worked (but skip if browserAction).
+    if (input.getAttribute("name") != "_execute_browser_action") {
+      await checkShortcut(input.getAttribute("name"), "7", {shiftKey: true, altKey: true});
+    }
+
+    // Change it again so it doesn't conflict with the next command.
+    input.focus();
+    EventUtils.synthesizeKey("9", {shiftKey: true, altKey: true});
+    await BrowserTestUtils.waitForCondition(
+      () => input.getAttribute("shortcut") == "Alt+Shift+9");
+  }
+
+  // Check that errors can be shown.
+  let input = inputs[0];
+  let error = doc.querySelector(".error-message");
+  let label = error.querySelector(".error-message-label");
+  is(error.style.visibility, "hidden", "The error is initially hidden");
+
+  // Try a shortcut with only shift for a modifier.
+  input.focus();
+  EventUtils.synthesizeKey("J", {shiftKey: true});
+  let possibleErrors = ["shortcuts-modifier-mac", "shortcuts-modifier-other"];
+  ok(possibleErrors.includes(label.dataset.l10nId), `The message is set`);
+  is(error.style.visibility, "visible", "The error is shown");
+
+  // Escape should clear the focus and hide the error.
+  is(doc.activeElement, input, "The input is focused");
+  EventUtils.synthesizeKey("Escape", {});
+  ok(doc.activeElement != input, "The input is no longer focused");
+  is(error.style.visibility, "hidden", "The error is hidden");
+
+  // Check the label uses the description first, and has a default for the special cases.
+  function checkLabel(name, value) {
+    let input = doc.querySelector(`.shortcut-input[name="${name}"]`);
+    let label = input.previousElementSibling;
+    if (label.dataset.l10nId) {
+      is(label.dataset.l10nId, value, "The l10n-id is set");
+    } else {
+      is(label.textContent, value, "The textContent is set");
+    }
+  }
+  checkLabel("commandOne", "commandOne");
+  checkLabel("commandTwo", "Command Two!");
+  checkLabel("_execute_browser_action", "shortcuts-browserAction");
+
+  await close_manager(gManagerWindow);
+  await extension.unload();
+});
--- a/toolkit/themes/shared/extensions/extensions.inc.css
+++ b/toolkit/themes/shared/extensions/extensions.inc.css
@@ -245,21 +245,27 @@ button.warning {
 }
 
 @media (max-width: 600px) {
   #header-search {
     width: 12em;
   }
 }
 
+#manage-shortcuts {
+  margin: 0 4px;
+  min-height: 30px;
+}
+
 #header-utils-btn {
   -moz-appearance: none;
   border: 1px solid var(--in-content-box-border-color);
   border-radius: 2px;
   line-height: 20px;
+  min-height: 30px;
   background-color: var(--in-content-page-background);
   padding-right: 10px;
   padding-left: 10px;
   /* This button is too tall, adding margin-bottom shrinks it. */
   margin-bottom: 2px;
   margin-inline-end: 0;
 }
 
--- a/toolkit/themes/shared/in-content/common.inc.css
+++ b/toolkit/themes/shared/in-content/common.inc.css
@@ -62,16 +62,17 @@
   --grey-90: #0c0c0d;
   --grey-90-a10: rgba(12, 12, 13, 0.1);
   --grey-90-a20: rgba(12, 12, 13, 0.2);
   --grey-90-a30: rgba(12, 12, 13, 0.3);
   --grey-90-a40: rgba(12, 12, 13, 0.4);
   --grey-90-a50: rgba(12, 12, 13, 0.5);
   --red-50: #ff0039;
   --red-50-a30: rgba(255, 0, 57, 0.3);
+  --red-60: #d70022;
   --yellow-50: #ffe900;
   --yellow-90: #3e2800;
 
   --shadow-10: 0 1px 4px var(--grey-90-a10);
   --card-shadow: var(--shadow-10);
   --card-outline-color: var(--grey-30);
   --card-shadow-hover: var(--card-shadow), 0 0 0 5px var(--card-outline-color);
   --card-shadow-focus: 0 0 0 2px var(--blue-50), 0 0 0 6px var(--blue-50-a30);