summaryrefslogtreecommitdiffstats
path: root/toolkit/components/aboutconfig/content/aboutconfig.js
diff options
context:
space:
mode:
Diffstat (limited to 'toolkit/components/aboutconfig/content/aboutconfig.js')
-rw-r--r--toolkit/components/aboutconfig/content/aboutconfig.js696
1 files changed, 696 insertions, 0 deletions
diff --git a/toolkit/components/aboutconfig/content/aboutconfig.js b/toolkit/components/aboutconfig/content/aboutconfig.js
new file mode 100644
index 0000000000..e11021ed64
--- /dev/null
+++ b/toolkit/components/aboutconfig/content/aboutconfig.js
@@ -0,0 +1,696 @@
+/* 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/. */
+
+const { DeferredTask } = ChromeUtils.importESModule(
+ "resource://gre/modules/DeferredTask.sys.mjs"
+);
+const { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+const SEARCH_TIMEOUT_MS = 100;
+const SEARCH_AUTO_MIN_CRARACTERS = 3;
+
+const GETTERS_BY_PREF_TYPE = {
+ [Ci.nsIPrefBranch.PREF_BOOL]: "getBoolPref",
+ [Ci.nsIPrefBranch.PREF_INT]: "getIntPref",
+ [Ci.nsIPrefBranch.PREF_STRING]: "getStringPref",
+};
+
+const STRINGS_ADD_BY_TYPE = {
+ Boolean: "about-config-pref-add-type-boolean",
+ Number: "about-config-pref-add-type-number",
+ String: "about-config-pref-add-type-string",
+};
+
+// Fluent limits the maximum length of placeables.
+const MAX_PLACEABLE_LENGTH = 2500;
+
+let gDefaultBranch = Services.prefs.getDefaultBranch("");
+let gFilterPrefsTask = new DeferredTask(
+ () => filterPrefs(),
+ SEARCH_TIMEOUT_MS,
+ 0
+);
+
+/**
+ * Maps the name of each preference in the back-end to its PrefRow object,
+ * separating the preferences that actually exist. This is as an optimization to
+ * avoid querying the preferences service each time the list is filtered.
+ */
+let gExistingPrefs = new Map();
+let gDeletedPrefs = new Map();
+
+/**
+ * Also cache several values to improve the performance of common use cases.
+ */
+let gSortedExistingPrefs = null;
+let gSearchInput = null;
+let gShowOnlyModifiedCheckbox = null;
+let gPrefsTable = null;
+
+/**
+ * Reference to the PrefRow currently being edited, if any.
+ */
+let gPrefInEdit = null;
+
+/**
+ * Lowercase substring that should be contained in the preference name.
+ */
+let gFilterString = null;
+
+/**
+ * RegExp that should be matched to the preference name.
+ */
+let gFilterPattern = null;
+
+/**
+ * True if we were requested to show all preferences.
+ */
+let gFilterShowAll = false;
+
+class PrefRow {
+ constructor(name, opts) {
+ this.name = name;
+ this.value = true;
+ this.hidden = false;
+ this.odd = false;
+ this.editing = false;
+ this.isAddRow = opts && opts.isAddRow;
+ this.refreshValue();
+ }
+
+ refreshValue() {
+ let prefType = Services.prefs.getPrefType(this.name);
+
+ // If this preference has been deleted, we keep its last known value.
+ if (prefType == Ci.nsIPrefBranch.PREF_INVALID) {
+ this.hasDefaultValue = false;
+ this.hasUserValue = false;
+ this.isLocked = false;
+ if (gExistingPrefs.has(this.name)) {
+ gExistingPrefs.delete(this.name);
+ gSortedExistingPrefs = null;
+ }
+ gDeletedPrefs.set(this.name, this);
+ return;
+ }
+
+ if (!gExistingPrefs.has(this.name)) {
+ gExistingPrefs.set(this.name, this);
+ gSortedExistingPrefs = null;
+ }
+ gDeletedPrefs.delete(this.name);
+
+ try {
+ this.value = gDefaultBranch[GETTERS_BY_PREF_TYPE[prefType]](this.name);
+ this.hasDefaultValue = true;
+ } catch (ex) {
+ this.hasDefaultValue = false;
+ }
+ this.hasUserValue = Services.prefs.prefHasUserValue(this.name);
+ this.isLocked = Services.prefs.prefIsLocked(this.name);
+
+ try {
+ if (this.hasUserValue) {
+ // This can throw for locked preferences without a default value.
+ this.value = Services.prefs[GETTERS_BY_PREF_TYPE[prefType]](this.name);
+ } else if (/^chrome:\/\/.+\/locale\/.+\.properties/.test(this.value)) {
+ // We don't know which preferences should be read using getComplexValue,
+ // so we use a heuristic to determine if this is a localized preference.
+ // This can throw if there is no value in the localized files.
+ this.value = Services.prefs.getComplexValue(
+ this.name,
+ Ci.nsIPrefLocalizedString
+ ).data;
+ }
+ } catch (ex) {
+ this.value = "";
+ }
+ }
+
+ get type() {
+ return this.value.constructor.name;
+ }
+
+ get exists() {
+ return this.hasDefaultValue || this.hasUserValue;
+ }
+
+ get matchesFilter() {
+ if (!this.matchesModifiedFilter) {
+ return false;
+ }
+
+ return (
+ gFilterShowAll ||
+ (gFilterPattern && gFilterPattern.test(this.name)) ||
+ (gFilterString && this.name.toLowerCase().includes(gFilterString))
+ );
+ }
+
+ get matchesModifiedFilter() {
+ const onlyShowModified = gShowOnlyModifiedCheckbox.checked;
+ return !onlyShowModified || this.hasUserValue;
+ }
+
+ /**
+ * Returns a reference to the table row element to be added to the document,
+ * constructing and initializing it the first time this method is called.
+ */
+ getElement() {
+ if (this._element) {
+ return this._element;
+ }
+
+ this._element = document.createElement("tr");
+ this._element._pref = this;
+
+ let nameCell = document.createElement("th");
+ let nameCellSpan = document.createElement("span");
+ nameCell.appendChild(nameCellSpan);
+ this._element.append(
+ nameCell,
+ (this.valueCell = document.createElement("td")),
+ (this.editCell = document.createElement("td")),
+ (this.resetCell = document.createElement("td"))
+ );
+ this.editCell.appendChild(
+ (this.editButton = document.createElement("button"))
+ );
+ delete this.resetButton;
+
+ nameCell.setAttribute("scope", "row");
+ this.valueCell.className = "cell-value";
+ this.editCell.className = "cell-edit";
+ this.resetCell.className = "cell-reset";
+
+ // Add <wbr> behind dots to prevent line breaking in random mid-word places.
+ let parts = this.name.split(".");
+ for (let i = 0; i < parts.length - 1; i++) {
+ nameCellSpan.append(parts[i] + ".", document.createElement("wbr"));
+ }
+ nameCellSpan.append(parts[parts.length - 1]);
+
+ this.refreshElement();
+
+ return this._element;
+ }
+
+ refreshElement() {
+ if (!this._element) {
+ // No need to update if this preference was never added to the table.
+ return;
+ }
+
+ if (this.exists && !this.editing) {
+ // We need to place the text inside a "span" element to ensure that the
+ // text copied to the clipboard includes all whitespace.
+ let span = document.createElement("span");
+ span.textContent = this.value;
+ // We additionally need to wrap this with another "span" element to convey
+ // the state to screen readers without affecting the visual presentation.
+ span.setAttribute("aria-hidden", "true");
+ let outerSpan = document.createElement("span");
+ if (this.type == "String" && this.value.length > MAX_PLACEABLE_LENGTH) {
+ // If the value is too long for localization, don't include the state.
+ // Since the preferences system is designed to store short values, this
+ // case happens very rarely, thus we keep the same DOM structure for
+ // consistency even though we could avoid the extra "span" element.
+ outerSpan.setAttribute("aria-label", this.value);
+ } else {
+ let spanL10nId = this.hasUserValue
+ ? "about-config-pref-accessible-value-custom"
+ : "about-config-pref-accessible-value-default";
+ document.l10n.setAttributes(outerSpan, spanL10nId, {
+ value: "" + this.value,
+ });
+ }
+ outerSpan.appendChild(span);
+ this.valueCell.textContent = "";
+ this.valueCell.append(outerSpan);
+ if (this.type == "Boolean") {
+ document.l10n.setAttributes(
+ this.editButton,
+ "about-config-pref-toggle-button"
+ );
+ this.editButton.className = "button-toggle semi-transparent";
+ } else {
+ document.l10n.setAttributes(
+ this.editButton,
+ "about-config-pref-edit-button"
+ );
+ this.editButton.className = "button-edit semi-transparent";
+ }
+ this.editButton.removeAttribute("form");
+ delete this.inputField;
+ } else {
+ this.valueCell.textContent = "";
+ // The form is needed for the validation report to appear, but we need to
+ // prevent the associated button from reloading the page.
+ let form = document.createElement("form");
+ form.addEventListener("submit", event => event.preventDefault());
+ form.id = "form-edit";
+ if (this.editing) {
+ this.inputField = document.createElement("input");
+ this.inputField.value = this.value;
+ if (this.type == "Number") {
+ this.inputField.type = "number";
+ this.inputField.required = true;
+ this.inputField.min = -2147483648;
+ this.inputField.max = 2147483647;
+ } else {
+ this.inputField.type = "text";
+ }
+ form.appendChild(this.inputField);
+ document.l10n.setAttributes(
+ this.editButton,
+ "about-config-pref-save-button"
+ );
+ this.editButton.className = "primary button-save semi-transparent";
+ } else {
+ delete this.inputField;
+ for (let type of ["Boolean", "Number", "String"]) {
+ let radio = document.createElement("input");
+ radio.type = "radio";
+ radio.name = "type";
+ radio.value = type;
+ radio.checked = this.type == type;
+ let radioSpan = document.createElement("span");
+ document.l10n.setAttributes(radioSpan, STRINGS_ADD_BY_TYPE[type]);
+ let radioLabel = document.createElement("label");
+ radioLabel.append(radio, radioSpan);
+ form.appendChild(radioLabel);
+ }
+ form.addEventListener("click", event => {
+ if (event.target.name != "type") {
+ return;
+ }
+ let type = event.target.value;
+ if (this.type != type) {
+ if (type == "Boolean") {
+ this.value = true;
+ } else if (type == "Number") {
+ this.value = 0;
+ } else {
+ this.value = "";
+ }
+ }
+ });
+ document.l10n.setAttributes(
+ this.editButton,
+ "about-config-pref-add-button"
+ );
+ this.editButton.className = "button-add semi-transparent";
+ }
+ this.valueCell.appendChild(form);
+ this.editButton.setAttribute("form", "form-edit");
+ }
+ this.editButton.disabled = this.isLocked;
+ if (!this.isLocked && this.hasUserValue) {
+ if (!this.resetButton) {
+ this.resetButton = document.createElement("button");
+ this.resetCell.appendChild(this.resetButton);
+ }
+ if (!this.hasDefaultValue) {
+ document.l10n.setAttributes(
+ this.resetButton,
+ "about-config-pref-delete-button"
+ );
+ this.resetButton.className =
+ "button-delete ghost-button semi-transparent";
+ } else {
+ document.l10n.setAttributes(
+ this.resetButton,
+ "about-config-pref-reset-button"
+ );
+ this.resetButton.className =
+ "button-reset ghost-button semi-transparent";
+ }
+ } else if (this.resetButton) {
+ this.resetButton.remove();
+ delete this.resetButton;
+ }
+
+ this.refreshClass();
+ }
+
+ refreshClass() {
+ if (!this._element) {
+ // No need to update if this preference was never added to the table.
+ return;
+ }
+
+ let className;
+ if (this.hidden) {
+ className = "hidden";
+ } else {
+ className =
+ (this.hasUserValue ? "has-user-value " : "") +
+ (this.isLocked ? "locked " : "") +
+ (this.exists ? "" : "deleted ") +
+ (this.isAddRow ? "add " : "") +
+ (this.odd ? "odd " : "");
+ }
+
+ if (this._lastClassName !== className) {
+ this._element.className = this._lastClassName = className;
+ }
+ }
+
+ edit() {
+ if (gPrefInEdit) {
+ gPrefInEdit.endEdit();
+ }
+ gPrefInEdit = this;
+ this.editing = true;
+ this.refreshElement();
+ // The type=number input isn't selected unless it's focused first.
+ this.inputField.focus();
+ this.inputField.select();
+ }
+
+ toggle() {
+ Services.prefs.setBoolPref(this.name, !this.value);
+ }
+
+ editOrToggle() {
+ if (this.type == "Boolean") {
+ this.toggle();
+ } else {
+ this.edit();
+ }
+ }
+
+ save() {
+ if (this.type == "Number") {
+ if (!this.inputField.reportValidity()) {
+ return;
+ }
+ Services.prefs.setIntPref(this.name, parseInt(this.inputField.value));
+ } else {
+ Services.prefs.setStringPref(this.name, this.inputField.value);
+ }
+ this.refreshValue();
+ this.endEdit();
+ this.editButton.focus();
+ }
+
+ endEdit() {
+ this.editing = false;
+ this.refreshElement();
+ gPrefInEdit = null;
+ }
+}
+
+let gPrefObserverRegistered = false;
+let gPrefObserver = {
+ observe(subject, topic, data) {
+ let pref = gExistingPrefs.get(data) || gDeletedPrefs.get(data);
+ if (pref) {
+ pref.refreshValue();
+ if (!pref.editing) {
+ pref.refreshElement();
+ }
+ return;
+ }
+
+ let newPref = new PrefRow(data);
+ if (newPref.matchesFilter) {
+ document.getElementById("prefs").appendChild(newPref.getElement());
+ }
+ },
+};
+
+if (!Preferences.get("browser.aboutConfig.showWarning")) {
+ // When showing the filtered preferences directly, remove the warning elements
+ // immediately to prevent flickering, but wait to filter the preferences until
+ // the value of the textbox has been restored from previous sessions.
+ document.addEventListener("DOMContentLoaded", loadPrefs, { once: true });
+ window.addEventListener(
+ "load",
+ () => {
+ if (document.getElementById("about-config-search").value) {
+ filterPrefs();
+ }
+ },
+ { once: true }
+ );
+} else {
+ document.addEventListener("DOMContentLoaded", function () {
+ let warningButton = document.getElementById("warningButton");
+ warningButton.addEventListener("click", onWarningButtonClick);
+ warningButton.focus({ focusVisible: false });
+ });
+}
+
+function onWarningButtonClick() {
+ Services.prefs.setBoolPref(
+ "browser.aboutConfig.showWarning",
+ document.getElementById("showWarningNextTime").checked
+ );
+ loadPrefs();
+}
+
+function loadPrefs() {
+ [...document.styleSheets].find(s => s.title == "infop").disabled = true;
+
+ let { content } = document.getElementById("main");
+ document.body.textContent = "";
+ document.body.appendChild(content);
+
+ let search = (gSearchInput = document.getElementById("about-config-search"));
+ let prefs = (gPrefsTable = document.getElementById("prefs"));
+ let showAll = document.getElementById("show-all");
+ gShowOnlyModifiedCheckbox = document.getElementById(
+ "about-config-show-only-modified"
+ );
+ search.focus();
+ gShowOnlyModifiedCheckbox.checked = false;
+
+ for (let name of Services.prefs.getChildList("")) {
+ new PrefRow(name);
+ }
+
+ search.addEventListener("keypress", event => {
+ if (event.key == "Escape") {
+ // The ESC key returns immediately to the initial empty page.
+ search.value = "";
+ gFilterPrefsTask.disarm();
+ filterPrefs();
+ } else if (event.key == "Enter") {
+ // The Enter key filters immediately even if the search string is short.
+ gFilterPrefsTask.disarm();
+ filterPrefs({ shortString: true });
+ }
+ });
+
+ search.addEventListener("input", () => {
+ // We call "disarm" to restart the timer at every input.
+ gFilterPrefsTask.disarm();
+ if (search.value.trim().length < SEARCH_AUTO_MIN_CRARACTERS) {
+ // Return immediately to the empty page if the search string is short.
+ filterPrefs();
+ } else {
+ gFilterPrefsTask.arm();
+ }
+ });
+
+ gShowOnlyModifiedCheckbox.addEventListener("change", () => {
+ // This checkbox:
+ // - Filters results to only modified prefs when search query is entered
+ // - Shows all modified prefs, in show all mode, and after initial checkbox click
+ let tableHidden = !document.body.classList.contains("table-shown");
+ filterPrefs({
+ showAll:
+ gFilterShowAll || (gShowOnlyModifiedCheckbox.checked && tableHidden),
+ });
+ });
+
+ showAll.addEventListener("click", event => {
+ search.focus();
+ search.value = "";
+ gFilterPrefsTask.disarm();
+ filterPrefs({ showAll: true });
+ });
+
+ function shouldBeginEdit(event) {
+ if (
+ event.target.localName != "button" &&
+ event.target.localName != "input"
+ ) {
+ let row = event.target.closest("tr");
+ return row && row._pref.exists;
+ }
+ return false;
+ }
+
+ // Disable double/triple-click text selection since that triggers edit/toggle.
+ prefs.addEventListener("mousedown", event => {
+ if (event.detail > 1 && shouldBeginEdit(event)) {
+ event.preventDefault();
+ }
+ });
+
+ prefs.addEventListener("click", event => {
+ if (event.detail == 2 && shouldBeginEdit(event)) {
+ event.target.closest("tr")._pref.editOrToggle();
+ return;
+ }
+
+ if (event.target.localName != "button") {
+ return;
+ }
+
+ let pref = event.target.closest("tr")._pref;
+ let button = event.target.closest("button");
+
+ if (button.classList.contains("button-add")) {
+ pref.isAddRow = false;
+ Preferences.set(pref.name, pref.value);
+ if (pref.type == "Boolean") {
+ pref.refreshClass();
+ } else {
+ pref.edit();
+ }
+ } else if (
+ button.classList.contains("button-toggle") ||
+ button.classList.contains("button-edit")
+ ) {
+ pref.editOrToggle();
+ } else if (button.classList.contains("button-save")) {
+ pref.save();
+ } else {
+ // This is "button-reset" or "button-delete".
+ pref.editing = false;
+ Services.prefs.clearUserPref(pref.name);
+ pref.editButton.focus();
+ }
+ });
+
+ window.addEventListener("keypress", event => {
+ if (event.target != search && event.key == "Escape" && gPrefInEdit) {
+ gPrefInEdit.endEdit();
+ }
+ });
+}
+
+function filterPrefs(options = {}) {
+ if (gPrefInEdit) {
+ gPrefInEdit.endEdit();
+ }
+ gDeletedPrefs.clear();
+
+ let searchName = gSearchInput.value.trim();
+ if (searchName.length < SEARCH_AUTO_MIN_CRARACTERS && !options.shortString) {
+ searchName = "";
+ }
+
+ gFilterString = searchName.toLowerCase();
+ gFilterShowAll = !!options.showAll;
+
+ gFilterPattern = null;
+ if (gFilterString.includes("*")) {
+ gFilterPattern = new RegExp(gFilterString.replace(/\*+/g, ".*"), "i");
+ gFilterString = "";
+ }
+
+ let showResults = gFilterString || gFilterPattern || gFilterShowAll;
+ document.body.classList.toggle("table-shown", showResults);
+
+ let prefArray = [];
+ if (showResults) {
+ if (!gSortedExistingPrefs) {
+ gSortedExistingPrefs = [...gExistingPrefs.values()];
+ gSortedExistingPrefs.sort((a, b) => a.name > b.name);
+ }
+ prefArray = gSortedExistingPrefs;
+ }
+
+ // The slowest operations tend to be the addition and removal of DOM nodes, so
+ // this algorithm tries to reduce removals by hiding nodes instead. This
+ // happens frequently when the set narrows while typing preference names. We
+ // iterate the nodes already in the table in parallel to those we want to
+ // show, because the two lists are sorted and they will often match already.
+ let fragment = null;
+ let indexInArray = 0;
+ let elementInTable = gPrefsTable.firstElementChild;
+ let odd = false;
+ let hasVisiblePrefs = false;
+ while (indexInArray < prefArray.length || elementInTable) {
+ // For efficiency, filter the array while we are iterating.
+ let prefInArray = prefArray[indexInArray];
+ if (prefInArray) {
+ if (!prefInArray.matchesFilter) {
+ indexInArray++;
+ continue;
+ }
+ prefInArray.hidden = false;
+ prefInArray.odd = odd;
+ }
+
+ let prefInTable = elementInTable && elementInTable._pref;
+ if (!prefInTable) {
+ // We're at the end of the table, we just have to insert all the matching
+ // elements that remain in the array. We can use a fragment to make the
+ // insertions faster, which is useful during the initial filtering.
+ if (!fragment) {
+ fragment = document.createDocumentFragment();
+ }
+ fragment.appendChild(prefInArray.getElement());
+ } else if (prefInTable == prefInArray) {
+ // We got two matching elements, we just need to update the visibility.
+ elementInTable = elementInTable.nextElementSibling;
+ } else if (prefInArray && prefInArray.name < prefInTable.name) {
+ // The iteration in the table is ahead of the iteration in the array.
+ // Insert or move the array element, and advance the array index.
+ gPrefsTable.insertBefore(prefInArray.getElement(), elementInTable);
+ } else {
+ // The iteration in the array is ahead of the iteration in the table.
+ // Hide the element in the table, and advance to the next element.
+ let nextElementInTable = elementInTable.nextElementSibling;
+ if (!prefInTable.exists) {
+ // Remove rows for deleted preferences, or temporary addition rows.
+ elementInTable.remove();
+ } else {
+ // Keep the element for the next filtering if the preference exists.
+ prefInTable.hidden = true;
+ prefInTable.refreshClass();
+ }
+ elementInTable = nextElementInTable;
+ continue;
+ }
+
+ prefInArray.refreshClass();
+ odd = !odd;
+ indexInArray++;
+ hasVisiblePrefs = true;
+ }
+
+ if (fragment) {
+ gPrefsTable.appendChild(fragment);
+ }
+
+ gPrefsTable.toggleAttribute("has-visible-prefs", hasVisiblePrefs);
+
+ if (searchName && !gExistingPrefs.has(searchName)) {
+ let addPrefRow = new PrefRow(searchName, { isAddRow: true });
+ addPrefRow.odd = odd;
+ gPrefsTable.appendChild(addPrefRow.getElement());
+ }
+
+ // We only start observing preference changes after the first search is done,
+ // so that newly added preferences won't appear while the page is still empty.
+ if (!gPrefObserverRegistered) {
+ gPrefObserverRegistered = true;
+ Services.prefs.addObserver("", gPrefObserver);
+ window.addEventListener(
+ "unload",
+ () => {
+ Services.prefs.removeObserver("", gPrefObserver);
+ },
+ { once: true }
+ );
+ }
+}